Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ jobs:
ValidatePrTitle:
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
- uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4 changes: 2 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

Expand Down Expand Up @@ -79,7 +79,7 @@ jobs:
Write-Host "Testing source archive pinned to $env:HEAD_SHA"

- name: Setup Tooling
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.2

Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,34 @@ For opencode v2:

opencode does not auto-update plugins. To update, modify the version number in your config file.

On v2, use the `opencode_sync` tool for sync operations. The `/sync-*` slash
commands are available on v1 only. V2 cannot display a slash command's direct
result without placing it in the model's prompt queue, which could trigger
another operation. For example, ask OpenCode to call `opencode_sync` with
`{"command":"status"}` to inspect the current state.

## Configure

### First machine (create new sync repo)

Run `/sync-init` to create a new sync repo:
On v1, run `/sync-init` to create a new sync repo. On v2, ask OpenCode to call
`opencode_sync` with `{"command":"init"}`:

1. Detects your GitHub username
2. Creates a private repo (`my-opencode-config` by default)
3. Clones the repo and pushes your current config

### Additional machines (link to existing repo)

Run `/sync-link` to connect to your existing sync repo:
On v1, run `/sync-link` to connect to your existing sync repo. On v2, ask
OpenCode to call `opencode_sync` with `{"command":"link"}`:

1. Searches your GitHub for common sync repo names (prioritizes `my-opencode-config`)
2. Clones and applies the synced config
3. **Overwrites local config** with synced content (preserves your local overrides file)

If auto-detection fails, specify the repo name: `/sync-link my-opencode-config`
If auto-detection fails, specify the repo name with `/sync-link my-opencode-config`
on v1 or `{"command":"link","repo":"my-opencode-config"}` on v2.

After linking, restart opencode to apply the synced settings.

Expand Down Expand Up @@ -196,6 +205,9 @@ Best-effort session artifact sync via Git paths:
- `~/.local/share/opencode/storage/session_diff/`

This mode can conflict with concurrent writers.
`/sync-link` restores the repo's session snapshot on a new machine. On an already linked machine,
`/sync-pull` reapplies that snapshot even when Git has no new commits, replacing local session
artifacts. Restart opencode after either command to load the restored sessions.

Large `opencode.db` files and legacy files under `storage/message/` are represented as a small,
versioned pointer plus 40 MiB parts once they exceed 50 MiB. Parts live in the plugin-owned
Expand Down
39 changes: 18 additions & 21 deletions docs/v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,22 @@ Overrides mapping
Override key V2 destination Notes
mcp `ctx.mcp.transform` (set/update per server) Unresolvable `{env:…}`: store secret-free copy (`blankEnvPlaceholders` in `src/sync/config.ts`) with `disabled:true` + `console.error`. Mirrors v1 `disableMcpServerForResolutionFailure` intent without mutating caller state.
agent `ctx.agent.transform` (update-only) Editor cannot create agents; unknown IDs warn once outside the transform, skip silently inside.
model `ctx.model.transform` (update-only) Non-object shapes warn; unknown provider IDs warn via `ctx.model.provider.list()` best-effort; unknown model IDs skip silently (no bulk "has" API).
model `ctx.model.transform` (update-only) Non-object shapes warn; unknown provider IDs warn via `ctx.provider.list()` best-effort; unknown model IDs skip silently (no bulk "has" API).
provider `ctx.provider.transform` (update-only) Unknown IDs warn once outside via `ctx.provider.list()`, skip silently inside.
command Ignored with warn Sync commands are owned by this plugin and registered via `ctx.command.transform` from `src/command/*.md`. External `command` overrides are not applied.
command Ignored with warn V2 uses the `opencode_sync` tool. Slash commands and external `command` overrides are not registered.
everything else `console.warn` with key name + docs pointer No fake global merge.

File-level behavior (`syncRepoToLocal`/`syncLocalToRepo`, `stripOverrides` in
`src/sync/apply.ts`, `src/sync/config.ts`) is runtime-independent and unchanged.

V2 command + tool limits (documented, not bugs)
- `CommandDefinition` only carries `name/description/execute` — unlike v1
`config.command` there is no `template/agent/model/subtask`. The md template
is executed directly: run service → post via `ctx.session.synthetic`.
- Slash commands only carry free text (`prompt.text`). Only a single bare
`owner/repo` (or URL) is parsed (`parseCommandRepoArg` in `src/v2.ts` takes
the first token, strips quotes/`$ARGUMENTS`); `init`/`link` extra flags and
`enable-secrets`/Turso options are not parseable from slash text — use the
`opencode_sync` tool for full args.
V2 command + tool limits
- V2 does not register `/sync-*` slash commands. Its `CommandDefinition`
callback has no direct result channel. Posting command output with
`session.synthetic` resumes the model as if the output were a new user prompt;
even `/sync-status` can then prompt a follow-up sync operation. Using
`resume: false` prevents that operation but leaves the output in the inbox
rather than the visible session transcript. Use the `opencode_sync` tool,
which returns the result as tool content.
- `Tool.Result.content` accepts `string | Content[]`; we return a plain string
to keep status output readable.

Expand All @@ -61,29 +60,27 @@ Implementation map
no-op toast — v2 has neither toast nor log sink), session-status facade
returns `{data:{}}` (empty = idle → Turso idle-gating intentionally skipped,
syncs immediately), AI via `generate.text`. Registers tool (JSON Schema,
`required:["command"]`), commands (parse bare repo arg, post via
`session.synthetic`), mcp/agent/model/provider transforms,
`required:["command"]`), mcp/agent/model/provider transforms,
`event.subscribe` → `service.handleEvent`, timed startup sync with dispose
cleanup (`clearTimeout` + `abort` + `service.dispose()` which stops the
Turso sync loop/idle-flush timers).
4. Dual entrypoint (`src/index.ts`) — `opencodeConfigSync` untouched; explicit
default export `{ id, setup: setupV2, server }` (no spread so runtimes do not
leak fields). Deps: `@opencode-ai/plugin ^1.18.29`, `@opencode/plugin ^2.0.0`.
README documents minimum versions.
5. Tests — `src/v2.test.ts`: dual export shape, mock-ctx tool/command/mcp/event
registration, status round-trip, missing-env disables server, transform
replay purity (no warn on second replay), Turso immediate-sync (empty status
= idle), `parseCommandRepoArg` edge cases. Existing v1 tests unchanged.
5. Tests — `src/v2.test.ts`: dual export shape, mock-ctx tool/mcp/event
registration, status round-trip without slash commands or synthetic messages,
missing-env disables server, transform replay purity (no warn on second
replay), Turso immediate-sync (empty status = idle). Existing v1 tests unchanged.
6. Local verify — `bun install`, `bun run check`, `bun test`, `bun run build`;
manual packed-tarball smoke on opencode v1 (`plugin`) and v2 (`plugins`).
7. CI — build assertion that `dist` exposes both `setup` and `server`; unit
tests; matrix smoke (v1 + v2 installs vs packed plugin, fail on early exit).
7. CI — lint, unit tests, build, and Windows path tests. Packed v1/v2 host
installation is a manual verification step, not a CI matrix job.

Risks / known parity gaps
- Non-MCP runtime keys warn-only in v2 (no global merge possible).
- No toasts in v2 (console + `app.log` facade); no session-idle gating (empty
status map = idle → immediate Turso sync).
- AI messages fall back to static when no model is available.
- Slash commands support bare repo/backend token only; full options require the
`opencode_sync` tool.
- V2 sync operations require the `opencode_sync` tool; v1 keeps `/sync-*` commands.
- Overrides require restart; no watcher/`reload()` calls.
17 changes: 17 additions & 0 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ export async function syncRepoToLocal(
}
}

export async function syncSessionArtifactsRepoToLocal(
plan: SyncPlan,
options: { chunkOptions?: ChunkOptions } = {}
): Promise<boolean> {
let restored = false;
for (const item of plan.items) {
if (!item.preserveWhenMissing || !(await pathExists(item.repoPath))) continue;
if (item.chunkLargeFiles) {
await copyChunkableItemFromRepo(item, plan.repoRoot, options.chunkOptions);
} else {
await copyItem(item.repoPath, item.localPath, item.type);
}
restored = true;
}
return restored;
}

export async function syncLocalToRepo(
plan: SyncPlan,
overrides: Record<string, unknown> | null,
Expand Down
84 changes: 83 additions & 1 deletion src/sync/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { promisify } from 'node:util';

import type { PluginInput } from '@opencode-ai/plugin';
import { describe, expect, it } from 'vitest';
import { loadState, loadSyncConfig, writeSyncConfig } from './config.js';
import { loadState, loadSyncConfig, writeState, writeSyncConfig } from './config.js';
import { resolveSyncLocations } from './paths.js';
import { createSyncService } from './service.js';

Expand Down Expand Up @@ -110,6 +110,88 @@ async function withIsolatedEnvironment(run: (root: string) => Promise<void>): Pr
}

describe('explicit Git remote service flow', () => {
it('restores sessions on link and an up-to-date pull', async () => {
await withIsolatedEnvironment(async (root) => {
const testShell = createTestShell();
const writeSqlite = async (dbPath: string, sql: string): Promise<void> => {
const script =
'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); db.executescript(sys.argv[2]); db.commit(); db.close()';
await testShell`python3 -c ${script} ${dbPath} ${sql}`.quiet();
};
const readSessionTitle = async (dbPath: string): Promise<string> => {
const script =
'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); row = db.execute("SELECT title FROM session WHERE id = ?", ("ses_issue_51",)).fetchone(); print(row[0] if row else ""); db.close()';
return (await testShell`python3 -c ${script} ${dbPath}`.text()).trim();
};
const remotePath = path.join(root, 'sync-remote.git');
await testShell`git init --bare ${remotePath}`.quiet();

const machineAHome = path.join(root, 'machine-a');
useIsolatedHome(machineAHome);
const machineALocations = resolveSyncLocations();
await fs.mkdir(machineALocations.configRoot, { recursive: true });
await fs.writeFile(path.join(machineALocations.configRoot, 'opencode.json'), '{}\n');
const machineADbPath = path.join(machineALocations.xdg.dataDir, 'opencode', 'opencode.db');
await fs.mkdir(path.dirname(machineADbPath), { recursive: true });
await writeSqlite(
machineADbPath,
"CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT); INSERT INTO session VALUES ('ses_issue_51', 'Machine A');"
);

const machineAService = createSyncService({ client: createClient(), $: testShell });
await machineAService.init({
repo: remotePath,
branch: 'main',
includeSecrets: true,
includeSessions: true,
acknowledgePrivateRemote: true,
});

useIsolatedHome(path.join(root, 'machine-unacknowledged'));
const unacknowledgedLocations = resolveSyncLocations();
const unacknowledgedService = createSyncService({ client: createClient(), $: testShell });
await expect(
unacknowledgedService.link({ repo: remotePath, branch: 'main' })
).rejects.toThrow('privacy cannot be verified');
await expect(
fs.stat(path.join(unacknowledgedLocations.xdg.dataDir, 'opencode', 'opencode.db'))
).rejects.toMatchObject({ code: 'ENOENT' });

const machineBHome = path.join(root, 'machine-b');
useIsolatedHome(machineBHome);
const machineBLocations = resolveSyncLocations();
const machineBDbPath = path.join(machineBLocations.xdg.dataDir, 'opencode', 'opencode.db');
const machineBService = createSyncService({ client: createClient(), $: testShell });
await machineBService.link({
repo: remotePath,
branch: 'main',
acknowledgePrivateRemote: true,
});

await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A');
const stateAfterLink = await loadState(machineBLocations);
expect(stateAfterLink.lastRemoteUpdate).toBeDefined();
await writeState(machineBLocations, { ...stateAfterLink, lastPull: undefined });
await expect(machineBService.status()).resolves.toContain('Last pull: never');

await writeSqlite(machineBDbPath, 'DELETE FROM session;');

await fs.writeFile(
path.join(machineBLocations.configRoot, 'opencode.json'),
'{"theme":"local"}\n'
);

await expect(machineBService.pull()).resolves.toContain('Restart opencode to load them');
await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A');
await expect(machineBService.status()).resolves.toMatch(/Last pull: \d{4}-\d\d-\d\dT/u);
const stateAfterPull = await loadState(machineBLocations);
expect(stateAfterPull.lastRemoteUpdate).toBe(stateAfterLink.lastRemoteUpdate);
await expect(
fs.readFile(path.join(machineBLocations.configRoot, 'opencode.json'), 'utf8')
).resolves.toContain('local');
});
}, 30_000);

it('initializes, links, pushes, and pulls through a local bare remote', async () => {
await withIsolatedEnvironment(async (root) => {
const testShell = createTestShell();
Expand Down
18 changes: 17 additions & 1 deletion src/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
parseResolutionDecision,
type ResolutionDecision,
} from './ai.js';
import { syncLocalToRepo, syncRepoToLocal } from './apply.js';
import { syncLocalToRepo, syncRepoToLocal, syncSessionArtifactsRepoToLocal } from './apply.js';
import { generateCommitMessage } from './commit.js';
import type { NormalizedSyncConfig } from './config.js';
import {
Expand Down Expand Up @@ -939,6 +939,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
await acknowledgePrivateRemote(locations, syncedConfig);
}
await ensureSensitiveSyncPolicy(ctx, locations, syncedConfig);
const sessionPlan = buildSyncPlan(syncedConfig, locations, repoRoot);
await syncSessionArtifactsRepoToLocal(sessionPlan);
}
if (syncedConfig && isTursoSessionBackend(syncedConfig)) {
const setup = await runTursoSetup(syncedConfig, { allowLogin: true });
Expand Down Expand Up @@ -994,8 +996,22 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {

const update = await fetchAndFastForward(ctx.$, repoRoot, branch);
if (!update.updated) {
const plan = buildSyncPlan(config, locations, repoRoot);
const restoredSessions = await syncSessionArtifactsRepoToLocal(plan);
const tursoSummary = await runForegroundTursoCycle(config, 'pull-up-to-date');
ensureTursoSyncLoop(config);
if (restoredSessions) {
await updateState(locations, { lastPull: new Date().toISOString() });
await showToast(
ctx.client,
'Sessions restored. Restart opencode to load them.',
'info'
);
return [
'Remote sessions restored. Restart opencode to load them.',
...(tursoSummary ? [tursoSummary] : []),
].join('\n');
}
if (tursoSummary) {
return ['Already up to date.', tursoSummary].join('\n');
}
Expand Down
Loading
Loading