Thomas Wolf Claude Opus 5 commited on
Commit
166e4e9
·
unverified ·
1 Parent(s): 0c0b094

opencode: resume the pinned conversation, not the folder's most recent (#27)

Browse files

The re-pin watcher keeps an opencode session's `ses_…` id current, but the
launch line never asked for it: opencode was resumed with `--continue`, which
takes the most-recent conversation in the working directory. So the pin decided
what the Overview digest, the trace panel and sharing read, while `--continue`
decided what the pane actually came back on -- two different conversations
whenever the folder's newest was not this session's own.

That happens in the two cases the pin exists to handle. After a reset the
watcher follows the successor, but the pane on restart would take whichever
conversation the folder touched last. And in a folder shared by two live
opencode sessions `--continue` hands BOTH the same conversation, which is why
resuming there was refused outright and a restart silently started fresh.

opencode does take a conversation id -- `--session <ses_…>` (1.18.9) -- so ask
for the pinned one. A session with the folder to itself keeps its conversation
across restarts; two sessions sharing a folder now each get their own back
instead of neither.

The id is existence-checked against the db before it reaches the shell, because
a missing row is fatal rather than a fallback: opencode exits 1 with "Session
not found" on sight, which would kill the pane. A purged conversation therefore
falls back to the old `--continue`/fresh path. The id is shape-checked too --
unlike Claude's uuid, which we mint, this one comes back out of a database and
is interpolated unquoted.

Verified against the installed opencode (1.18.9): a real id keeps the TUI up,
an unknown one exits 1 immediately.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

server/package.json CHANGED
@@ -13,7 +13,7 @@
13
  "scripts": {
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
- "test": "node test/repin.test.mjs && node migration.test.mjs && node resize.test.mjs"
17
  },
18
  "engines": {
19
  "node": ">=20.19"
 
13
  "scripts": {
14
  "start": "node src/index.js",
15
  "dev": "node --watch src/index.js",
16
+ "test": "node test/repin.test.mjs && node test/opencode-resume.test.mjs && node migration.test.mjs && node resize.test.mjs"
17
  },
18
  "engines": {
19
  "node": ">=20.19"
server/src/runner.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
  import { remoteState, setPaused } from './remote.js';
6
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
  import { update, list } from './sessions.js';
8
- import { captureOpencodeSession, readTrace } from './traces.js';
9
  import {
10
  buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
11
  } from './snapshot.js';
@@ -1147,7 +1147,12 @@ function scheduleOpencodeCapture(session, workdir) {
1147
  // Single-quote a string for embedding in an `sh -lc` command line.
1148
  const shq = (t) => `'${String(t).replace(/'/g, `'\\''`)}'`;
1149
 
1150
- function commandFor(session) {
 
 
 
 
 
1151
  const cli = cliById(session.cli) || cliById('shell');
1152
  if (cli.id === 'shell') return bashLaunch;
1153
  // Quickstart: a prompt queued at creation rides the FIRST launch command
@@ -1202,11 +1207,24 @@ function commandFor(session) {
1202
  const guard = 'G="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"; '
1203
  + 'mkdir -p "$(dirname "$G")"; [ -d "$G" ] && rm -rf "$G"; '
1204
  + '[ -e "$G" ] || { [ -f "${G}c" ] && cp "${G}c" "$G" || echo "{}" > "$G"; }; ';
1205
- // `--continue` resumes the most-recent conversation in the CWD, so two
1206
- // opencode agents sharing a folder would resume onto the SAME conversation
1207
- // (cross-talk) same hazard as codex `resume --last`. Only continue when
1208
- // this session holds its folder alone; otherwise start fresh, and capture
1209
- // pins the new conversation right away (see scheduleOpencodeCapture).
 
 
 
 
 
 
 
 
 
 
 
 
 
1210
  const folder = session.path ?? session.id;
1211
  const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
1212
  const base = session.everStarted && cli.cont && !shared
 
5
  import { remoteState, setPaused } from './remote.js';
6
  import { cliById, isRemote, STATE_DIR, WORKSPACES_DIR } from './config.js';
7
  import { update, list } from './sessions.js';
8
+ import { captureOpencodeSession, opencodeSessionExists, readTrace } from './traces.js';
9
  import {
10
  buildPaletteIndex, snapshotToRestoreAnsi, styledSnapshotLines, textColumns,
11
  } from './snapshot.js';
 
1147
  // Single-quote a string for embedding in an `sh -lc` command line.
1148
  const shq = (t) => `'${String(t).replace(/'/g, `'\\''`)}'`;
1149
 
1150
+ // Conversation ids reach the launch line unquoted, and this one comes back out
1151
+ // of a database rather than from us (Claude's uuid we mint ourselves). Shape-check
1152
+ // it so nothing but an opencode session id can ever be interpolated.
1153
+ const SES_ID = /^ses_[A-Za-z0-9_-]+$/;
1154
+
1155
+ export function commandFor(session) {
1156
  const cli = cliById(session.cli) || cliById('shell');
1157
  if (cli.id === 'shell') return bashLaunch;
1158
  // Quickstart: a prompt queued at creation rides the FIRST launch command
 
1207
  const guard = 'G="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/opencode.json"; '
1208
  + 'mkdir -p "$(dirname "$G")"; [ -d "$G" ] && rm -rf "$G"; '
1209
  + '[ -e "$G" ] || { [ -f "${G}c" ] && cp "${G}c" "$G" || echo "{}" > "$G"; }; ';
1210
+ // Resume the conversation this session is PINNED to, by id. `--continue`
1211
+ // (below) takes the most-recent conversation in the CWD instead, which
1212
+ // ignores the pin entirely: after a reset a restart came back on whichever
1213
+ // conversation the folder touched last, and the pin the watcher maintains —
1214
+ // the one the digest, the trace panel and sharing all read — described a
1215
+ // different thread than the pane was showing. Existence-checked in JS rather
1216
+ // than in the shell, because the db is one file for all conversations (no
1217
+ // per-conversation path to test) and a missing row is fatal, not a fallback.
1218
+ const pin = session.opencodeSessionId;
1219
+ if (session.everStarted && pin && SES_ID.test(pin) && opencodeSessionExists(pin)) {
1220
+ return `${guard}exec opencode --session ${pin}`;
1221
+ }
1222
+ // Unpinned (or the conversation is gone): `--continue` resumes the
1223
+ // most-recent conversation in the CWD, so two opencode agents sharing a
1224
+ // folder would resume onto the SAME one (cross-talk) — same hazard as codex
1225
+ // `resume --last`. Only continue when this session holds its folder alone;
1226
+ // otherwise start fresh, and capture pins the new conversation right away
1227
+ // (see scheduleOpencodeCapture).
1228
  const folder = session.path ?? session.id;
1229
  const shared = list().some((o) => o.id !== session.id && o.cli === 'opencode' && (o.path ?? o.id) === folder);
1230
  const base = session.everStarted && cli.cont && !shared
server/src/traces.js CHANGED
@@ -433,6 +433,21 @@ export function captureOpencodeSession(directory, sinceMs, claimed) {
433
  } catch { return null; } finally { try { db.close(); } catch {} }
434
  }
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  // ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ----------
437
  // sessions carry cwd + token totals; messages carry role/content/tool_name.
438
  // Timestamps are float SECONDS — converted to ms for digest fields.
 
433
  } catch { return null; } finally { try { db.close(); } catch {} }
434
  }
435
 
436
+ // Does a pinned opencode conversation still exist? The launch line resumes by id
437
+ // (`opencode --session <ses_…>`), and opencode exits 1 with "Session not found"
438
+ // the moment the row is gone — verified on 1.18.9 — which would kill the pane on
439
+ // sight. So the pin is checked here, before it can reach the shell: a purged
440
+ // conversation starts fresh instead, the same honest fallback the Claude
441
+ // transcript check and the codex rollout check give.
442
+ export function opencodeSessionExists(id) {
443
+ if (!DatabaseSync || !id) return false;
444
+ let db;
445
+ try { db = new DatabaseSync(opencodeDbPath(), { readOnly: true }); } catch { return false; }
446
+ try {
447
+ return !!db.prepare('select 1 from session where id = ?').get(id);
448
+ } catch { return false; } finally { try { db.close(); } catch {} }
449
+ }
450
+
451
  // ---------- Hermes (SQLite: ~/.hermes/state.db, WAL) ----------
452
  // sessions carry cwd + token totals; messages carry role/content/tool_name.
453
  // Timestamps are float SECONDS — converted to ms for digest fields.
server/test/opencode-resume.test.mjs ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Which conversation an opencode pane comes back on after a restart.
2
+ //
3
+ // Regression cover for the bug where the launch line used `--continue` — the
4
+ // most-recent conversation in the CWD — and so ignored the pin the watcher
5
+ // maintains: a restart could land on a conversation the pane had left, or in a
6
+ // shared folder on a sibling's. Run with: node test/opencode-resume.test.mjs
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import os from 'node:os';
10
+ import { DatabaseSync } from 'node:sqlite';
11
+
12
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-resume-'));
13
+ const XDG = path.join(TMP, 'xdg');
14
+ const DATA = path.join(TMP, 'data');
15
+ fs.mkdirSync(path.join(XDG, 'opencode'), { recursive: true });
16
+ fs.mkdirSync(DATA, { recursive: true });
17
+
18
+ process.env.XDG_DATA_HOME = XDG;
19
+ process.env.DATA_DIR = DATA;
20
+
21
+ // Only the columns the runner reads. opencode's real table has ~30 more; a
22
+ // narrower one still proves the query, and drifts less.
23
+ const DB = path.join(XDG, 'opencode', 'opencode.db');
24
+ const db = new DatabaseSync(DB);
25
+ db.exec('create table session (id text primary key, directory text not null, time_created integer not null)');
26
+ const addRow = (id, directory, timeCreated) =>
27
+ db.prepare('insert into session (id, directory, time_created) values (?, ?, ?)').run(id, directory, timeCreated);
28
+
29
+ const sessions = await import('../src/sessions.js');
30
+ const runner = await import('../src/runner.js');
31
+ const traces = await import('../src/traces.js');
32
+ sessions.init();
33
+
34
+ let pass = 0, fail = 0;
35
+ const check = (name, got, want) => {
36
+ const ok = got === want;
37
+ ok ? pass++ : fail++;
38
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `\n got ${got}\n want ${want}`}`);
39
+ };
40
+ const has = (name, hay, needle) => check(name, String(hay).includes(needle), true);
41
+
42
+ const LIVE = 'ses_0325987abffej9UeLKjc55GHK8';
43
+ const GONE = 'ses_099999999ffezzzzzzzzzzzzzzz';
44
+ addRow(LIVE, '/data/workspaces/proj-a', 1770000000000);
45
+
46
+ console.log('\nthe db decides whether a pin is still resumable');
47
+ check('live row found', traces.opencodeSessionExists(LIVE), true);
48
+ check('purged row not found', traces.opencodeSessionExists(GONE), false);
49
+ check('no id is not a row', traces.opencodeSessionExists(null), false);
50
+
51
+ console.log('\na restart resumes the pinned conversation, not the folder\'s newest');
52
+ const s = sessions.create({ name: 'oc', cli: 'opencode', path: 'proj-a' });
53
+ sessions.update(s.id, { everStarted: true, opencodeSessionId: LIVE });
54
+ const cmd = runner.commandFor(sessions.get(s.id));
55
+ has('resumes by id', cmd, `exec opencode --session ${LIVE}`);
56
+ check('does not fall back to --continue', cmd.includes('--continue'), false);
57
+ has('keeps the opencode.json guard', cmd, 'opencode/opencode.json');
58
+
59
+ console.log('\na pin whose conversation is gone starts fresh, it does not die');
60
+ // opencode exits 1 with "Session not found" on a missing id (verified on
61
+ // 1.18.9), so emitting the flag here would kill the pane on sight.
62
+ sessions.update(s.id, { opencodeSessionId: GONE });
63
+ const purged = runner.commandFor(sessions.get(s.id));
64
+ check('no --session for a missing row', purged.includes('--session'), false);
65
+ has('continues in its own folder instead', purged, '--continue');
66
+
67
+ console.log('\nan id that is not an opencode id never reaches the shell');
68
+ for (const bad of ['ses_a; rm -rf /', 'ses_$(whoami)', '../../etc/passwd', 'ses_a b']) {
69
+ addRow(bad, '/data/workspaces/proj-a', 1770000000000); // even if the db says it exists
70
+ sessions.update(s.id, { opencodeSessionId: bad });
71
+ check(`rejected: ${JSON.stringify(bad)}`, runner.commandFor(sessions.get(s.id)).includes('--session'), false);
72
+ }
73
+
74
+ console.log('\nthe pin wins over the folder, so siblings cannot cross-talk');
75
+ // Two live opencode sessions in one folder: `--continue` would have given BOTH
76
+ // the same conversation. Each pinned session resumes its own.
77
+ const other = sessions.create({ name: 'oc-2', cli: 'opencode', path: 'proj-a' });
78
+ const MINE = 'ses_0aaaaaaaaffeaaaaaaaaaaaaaaa';
79
+ const THEIRS = 'ses_0bbbbbbbbffebbbbbbbbbbbbbbb';
80
+ addRow(MINE, '/data/workspaces/proj-a', 1770000001000);
81
+ addRow(THEIRS, '/data/workspaces/proj-a', 1770000002000); // newer: --continue would pick this
82
+ sessions.update(s.id, { opencodeSessionId: MINE });
83
+ sessions.update(other.id, { everStarted: true, opencodeSessionId: THEIRS });
84
+ has('this session resumes its own', runner.commandFor(sessions.get(s.id)), `--session ${MINE}`);
85
+ has('the sibling resumes its own', runner.commandFor(sessions.get(other.id)), `--session ${THEIRS}`);
86
+
87
+ console.log('\na first launch has nothing to resume');
88
+ const fresh = sessions.create({ name: 'oc-3', cli: 'opencode', path: 'proj-b' });
89
+ sessions.update(fresh.id, { opencodeSessionId: LIVE }); // pinned but never started
90
+ const first = runner.commandFor(sessions.get(fresh.id));
91
+ check('no --session on first launch', first.includes('--session'), false);
92
+ check('no --continue on first launch', first.includes('--continue'), false);
93
+
94
+ console.log(`\n${pass} passed, ${fail} failed`);
95
+ try { db.close(); } catch {}
96
+ fs.rmSync(TMP, { recursive: true, force: true });
97
+ process.exit(fail ? 1 : 0);