mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
fix: Connect cloud OpenCode sessions to the latest conversation (no-changelog) (#39449)
This commit is contained in:
@@ -72,8 +72,8 @@ OpenCode server, repository, tools, and builds run in the Codespace. Browser
|
||||
mode opens the remote web interface through a local connection.
|
||||
|
||||
```bash
|
||||
pnpm session:opencode fix-flaky # resume the saved conversation
|
||||
pnpm session:opencode fix-flaky --web # open that conversation in a browser
|
||||
pnpm session:opencode fix-flaky # resume that workspace's latest conversation
|
||||
pnpm session:opencode fix-flaky --web # open it in a browser
|
||||
pnpm session:opencode fix-flaky --new # start a new conversation in that worktree
|
||||
pnpm session:opencode --web --port 4100 # override the default browser port
|
||||
pnpm session:opencode --help
|
||||
@@ -98,11 +98,12 @@ post-start command installs. The local harness setup above is not required for
|
||||
these commands.
|
||||
|
||||
The command prepares the worktree, starts or reuses one server, opens an SSH
|
||||
tunnel, and connects the client. Each workspace name has a saved conversation
|
||||
ID on the Codespace. TUI and browser modes open the same saved conversation.
|
||||
`--new` replaces that saved ID. It preserves the worktree and the old conversation.
|
||||
If you switch conversations inside a client, the next launcher run still opens
|
||||
the ID saved for that workspace name.
|
||||
tunnel, and connects the client. Each workspace opens its own conversation: the
|
||||
most recently updated one in that worktree. A conversation moves to the front
|
||||
when it receives a message. Switching to a conversation without sending a
|
||||
message does not move it. Switching to another workspace's conversation does
|
||||
not change what this workspace opens next. `--new` starts a new conversation
|
||||
instead. It preserves the worktree and the old conversation.
|
||||
|
||||
- **Exit the TUI** with `/exit` or its quit shortcut. The launcher closes its
|
||||
tunnel. The remote server stays running.
|
||||
@@ -110,7 +111,8 @@ the ID saved for that workspace name.
|
||||
the browser tab does not close the tunnel. Keep the launcher running while
|
||||
you use the browser.
|
||||
- **Reconnect** with the same command after a network interruption. After a
|
||||
Codespace stop, the command restarts the server and opens the saved conversation.
|
||||
Codespace stop, the command restarts the server and opens the workspace's
|
||||
most recently updated conversation.
|
||||
A stop terminates running tools. It does not resume interrupted commands.
|
||||
- **Browser mode uses local port 4096 by default.** Use `--port` to override it.
|
||||
TUI mode selects an available port unless you specify one.
|
||||
@@ -129,14 +131,14 @@ your laptop can access the local browser proxy while it runs. Do not forward
|
||||
this proxy or the OpenCode server port to other machines.
|
||||
|
||||
The server enables only OpenRouter. It reads `OPENROUTER_API_KEY` when it starts.
|
||||
Browser mode opens the selected conversation directly.
|
||||
Browser mode opens the workspace's most recently updated conversation directly.
|
||||
The web UI stores opened projects in browser storage. If a new-session page
|
||||
shows **New project**, open `/workspaces/n8n` there once. Keep the same browser
|
||||
port when you reconnect to preserve this selection.
|
||||
|
||||
The server runs in the detached tmux session `n8n-opencode-server`. Its log is
|
||||
`/workspaces/.n8n-opencode/server.log`. That directory also holds the server
|
||||
credentials and saved conversation IDs. It is readable only by its owner.
|
||||
credentials. It is readable only by its owner.
|
||||
An unhealthy server produces an error without stopping active work. Inspect
|
||||
the log through `pnpm session:shell`. To restart it after checking active work,
|
||||
run `tmux kill-session -t '=n8n-opencode-server'` in that shell. Then reconnect.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { rmSync, readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -43,8 +43,12 @@ if (args[0] === 'codespace' && args[1] === 'list') {
|
||||
process.stdin.on('end', () => {
|
||||
log({ event: 'bootstrap', hasSource: source.includes('prepareOpenCode') });
|
||||
if (process.env.TEST_BOOTSTRAP_FAIL) process.exit(1);
|
||||
const name = args.at(-1).split(' ').at(-2);
|
||||
console.log(JSON.stringify({ port: 4242, password: '${secret}', sessionID: 'ses_saved',
|
||||
const parts = args.at(-1).split(' ');
|
||||
const name = parts.at(-3);
|
||||
const fresh = parts.at(-2) === 'true';
|
||||
const web = parts.at(-1) === 'true';
|
||||
console.log(JSON.stringify({ port: 4242, password: '${secret}',
|
||||
...(fresh && !web ? {} : { sessionID: 'ses_saved' }),
|
||||
directory: name === 'agent' ? '/workspaces/n8n' : '/workspaces/wt-' + name }));
|
||||
});
|
||||
}
|
||||
@@ -93,6 +97,22 @@ async function waitFor(check) {
|
||||
throw new Error('Fixture did not become ready.');
|
||||
}
|
||||
|
||||
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
|
||||
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
|
||||
// kernel exposes it; without /proc only the signal error can tell.
|
||||
function stopped(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('parses options before or after the workspace and rejects unsupported flags', () => {
|
||||
assert.equal(parseOpenCodeArgs(['--web']).port, 4096);
|
||||
assert.equal(parseOpenCodeArgs([]).port, 0);
|
||||
@@ -142,20 +162,21 @@ test(
|
||||
assert.ok(!result.output.includes(secret));
|
||||
assert.ok(calls.some((call) => call.event === 'tunnel-stopped'));
|
||||
const pid = calls.find((call) => call.event === 'ssh-child').pid;
|
||||
await waitFor(() => {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
await waitFor(() => stopped(pid));
|
||||
},
|
||||
);
|
||||
|
||||
test('starts a new conversation for --new instead of continuing', { timeout: 15000 }, async (t) => {
|
||||
const f = fixture(t);
|
||||
const result = await f.start(['fix-flaky', '--new']).done;
|
||||
assert.equal(result.code, 0, result.output);
|
||||
const client = f.calls().find((call) => call.command === 'opencode');
|
||||
assert.deepEqual(client.args.slice(2), ['--dir', '/workspaces/wt-fix-flaky']);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGHUP']) {
|
||||
test(
|
||||
`opens the saved web conversation and cleans up on ${signal}`,
|
||||
`opens the newest web conversation and cleans up on ${signal}`,
|
||||
{ timeout: 15000 },
|
||||
async (t) => {
|
||||
const f = fixture(t, { TEST_NO_CLIENT: '1' });
|
||||
|
||||
@@ -162,35 +162,35 @@ function prepareWorkspace({ name, directory, mainDirectory, stateDir }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSession({ name, fresh, directory, stateDir, server }) {
|
||||
const sessionFile = join(stateDir, `${name}.session.json`);
|
||||
const saved = fresh ? undefined : readJson(sessionFile);
|
||||
let session;
|
||||
if (saved) {
|
||||
const response = await request(server, `/session/${encodeURIComponent(saved.id)}`, directory);
|
||||
if (response.ok) session = await response.json();
|
||||
else if (response.status !== 404)
|
||||
throw new Error(`Cannot resume OpenCode session (${response.status}).`);
|
||||
if (session && session.directory !== directory)
|
||||
throw new Error('The saved OpenCode session belongs to another directory. Use --new.');
|
||||
}
|
||||
if (!session) {
|
||||
const response = await request(server, '/session', directory, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: `n8n: ${name}` }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
|
||||
session = await response.json();
|
||||
if (typeof session.id !== 'string' || session.directory !== directory)
|
||||
throw new Error('OpenCode returned an invalid session.');
|
||||
saveJson(sessionFile, { id: session.id });
|
||||
// OpenCode's own resume is scoped to the repository project, so all worktrees
|
||||
// share one pool. Scope the selection to the workspace directory instead, so
|
||||
// each worktree resumes its own most recently updated root conversation.
|
||||
async function resolveSession({ name, fresh, directory, server }) {
|
||||
if (!fresh) {
|
||||
const query = new URLSearchParams({ directory, roots: 'true' });
|
||||
const response = await request(server, `/session?${query}`, directory);
|
||||
if (!response.ok) throw new Error(`Cannot list OpenCode sessions (${response.status}).`);
|
||||
const sessions = await response.json();
|
||||
const latest = Array.isArray(sessions)
|
||||
? sessions.find((entry) => typeof entry?.id === 'string' && entry.id.length > 0)
|
||||
: undefined;
|
||||
if (latest) return latest.id;
|
||||
}
|
||||
const response = await request(server, '/session', directory, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: `n8n: ${name}` }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Cannot create OpenCode session (${response.status}).`);
|
||||
const session = await response.json();
|
||||
if (typeof session.id !== 'string' || session.directory !== directory)
|
||||
throw new Error('OpenCode returned an invalid session.');
|
||||
return session.id;
|
||||
}
|
||||
|
||||
export async function prepareOpenCode({
|
||||
name = 'agent',
|
||||
fresh = false,
|
||||
web = false,
|
||||
workspaces = '/workspaces',
|
||||
} = {}) {
|
||||
const stateDir = join(workspaces, '.n8n-opencode');
|
||||
@@ -203,15 +203,19 @@ export async function prepareOpenCode({
|
||||
if (!server) execFileSync('opencode', ['--version'], { stdio: ['ignore', 'pipe', 'inherit'] });
|
||||
prepareWorkspace({ name, directory, mainDirectory, stateDir });
|
||||
server ??= await startServer({ stateDir, mainDirectory, workspaces });
|
||||
const sessionID = await ensureSession({ name, fresh, directory, stateDir, server });
|
||||
const state = { ...server, directory };
|
||||
// A fresh TUI conversation needs no record. The TUI creates it on the first message.
|
||||
if (fresh && !web) return state;
|
||||
// Only the parent process reads stdout. Never send this record to terminal output.
|
||||
return { ...server, directory, sessionID };
|
||||
return { ...state, sessionID: await resolveSession({ name, fresh, directory, server }) };
|
||||
}
|
||||
|
||||
if (process.argv[1] === '-') {
|
||||
try {
|
||||
const [name, fresh] = process.argv.slice(2);
|
||||
console.log(JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true' })));
|
||||
const [name, fresh, web] = process.argv.slice(2);
|
||||
console.log(
|
||||
JSON.stringify(await prepareOpenCode({ name, fresh: fresh === 'true', web: web === 'true' })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -7,6 +7,22 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { fakeBinaries } from './fake-bin.mjs';
|
||||
import { prepareOpenCode } from './opencode-server.mjs';
|
||||
|
||||
// An orphaned fixture process can linger as a zombie where PID 1 does not reap
|
||||
// it, and kill(pid, 0) still succeeds for one. Read the process state where the
|
||||
// kernel exposes it; without /proc only the signal error can tell.
|
||||
function stopped(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fixture(t) {
|
||||
const fake = fakeBinaries('opencode-server-');
|
||||
const dir = fake.root;
|
||||
@@ -38,7 +54,13 @@ if (process.env.TEST_INSTALL_FAIL) process.exit(1);
|
||||
'tmux',
|
||||
`
|
||||
if (args[0] === 'has-session') {
|
||||
try { process.kill(+fs.readFileSync(file('pid'), 'utf8'), 0); } catch { process.exit(1); }
|
||||
const pid = +fs.readFileSync(file('pid'), 'utf8');
|
||||
let gone = false;
|
||||
try { process.kill(pid, 0); } catch { gone = true; }
|
||||
if (!gone) {
|
||||
try { gone = fs.readFileSync('/proc/' + pid + '/stat', 'utf8').split(' ')[2] === 'Z'; } catch {}
|
||||
}
|
||||
if (gone) process.exit(1);
|
||||
} else {
|
||||
const child = require('node:child_process').spawn('bash', ['-c', args.at(-1)], { detached:true, stdio:'ignore' });
|
||||
fs.writeFileSync(file('pid'), String(child.pid)); child.unref();
|
||||
@@ -59,15 +81,35 @@ const server = require('node:http').createServer(async (req, res) => {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
const expected = 'Basic ' + Buffer.from('opencode:' + process.env.OPENCODE_SERVER_PASSWORD).toString('base64');
|
||||
if (req.headers.authorization !== expected) { res.writeHead(401).end('{}'); return; }
|
||||
const directory = decodeURIComponent(req.headers['x-opencode-directory']);
|
||||
if (req.url === '/global/health') { res.end(JSON.stringify({ healthy:true, version:'1.2.3' })); return; }
|
||||
if (req.method === 'POST' && req.url === '/session') {
|
||||
for await (const chunk of req) {}
|
||||
if (fs.existsSync(file('reject-create'))) { res.writeHead(503).end('{}'); return; }
|
||||
let body = '';
|
||||
for await (const chunk of req) body += chunk;
|
||||
const id = 'ses_' + (Object.keys(sessions).length + 1);
|
||||
sessions[id] = { id, directory:decodeURIComponent(req.headers['x-opencode-directory']) };
|
||||
sessions[id] = { id, directory, title: JSON.parse(body).title };
|
||||
fs.writeFileSync(file('sessions.json'), JSON.stringify(sessions));
|
||||
res.end(JSON.stringify(sessions[id])); return;
|
||||
}
|
||||
if (fs.existsSync(file('reject-session'))) { res.writeHead(503).end('{}'); return; }
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
if (url.pathname === '/session') {
|
||||
// Newest first, like the real server. Filter on the query, not the header,
|
||||
// so the test fails when the launcher omits either parameter. The
|
||||
// child-session file models a subagent conversation that the roots query
|
||||
// flag must exclude.
|
||||
const directory = url.searchParams.get('directory');
|
||||
const roots = url.searchParams.get('roots') === 'true';
|
||||
const stored = Object.values(sessions);
|
||||
if (fs.existsSync(file('child-session')))
|
||||
stored.push({ id: 'ses_child', directory, title: 'child', parentID: 'ses_1' });
|
||||
const list = stored
|
||||
.filter(s => s.directory === directory && (!roots || !s.parentID))
|
||||
.reverse();
|
||||
if (fs.existsSync(file('extra-session'))) list.unshift({ id: 'ses_extra', directory, title: 'TUI session' });
|
||||
res.end(JSON.stringify(list)); return;
|
||||
}
|
||||
const session = sessions[req.url.split('/').at(-1)];
|
||||
if (!session) { res.writeHead(404).end('{}'); return; }
|
||||
res.end(JSON.stringify(session));
|
||||
@@ -80,11 +122,7 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
|
||||
const pid = +readFileSync(join(dir, 'pid'), 'utf8');
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
for (let count = 0; count < 100; count++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (stopped(pid)) return;
|
||||
await delay(20);
|
||||
}
|
||||
throw new Error('Fixture server did not stop.');
|
||||
@@ -107,19 +145,33 @@ server.listen(+args[args.indexOf('--port') + 1], '127.0.0.1');
|
||||
}
|
||||
|
||||
test(
|
||||
'reuses the server and saved conversation, creates separate workspaces, and resumes after restart',
|
||||
'reuses the server, serves each workspace its newest conversation, and resumes after restart',
|
||||
{ timeout: 15000 },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
const first = await f.prepare({ name: 'fix-flaky' });
|
||||
assert.equal(first.directory, join(f.dir, 'wt-fix-flaky'));
|
||||
assert.equal(first.sessionID, 'ses_1');
|
||||
assert.deepEqual(await f.prepare({ name: 'fix-flaky' }), first);
|
||||
const second = await f.prepare({ name: 'another-task' });
|
||||
assert.equal(second.port, first.port);
|
||||
assert.notEqual(second.sessionID, first.sessionID);
|
||||
const fresh = await f.prepare({ name: 'fix-flaky', fresh: true });
|
||||
assert.notEqual(fresh.sessionID, first.sessionID);
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
|
||||
assert.equal(second.sessionID, 'ses_2');
|
||||
const webbed = await f.prepare({ name: 'fix-flaky', web: true });
|
||||
assert.equal(webbed.sessionID, 'ses_1');
|
||||
const created = JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8'));
|
||||
assert.equal(created.ses_1.title, 'n8n: fix-flaky');
|
||||
assert.equal(created.ses_1.directory, join(f.dir, 'wt-fix-flaky'));
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
|
||||
assert.equal((await f.prepare({ name: 'another-task', web: true })).sessionID, 'ses_2');
|
||||
writeFileSync(join(f.dir, 'extra-session'), '');
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_extra');
|
||||
rmSync(join(f.dir, 'extra-session'));
|
||||
writeFileSync(join(f.dir, 'child-session'), '');
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, 'ses_1');
|
||||
rmSync(join(f.dir, 'child-session'));
|
||||
const fresh = await f.prepare({ name: 'fix-flaky', web: true, fresh: true });
|
||||
assert.notEqual(fresh.sessionID, 'ses_1');
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky', web: true })).sessionID, fresh.sessionID);
|
||||
const env = JSON.parse(readFileSync(join(f.dir, 'server-env.json'), 'utf8'));
|
||||
assert.deepEqual([env.worker, env.queue, env.slack], [false, false, false]);
|
||||
assert.equal(env.cache, join(f.dir, '.turbo-cache'));
|
||||
@@ -127,18 +179,18 @@ test(
|
||||
assert.deepEqual(env.config.enabled_providers, ['openrouter']);
|
||||
assert.deepEqual([env.runtime, env.profile], ['sandbox', null]);
|
||||
assert.equal(statSync(join(f.dir, '.n8n-opencode')).mode & 0o777, 0o700);
|
||||
for (const file of ['serve.sh', 'server.json', 'fix-flaky.session.json']) {
|
||||
for (const file of ['serve.sh', 'server.json']) {
|
||||
assert.equal(statSync(join(f.dir, '.n8n-opencode', file)).mode & 0o777, 0o600);
|
||||
}
|
||||
assert.ok(!f.commands().some((entry) => JSON.stringify(entry.args).includes(first.password)));
|
||||
assert.equal(f.commands().filter((entry) => entry.command === 'pnpm').length, 2);
|
||||
await f.stop();
|
||||
const restarted = await f.prepare({ name: 'fix-flaky' });
|
||||
const restarted = await f.prepare({ name: 'fix-flaky', web: true });
|
||||
assert.equal(restarted.sessionID, fresh.sessionID);
|
||||
assert.notEqual(restarted.password, first.password);
|
||||
rmSync(first.directory, { recursive: true });
|
||||
writeFileSync(join(f.dir, 'branch-exists'), '');
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky' })).sessionID, fresh.sessionID);
|
||||
assert.equal((await f.prepare({ name: 'fix-flaky' })).directory, join(f.dir, 'wt-fix-flaky'));
|
||||
const worktree = f
|
||||
.commands()
|
||||
.filter((entry) => entry.command === 'git' && entry.args.includes('add'))
|
||||
@@ -183,19 +235,23 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
'preserves saved sessions on API errors and replaces only missing sessions',
|
||||
'reports session API errors and creates conversations only when none exist',
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
const first = await f.prepare();
|
||||
const first = await f.prepare({ web: true });
|
||||
assert.equal(first.sessionID, 'ses_1');
|
||||
writeFileSync(join(f.dir, 'reject-session'), '');
|
||||
await assert.rejects(f.prepare(), /Cannot resume OpenCode session \(503\)/);
|
||||
const saved = join(f.dir, '.n8n-opencode', 'agent.session.json');
|
||||
assert.equal(JSON.parse(readFileSync(saved, 'utf8')).id, first.sessionID);
|
||||
await assert.rejects(f.prepare({ web: true }), /Cannot list OpenCode sessions \(503\)/);
|
||||
assert.ok(JSON.parse(readFileSync(join(f.dir, 'sessions.json'), 'utf8')).ses_1);
|
||||
rmSync(join(f.dir, 'reject-session'));
|
||||
writeFileSync(saved, JSON.stringify({ id: 'ses_missing' }));
|
||||
assert.notEqual((await f.prepare()).sessionID, first.sessionID);
|
||||
writeFileSync(saved, '{');
|
||||
await assert.rejects(f.prepare(), /Cannot read/);
|
||||
assert.equal((await f.prepare({ web: true })).sessionID, first.sessionID);
|
||||
writeFileSync(join(f.dir, 'reject-create'), '');
|
||||
await assert.rejects(
|
||||
f.prepare({ name: 'fresh-task', web: true }),
|
||||
/Cannot create OpenCode session \(503\)/,
|
||||
);
|
||||
rmSync(join(f.dir, 'reject-create'));
|
||||
assert.equal((await f.prepare({ name: 'fresh-task', web: true })).sessionID, 'ses_2');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ function startChild(command, args, options = {}) {
|
||||
async function bootstrap(codespace, options, signal) {
|
||||
signal.throwIfAborted();
|
||||
console.log(`Preparing OpenCode workspace '${options.name}' on ${codespace}…`);
|
||||
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh}`;
|
||||
const command = `umask 077; mkdir -p /workspaces/.n8n-opencode && flock --close -w 600 /workspaces/.n8n-opencode/launch.lock node --input-type=module - ${options.name} ${options.fresh} ${options.web}`;
|
||||
const remote = startChild('gh', ['codespace', 'ssh', '-c', codespace, '--', command], {
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
detached: true,
|
||||
@@ -129,8 +129,8 @@ async function bootstrap(codespace, options, signal) {
|
||||
!state ||
|
||||
!Number.isInteger(state.port) ||
|
||||
!text(state.password) ||
|
||||
!text(state.sessionID) ||
|
||||
!text(state.directory)
|
||||
!text(state.directory) ||
|
||||
(options.web && !text(state.sessionID))
|
||||
) {
|
||||
throw new Error('Invalid OpenCode server response.');
|
||||
}
|
||||
@@ -236,9 +236,18 @@ export async function connectOpenCode(options, ensureCodespace) {
|
||||
openBrowser(webUrl);
|
||||
} else {
|
||||
console.log(`Connecting to OpenCode ${health.version} in ${state.directory}…`);
|
||||
// OpenCode's own resume spans all worktrees of the repository. The
|
||||
// server resolved the workspace's latest conversation instead, so
|
||||
// target it directly. A fresh TUI conversation needs no target.
|
||||
client = startChild(
|
||||
'opencode',
|
||||
['attach', url, '--dir', state.directory, '--session', state.sessionID],
|
||||
[
|
||||
'attach',
|
||||
url,
|
||||
'--dir',
|
||||
state.directory,
|
||||
...(state.sessionID ? ['--session', state.sessionID] : []),
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
|
||||
Reference in New Issue
Block a user