Spaces:
Running
opencode: resume the pinned conversation, not the folder's most recent (#27)
Browse filesThe 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 +1 -1
- server/src/runner.js +25 -7
- server/src/traces.js +15 -0
- server/test/opencode-resume.test.mjs +97 -0
|
@@ -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"
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 1206 |
-
//
|
| 1207 |
-
//
|
| 1208 |
-
//
|
| 1209 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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.
|
|
@@ -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);
|