@shardflux/cli 0.23.1 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +249 -4
- package/dist/account-args.js +8 -3
- package/dist/account-commands.js +15 -2
- package/dist/account-pool.js +5 -1
- package/dist/acp-journal.d.ts +2 -2
- package/dist/acp-journal.js +14 -4
- package/dist/acp-permission-elsewhere.d.ts +38 -0
- package/dist/acp-permission-elsewhere.js +85 -0
- package/dist/acp-rpc.d.ts +2 -1
- package/dist/acp-rpc.js +11 -2
- package/dist/acp.js +125 -30
- package/dist/agent-credentials.d.ts +3 -0
- package/dist/agent-credentials.js +7 -0
- package/dist/args.d.ts +1 -1
- package/dist/args.js +154 -8
- package/dist/board-args.d.ts +6 -0
- package/dist/board-args.js +155 -0
- package/dist/board-commands.d.ts +62 -0
- package/dist/board-commands.js +395 -0
- package/dist/codex-cloud.d.ts +30 -3
- package/dist/codex-cloud.js +86 -12
- package/dist/conversation-commands.d.ts +30 -0
- package/dist/conversation-commands.js +312 -9
- package/dist/conversation-transcript.d.ts +44 -0
- package/dist/conversation-transcript.js +206 -0
- package/dist/conversations.d.ts +5 -2
- package/dist/conversations.js +20 -10
- package/dist/default-volume-commands.d.ts +3 -0
- package/dist/default-volume-commands.js +35 -0
- package/dist/device-approvals.js +4 -3
- package/dist/device-bridge.d.ts +42 -1
- package/dist/device-bridge.js +241 -14
- package/dist/device-folders.d.ts +58 -0
- package/dist/device-folders.js +228 -0
- package/dist/device-helper.d.ts +6 -0
- package/dist/device-helper.js +87 -14
- package/dist/drive-commands.d.ts +25 -0
- package/dist/drive-commands.js +201 -0
- package/dist/filesystem-commands.js +2 -1
- package/dist/handlers.js +6 -1
- package/dist/local-sessions.d.ts +7 -1
- package/dist/local-sessions.js +38 -0
- package/dist/main.js +3 -0
- package/dist/move-files.d.ts +39 -0
- package/dist/move-files.js +167 -0
- package/dist/move-interrupt.d.ts +18 -0
- package/dist/move-interrupt.js +193 -0
- package/dist/move.js +28 -17
- package/dist/onboarding.d.ts +6 -2
- package/dist/onboarding.js +24 -6
- package/dist/project-git.d.ts +7 -1
- package/dist/project-git.js +16 -3
- package/dist/project-preparation.d.ts +7 -0
- package/dist/project-preparation.js +14 -0
- package/dist/project-template-setup.js +32 -11
- package/dist/router-commands.d.ts +22 -0
- package/dist/router-commands.js +179 -0
- package/dist/worktree-twin-engine.js +16 -6
- package/package.json +7 -5
package/dist/local-sessions.js
CHANGED
|
@@ -308,6 +308,44 @@ function codexRunning(env, id) {
|
|
|
308
308
|
const r = spawnSync('perl', ['-e', 'use Fcntl qw(:flock); open(my $f, "<", $ARGV[0]) or exit 2; exit(flock($f, LOCK_EX|LOCK_NB) ? 0 : 1)', lock], { stdio: 'ignore', timeout: 5000 });
|
|
309
309
|
return r.status === 1;
|
|
310
310
|
}
|
|
311
|
+
/** The actual writer, never a guessed PID from the transcript. Codex TUI's writer may be its shared daemon. */
|
|
312
|
+
export function sessionProcessPid(s, env) {
|
|
313
|
+
if (s.harness === 'claude')
|
|
314
|
+
return claudeProcesses(env)?.find(p => p.sessionId === s.session_id)?.pid;
|
|
315
|
+
if (!codexRunning(env, s.session_id))
|
|
316
|
+
return undefined;
|
|
317
|
+
const lock = join(codexHome(env), 'thread-writer-locks', `${s.session_id}.lock`);
|
|
318
|
+
if (process.platform === 'linux') {
|
|
319
|
+
const st = statSync(lock, { bigint: true });
|
|
320
|
+
const major = ((st.dev >> 8n) & 0xfffn) | ((st.dev >> 32n) & 0xfffff000n);
|
|
321
|
+
const minor = (st.dev & 0xffn) | ((st.dev >> 12n) & 0xffffff00n);
|
|
322
|
+
try {
|
|
323
|
+
for (const line of readFileSync('/proc/locks', 'utf8').split('\n')) {
|
|
324
|
+
const m = /^\d+: FLOCK\s+ADVISORY\s+WRITE\s+(\d+)\s+([0-9a-f]+):([0-9a-f]+):(\d+)\s/.exec(line);
|
|
325
|
+
if (m && BigInt(`0x${m[2]}`) === major && BigInt(`0x${m[3]}`) === minor && BigInt(m[4]) === st.ino)
|
|
326
|
+
return Number(m[1]);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
catch { /* Fall through to lsof on systems without a readable proc lock table. */ }
|
|
330
|
+
}
|
|
331
|
+
const r = spawnSync('lsof', ['-t', '--', lock], { encoding: 'utf8', timeout: 2000 });
|
|
332
|
+
const pids = [...new Set((r.stdout ?? '').trim().split(/\s+/).map(Number).filter(p => Number.isSafeInteger(p) && p > 1))];
|
|
333
|
+
return pids.length === 1 ? pids[0] : undefined;
|
|
334
|
+
}
|
|
335
|
+
/** The selected thread's last started turn, from its native rollout (Codex 0.160.0). */
|
|
336
|
+
export function sessionTurnId(s) {
|
|
337
|
+
const r = tail(s.file, statSync(s.file).size).reverse().find(r => r.type === 'event_msg' && obj(r.payload).type === 'task_started');
|
|
338
|
+
const id = obj(r?.payload).turn_id;
|
|
339
|
+
return typeof id === 'string' ? id : undefined;
|
|
340
|
+
}
|
|
341
|
+
/** Re-read the registry immediately before signalling: a PID reused after a session exits belongs to someone else. */
|
|
342
|
+
export function sameClaudeProcess(s, env, pid) {
|
|
343
|
+
const p = claudeProcesses(env)?.find(p => p.sessionId === s.session_id && p.pid === pid);
|
|
344
|
+
if (!p)
|
|
345
|
+
return false;
|
|
346
|
+
const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 2000, env: { ...process.env, TZ: 'UTC' } });
|
|
347
|
+
return !!p.procStart && r.status === 0 && r.stdout.trim().replace(/\s+/g, ' ') === p.procStart.trim().replace(/\s+/g, ' ');
|
|
348
|
+
}
|
|
311
349
|
// ---------------------------------------------------------------------------------------------------------------
|
|
312
350
|
// Listing, finding, state
|
|
313
351
|
const within = (folder) => (cwd) => cwd === folder || cwd.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
|
package/dist/main.js
CHANGED
|
@@ -371,6 +371,9 @@ const NO_NUDGE_REASONS = new Set([
|
|
|
371
371
|
// Inbound ports (0.9.0): the hint names the command to expose or close a port.
|
|
372
372
|
'port_not_exposed',
|
|
373
373
|
'port_limit',
|
|
374
|
+
// Permission requests (0.25.0+): answered already (here or where the agent runs), or its agent no longer waits.
|
|
375
|
+
'permission_answered',
|
|
376
|
+
'permission_canceled',
|
|
374
377
|
]);
|
|
375
378
|
/**
|
|
376
379
|
* Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CellClient } from '@shardflux/sdk';
|
|
2
|
+
import type { SessionHarness } from './local-sessions.js';
|
|
3
|
+
export declare const VM_HOME = "/home/user";
|
|
4
|
+
export interface SessionFile {
|
|
5
|
+
path: string;
|
|
6
|
+
bytes: number;
|
|
7
|
+
mode: number;
|
|
8
|
+
why: 'handed' | 'opened' | 'named';
|
|
9
|
+
}
|
|
10
|
+
export interface SessionFiles {
|
|
11
|
+
files: SessionFile[];
|
|
12
|
+
left: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Absolute and home paths in free text: `@"/a b/c.md"`, `/x/y.md`, `~/notes.md`. */
|
|
15
|
+
export declare function pathsIn(text: string): string[];
|
|
16
|
+
/** What the transcript says the session was handed and opened, as spelled there (a `~/` path is not expanded yet). */
|
|
17
|
+
export declare function sessionMentions(bytes: Buffer, harness: SessionHarness): {
|
|
18
|
+
handed: string[];
|
|
19
|
+
opened: string[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The files to carry: regular files under `home`, outside every root (the moved checkout and the repository's other
|
|
23
|
+
* worktrees), not private, within the bounds. Handed files come first, then opened ones, then the ones a handed file
|
|
24
|
+
* names; `left` lists the ones past the bounds.
|
|
25
|
+
*/
|
|
26
|
+
export declare function sessionFiles(bytes: Buffer, harness: SessionHarness, home: string, roots: readonly string[]): SessionFiles;
|
|
27
|
+
/** Where a carried file goes in the VM. */
|
|
28
|
+
export declare const vmPath: (home: string, path: string) => string;
|
|
29
|
+
/** `~/Downloads/handoff.md` for display. */
|
|
30
|
+
export declare const homeSpelling: (home: string, path: string) => string;
|
|
31
|
+
type FilesCell = Pick<CellClient, 'exec' | 'files'>;
|
|
32
|
+
export declare function writeChunks(cell: Pick<FilesCell, 'files'>, path: string, bytes: Uint8Array, mode?: string): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Writes each file under the VM's home, then links this computer's home path to the VM's home when it is free there
|
|
35
|
+
* (`/Users/alex` → `/home/user`), so absolute paths in the history resolve. A VM without sudo keeps the home-relative
|
|
36
|
+
* copies; the link is a convenience, never a reason to fail the move.
|
|
37
|
+
*/
|
|
38
|
+
export declare function placeSessionFiles(cell: FilesCell, found: SessionFiles, home: string, signal?: AbortSignal): Promise<boolean>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files a moved session used outside its project (0.25.0+): what the user handed it (an @-mention, a path typed in a
|
|
3
|
+
* prompt), what it opened with Read, Edit or Write, and the files a handed text file names (a handoff's own
|
|
4
|
+
* references). Each goes into the VM under its home at the same home-relative path, and the VM links this computer's
|
|
5
|
+
* home there, so a path the history names (/Users/alex/Downloads/handoff.md) opens in the cloud as it did here.
|
|
6
|
+
* Credential stores and keys are never carried; files past the bounds stay on this computer and are listed.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
10
|
+
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
11
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
12
|
+
export const VM_HOME = '/home/user';
|
|
13
|
+
const MAX_FILES = 64, FILE_BYTES = 16 << 20, TOTAL_BYTES = 64 << 20;
|
|
14
|
+
/** A handed file is read for the paths it names when it is text of at most this size. */
|
|
15
|
+
const NAMES_BYTES = 1 << 20;
|
|
16
|
+
/** Claude Code tools whose input names a file the session opened. */
|
|
17
|
+
const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit']);
|
|
18
|
+
/** Home-relative stores of keys and sign-ins: never carried, whatever the session read. */
|
|
19
|
+
const PRIVATE = ['.ssh', '.aws', '.gnupg', '.password-store', '.azure', '.kube', '.docker', '.netrc', '.npmrc', '.pypirc',
|
|
20
|
+
'.git-credentials', '.config/gh', '.config/gcloud', '.config/shardflux', '.claude/.credentials.json', '.claude.json',
|
|
21
|
+
'.codex/auth.json', 'Library/Keychains', 'Library/Cookies'];
|
|
22
|
+
const PRIVATE_NAME = /^(\.env(\..*)?|id_[a-z0-9]+(\.pub)?|.*\.(pem|key|p12|pfx|keychain-db))$/i;
|
|
23
|
+
/** Absolute and home paths in free text: `@"/a b/c.md"`, `/x/y.md`, `~/notes.md`. */
|
|
24
|
+
export function pathsIn(text) {
|
|
25
|
+
const out = new Set();
|
|
26
|
+
for (const m of text.matchAll(/["'`](~?\/[^"'`\n]+)["'`]|(?:^|[\s(@[])(~?\/[^\s"'`()<>[\]]+)/g))
|
|
27
|
+
out.add(m[1] ?? m[2].replace(/[.,;:!?]+$/, ''));
|
|
28
|
+
return [...out];
|
|
29
|
+
}
|
|
30
|
+
/** What the transcript says the session was handed and opened, as spelled there (a `~/` path is not expanded yet). */
|
|
31
|
+
export function sessionMentions(bytes, harness) {
|
|
32
|
+
const handed = new Set(), opened = new Set();
|
|
33
|
+
for (const line of bytes.toString('utf8').split('\n')) {
|
|
34
|
+
let r;
|
|
35
|
+
try {
|
|
36
|
+
r = obj(JSON.parse(line));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (harness === 'claude') {
|
|
42
|
+
const attachment = obj(r.attachment);
|
|
43
|
+
if (r.type === 'attachment' && attachment.type === 'file' && typeof attachment.filename === 'string')
|
|
44
|
+
handed.add(attachment.filename);
|
|
45
|
+
const message = obj(r.message);
|
|
46
|
+
if (r.type === 'user' && r.isMeta !== true && typeof message.content === 'string')
|
|
47
|
+
for (const p of pathsIn(message.content))
|
|
48
|
+
handed.add(p);
|
|
49
|
+
if (r.type === 'assistant' && Array.isArray(message.content)) {
|
|
50
|
+
for (const block of message.content.map(obj)) {
|
|
51
|
+
if (block.type !== 'tool_use' || !FILE_TOOLS.has(String(block.name)))
|
|
52
|
+
continue;
|
|
53
|
+
const input = obj(block.input), path = input.file_path ?? input.notebook_path;
|
|
54
|
+
if (typeof path === 'string')
|
|
55
|
+
opened.add(path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const p = obj(r.payload);
|
|
61
|
+
if (r.type !== 'response_item' || p.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content))
|
|
62
|
+
continue;
|
|
63
|
+
// Codex's own context blocks (<environment_context>, <turn_aborted>) are user-role records the user never typed.
|
|
64
|
+
for (const item of p.content.map(obj))
|
|
65
|
+
if (item.type === 'input_text' && typeof item.text === 'string' && !item.text.startsWith('<'))
|
|
66
|
+
for (const q of pathsIn(item.text))
|
|
67
|
+
handed.add(q);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { handed: [...handed], opened: [...opened] };
|
|
71
|
+
}
|
|
72
|
+
const inside = (root, path) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`);
|
|
73
|
+
const realOr = (path) => { try {
|
|
74
|
+
return realpathSync(path);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return resolve(path);
|
|
78
|
+
} };
|
|
79
|
+
function isPrivate(home, path) {
|
|
80
|
+
const rel = relative(home, path);
|
|
81
|
+
return PRIVATE.some(p => rel === p || rel.startsWith(`${p}/`)) || rel.split('/').some(part => PRIVATE_NAME.test(part));
|
|
82
|
+
}
|
|
83
|
+
function isText(bytes) { return !bytes.subarray(0, 8192).includes(0); }
|
|
84
|
+
/**
|
|
85
|
+
* The files to carry: regular files under `home`, outside every root (the moved checkout and the repository's other
|
|
86
|
+
* worktrees), not private, within the bounds. Handed files come first, then opened ones, then the ones a handed file
|
|
87
|
+
* names; `left` lists the ones past the bounds.
|
|
88
|
+
*/
|
|
89
|
+
export function sessionFiles(bytes, harness, home, roots) {
|
|
90
|
+
const realHome = realOr(home), realRoots = roots.map(realOr);
|
|
91
|
+
const expand = (p) => (p === '~' || p.startsWith('~/') ? home + p.slice(1) : p);
|
|
92
|
+
const files = [], left = [], seen = new Set();
|
|
93
|
+
let total = 0;
|
|
94
|
+
const consider = (spelled, why) => {
|
|
95
|
+
const path = resolve(expand(spelled));
|
|
96
|
+
if (!isAbsolute(expand(spelled)) || seen.has(path))
|
|
97
|
+
return undefined;
|
|
98
|
+
seen.add(path);
|
|
99
|
+
const real = realOr(path);
|
|
100
|
+
if (!inside(home, path) || !inside(realHome, real) || realRoots.some(root => inside(root, real)) || isPrivate(home, path) || isPrivate(realHome, real))
|
|
101
|
+
return undefined;
|
|
102
|
+
let st;
|
|
103
|
+
try {
|
|
104
|
+
st = statSync(real);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
if (!st.isFile())
|
|
110
|
+
return undefined;
|
|
111
|
+
if (files.length >= MAX_FILES || st.size > FILE_BYTES || total + st.size > TOTAL_BYTES) {
|
|
112
|
+
left.push(path);
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
total += st.size;
|
|
116
|
+
const f = { path, bytes: st.size, mode: st.mode & 0o777, why };
|
|
117
|
+
files.push(f);
|
|
118
|
+
return f;
|
|
119
|
+
};
|
|
120
|
+
const { handed, opened } = sessionMentions(bytes, harness);
|
|
121
|
+
const given = handed.map(p => consider(p, 'handed')).filter((f) => !!f);
|
|
122
|
+
for (const p of opened)
|
|
123
|
+
consider(p, 'opened');
|
|
124
|
+
for (const f of given) {
|
|
125
|
+
if (f.bytes > NAMES_BYTES)
|
|
126
|
+
continue;
|
|
127
|
+
let text;
|
|
128
|
+
try {
|
|
129
|
+
text = readFileSync(f.path);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (isText(text))
|
|
135
|
+
for (const p of pathsIn(text.toString('utf8')))
|
|
136
|
+
consider(p, 'named');
|
|
137
|
+
}
|
|
138
|
+
return { files, left };
|
|
139
|
+
}
|
|
140
|
+
/** Where a carried file goes in the VM. */
|
|
141
|
+
export const vmPath = (home, path) => `${VM_HOME}/${relative(home, path)}`;
|
|
142
|
+
/** `~/Downloads/handoff.md` for display. */
|
|
143
|
+
export const homeSpelling = (home, path) => (inside(home, path) ? `~/${relative(home, path)}` : path);
|
|
144
|
+
export async function writeChunks(cell, path, bytes, mode = '0600') {
|
|
145
|
+
if (!bytes.length) {
|
|
146
|
+
await cell.files.write(path, '', { mode });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
150
|
+
await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode, append: at > 0 });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Writes each file under the VM's home, then links this computer's home path to the VM's home when it is free there
|
|
154
|
+
* (`/Users/alex` → `/home/user`), so absolute paths in the history resolve. A VM without sudo keeps the home-relative
|
|
155
|
+
* copies; the link is a convenience, never a reason to fail the move.
|
|
156
|
+
*/
|
|
157
|
+
export async function placeSessionFiles(cell, found, home, signal) {
|
|
158
|
+
for (const f of found.files) {
|
|
159
|
+
const target = vmPath(home, f.path);
|
|
160
|
+
await cell.files.mkdir(dirname(target), { parents: true });
|
|
161
|
+
await writeChunks(cell, target, readFileSync(f.path), `0${f.mode.toString(8)}`);
|
|
162
|
+
}
|
|
163
|
+
if (!found.files.length || home === VM_HOME)
|
|
164
|
+
return false;
|
|
165
|
+
const linked = await cell.exec.run(['sh', '-c', 'test -e "$1" || { sudo -n mkdir -p "$(dirname "$1")" && sudo -n ln -s "$2" "$1"; }', 'sh', home, VM_HOME], { timeoutMs: 10_000, ...(signal ? { signal } : {}) }).catch(() => null);
|
|
166
|
+
return linked?.exitCode === 0;
|
|
167
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Ctx } from './commands.js';
|
|
2
|
+
import type { LocalSession } from './local-sessions.js';
|
|
3
|
+
type Env = Record<string, string | undefined>;
|
|
4
|
+
/** Detached MCP children retain their parent chain; this also works for already-installed MCP servers. */
|
|
5
|
+
export declare function processAncestor(ancestor: number, child?: number): boolean;
|
|
6
|
+
export declare function selfMove(s: LocalSession, env: Env): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Whether a move stops the session's turn itself. A self-move, and a session whose live process this computer cannot
|
|
9
|
+
* name (a Claude Code without the session registry, a Codex thread whose lock more than one process holds), let the
|
|
10
|
+
* turn end first instead.
|
|
11
|
+
*/
|
|
12
|
+
export declare function stopsLocalTurn(s: LocalSession, env: Env): boolean;
|
|
13
|
+
/** Shared Codex daemons need thread-scoped interrupt AND background-terminal cleanup: interrupt alone leaves Bash alive. */
|
|
14
|
+
export declare function interruptCodexThread(s: LocalSession, env: Env, signal: AbortSignal): Promise<void>;
|
|
15
|
+
/** Stop before the project snapshot. The native harness owns its interrupt records; the local transcript is read-only here. */
|
|
16
|
+
export declare function interruptLocalTurn(ctx: Pick<Ctx, 'io' | 'json' | 'signal'>, s: LocalSession, env: Env): Promise<boolean>;
|
|
17
|
+
export declare const MOVE_CONTINUATION = "Continue the same reply after the move to the cloud. The local turn was interrupted for this move. Redo the interrupted step from its beginning, including re-running the interrupted tool call; never skip to the next step. Everything completed before the interruption is in this project and session history. Finish the original request without waiting for another prompt.";
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/** Native interruption measured with Claude 2.1.295/2.1.300 and Codex 0.160.0; see ground-truth.md. */
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import { UsageError } from "./errors.js";
|
|
8
|
+
import { CLI_VERSION } from "./args.js";
|
|
9
|
+
import { codexHome, sameClaudeProcess, sessionProcessPid, sessionState, sessionTurnId } from "./local-sessions.js";
|
|
10
|
+
function processes() {
|
|
11
|
+
const r = spawnSync('ps', ['-axo', 'pid=,ppid=,stat=,lstart=,comm='], { encoding: 'utf8', timeout: 2000, env: { ...process.env, TZ: 'UTC' } });
|
|
12
|
+
if (r.status !== 0)
|
|
13
|
+
throw new UsageError('Couldn’t check the local commands. Retry the move.');
|
|
14
|
+
return r.stdout.split('\n').flatMap(line => {
|
|
15
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
|
|
16
|
+
// Zombies and processes already exiting cannot write project files.
|
|
17
|
+
return m && !/[ZE]/.test(m[3]) ? [{ pid: Number(m[1]), parent: Number(m[2]), identity: m[4].replace(/\s+/g, ' ') }] : [];
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
function descendants(pid) {
|
|
21
|
+
const rows = processes(), ids = new Set([pid]);
|
|
22
|
+
for (;;) {
|
|
23
|
+
const before = ids.size;
|
|
24
|
+
for (const p of rows)
|
|
25
|
+
if (ids.has(p.parent))
|
|
26
|
+
ids.add(p.pid);
|
|
27
|
+
if (ids.size === before)
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
return rows.filter(p => p.pid !== pid && ids.has(p.pid));
|
|
31
|
+
}
|
|
32
|
+
/** A standalone harness's commands cannot outlive the snapshot, even if its interruption leaves an orphan shell. */
|
|
33
|
+
async function stopCommands(owned) {
|
|
34
|
+
const remaining = () => processes().filter(p => owned.some(o => o.pid === p.pid && o.identity === p.identity));
|
|
35
|
+
const send = (p, sig) => { try {
|
|
36
|
+
process.kill(p.pid, sig);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
if (e.code !== 'ESRCH')
|
|
40
|
+
throw e;
|
|
41
|
+
} };
|
|
42
|
+
for (const p of remaining())
|
|
43
|
+
send(p, 'SIGTERM');
|
|
44
|
+
const deadline = Date.now() + 1000;
|
|
45
|
+
while (remaining().length && Date.now() < deadline)
|
|
46
|
+
await delay(50);
|
|
47
|
+
for (const p of remaining())
|
|
48
|
+
send(p, 'SIGKILL');
|
|
49
|
+
if (remaining().length) {
|
|
50
|
+
await delay(100);
|
|
51
|
+
if (remaining().length)
|
|
52
|
+
throw new UsageError('A local command has not stopped. Stop it locally and retry the move.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Detached MCP children retain their parent chain; this also works for already-installed MCP servers. */
|
|
56
|
+
export function processAncestor(ancestor, child = process.pid) {
|
|
57
|
+
const r = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
|
|
58
|
+
if (r.status !== 0)
|
|
59
|
+
throw new UsageError('Couldn’t check this session’s process. Retry the move.');
|
|
60
|
+
const parents = new Map(r.stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number)).map(([pid, parent]) => [pid, parent]));
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
for (let p = child; p > 1 && !seen.has(p); p = parents.get(p) ?? 0) {
|
|
63
|
+
if (p === ancestor)
|
|
64
|
+
return true;
|
|
65
|
+
seen.add(p);
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
export function selfMove(s, env) {
|
|
70
|
+
const pid = sessionProcessPid(s, env);
|
|
71
|
+
return pid !== undefined && processAncestor(pid);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether a move stops the session's turn itself. A self-move, and a session whose live process this computer cannot
|
|
75
|
+
* name (a Claude Code without the session registry, a Codex thread whose lock more than one process holds), let the
|
|
76
|
+
* turn end first instead.
|
|
77
|
+
*/
|
|
78
|
+
export function stopsLocalTurn(s, env) {
|
|
79
|
+
const pid = sessionProcessPid(s, env);
|
|
80
|
+
return pid !== undefined && !processAncestor(pid);
|
|
81
|
+
}
|
|
82
|
+
/** Shared Codex daemons need thread-scoped interrupt AND background-terminal cleanup: interrupt alone leaves Bash alive. */
|
|
83
|
+
export async function interruptCodexThread(s, env, signal) {
|
|
84
|
+
const turnId = sessionTurnId(s);
|
|
85
|
+
if (!turnId)
|
|
86
|
+
throw new UsageError('Couldn’t identify this Codex turn. Stop it locally and retry the move.');
|
|
87
|
+
const socket = realpathSync(join(codexHome(env), 'app-server-control', 'app-server-control.sock'));
|
|
88
|
+
const ws = new WebSocket(`ws+unix://${socket}:/`);
|
|
89
|
+
const pending = new Map();
|
|
90
|
+
const bounded = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
|
|
91
|
+
let id = 0;
|
|
92
|
+
const fail = (error) => { for (const p of pending.values())
|
|
93
|
+
p.reject(error); pending.clear(); };
|
|
94
|
+
ws.on('error', fail);
|
|
95
|
+
ws.on('close', () => fail(new Error('Codex’s session control closed.')));
|
|
96
|
+
ws.on('message', data => {
|
|
97
|
+
let r;
|
|
98
|
+
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
99
|
+
try {
|
|
100
|
+
r = JSON.parse(bytes.toString('utf8'));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const p = r.id === undefined ? undefined : pending.get(r.id);
|
|
106
|
+
if (!p)
|
|
107
|
+
return;
|
|
108
|
+
pending.delete(r.id);
|
|
109
|
+
if (r.error)
|
|
110
|
+
p.reject(new Error(`Codex could not stop the turn: ${JSON.stringify(r.error)}`));
|
|
111
|
+
else
|
|
112
|
+
p.resolve(r.result);
|
|
113
|
+
});
|
|
114
|
+
const abort = () => { fail(new Error('Codex did not confirm that the local turn stopped.')); ws.terminate(); };
|
|
115
|
+
bounded.addEventListener('abort', abort, { once: true });
|
|
116
|
+
const call = (method, params) => new Promise((resolve, reject) => {
|
|
117
|
+
bounded.throwIfAborted();
|
|
118
|
+
const next = ++id;
|
|
119
|
+
pending.set(next, { resolve, reject });
|
|
120
|
+
ws.send(JSON.stringify({ id: next, method, params }));
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); bounded.addEventListener('abort', () => reject(bounded.reason), { once: true }); });
|
|
124
|
+
await call('initialize', { clientInfo: { name: 'shard_move', version: CLI_VERSION }, capabilities: { experimentalApi: true } });
|
|
125
|
+
ws.send(JSON.stringify({ method: 'initialized' }));
|
|
126
|
+
await call('turn/interrupt', { threadId: s.session_id, turnId });
|
|
127
|
+
await call('thread/backgroundTerminals/clean', { threadId: s.session_id });
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
bounded.removeEventListener('abort', abort);
|
|
131
|
+
ws.terminate();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Stop before the project snapshot. The native harness owns its interrupt records; the local transcript is read-only here. */
|
|
135
|
+
export async function interruptLocalTurn(ctx, s, env) {
|
|
136
|
+
if (sessionState(s, env).turn !== 'working')
|
|
137
|
+
return false;
|
|
138
|
+
const pid = sessionProcessPid(s, env);
|
|
139
|
+
if (!pid)
|
|
140
|
+
throw new UsageError('Couldn’t identify this session’s live process. Stop its local turn and retry the move.');
|
|
141
|
+
if (processAncestor(pid))
|
|
142
|
+
throw new UsageError('A session cannot interrupt its own turn to move.');
|
|
143
|
+
if (!ctx.json)
|
|
144
|
+
ctx.io.stderr.write('Stopping the local turn and continuing it in the cloud…\n');
|
|
145
|
+
const args = s.harness === 'codex' ? spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8', timeout: 2000 }).stdout ?? '' : '';
|
|
146
|
+
const daemon = s.harness === 'codex' && args.includes('app-server') && args.includes('--managed-daemon');
|
|
147
|
+
if (daemon) {
|
|
148
|
+
// Only use the daemon that actually owns this thread’s lock; never connect to another CODEX_HOME.
|
|
149
|
+
if (!existsSync(join(codexHome(env), 'app-server-control', 'app-server-control.sock')))
|
|
150
|
+
throw new UsageError('Codex’s session control is unavailable. Stop it locally and retry the move.');
|
|
151
|
+
if (sessionProcessPid(s, env) !== pid)
|
|
152
|
+
throw new UsageError('This session’s process changed. Retry the move.');
|
|
153
|
+
await interruptCodexThread(s, env, ctx.signal);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const owner = processes().find(p => p.pid === pid);
|
|
157
|
+
let owned = descendants(pid);
|
|
158
|
+
if (s.harness === 'claude') {
|
|
159
|
+
if (!sameClaudeProcess(s, env, pid))
|
|
160
|
+
throw new UsageError('This session’s process changed. Retry the move.');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const comm = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 2000 }).stdout?.trim() ?? '';
|
|
164
|
+
if (sessionProcessPid(s, env) !== pid || !/^codex(?:\.exe)?$/.test(basename(comm)))
|
|
165
|
+
throw new UsageError('This session’s process changed. Retry the move.');
|
|
166
|
+
}
|
|
167
|
+
process.kill(pid, 'SIGINT');
|
|
168
|
+
await delay(100, undefined, { signal: ctx.signal });
|
|
169
|
+
const same = () => owner && processes().some(p => p.pid === pid && p.identity === owner.identity);
|
|
170
|
+
if (same()) {
|
|
171
|
+
// Interactive Claude's OS signal handler can leave the turn running. Terminate that verified standalone
|
|
172
|
+
// harness, after giving Desktop/exec their native clean-interrupt path; never signal a shared daemon here.
|
|
173
|
+
owned = [...owned, ...descendants(pid)];
|
|
174
|
+
process.kill(pid, 'SIGTERM');
|
|
175
|
+
await delay(100, undefined, { signal: ctx.signal });
|
|
176
|
+
if (same())
|
|
177
|
+
process.kill(pid, 'SIGKILL');
|
|
178
|
+
}
|
|
179
|
+
await stopCommands(owned);
|
|
180
|
+
}
|
|
181
|
+
const deadline = Date.now() + 5000;
|
|
182
|
+
while (sessionState(s, env).turn === 'working') {
|
|
183
|
+
// macOS can retain an exiting native process and its busy registry entry while kill(pid, 0) still succeeds.
|
|
184
|
+
// Its userspace and the owned commands above are stopped; do not wait for corpse reaping to snapshot.
|
|
185
|
+
if (!daemon && !processes().some(p => p.pid === pid))
|
|
186
|
+
break;
|
|
187
|
+
if (Date.now() >= deadline)
|
|
188
|
+
throw new UsageError('The local turn has not stopped. Stop it locally and retry the move.');
|
|
189
|
+
await delay(50, undefined, { signal: ctx.signal });
|
|
190
|
+
}
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
export const MOVE_CONTINUATION = 'Continue the same reply after the move to the cloud. The local turn was interrupted for this move. Redo the interrupted step from its beginning, including re-running the interrupted tool call; never skip to the next step. Everything completed before the interruption is in this project and session history. Finish the original request without waiting for another prompt.';
|
package/dist/move.js
CHANGED
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
* `shard move` (0.19.0+): a Claude Code or Codex session on this computer continues in a cloud conversation as the same
|
|
3
3
|
* session, with its full history.
|
|
4
4
|
*
|
|
5
|
-
* 1.
|
|
5
|
+
* 1. Stops a user-moved session's local turn; an agent moving itself waits until its own turn ends.
|
|
6
6
|
* 2. Starts a conversation from the live twin's snapshot of the project (uncommitted edits included; the working
|
|
7
7
|
* tree, index and HEAD are not touched) whose record starts with the session (harness_session_id).
|
|
8
8
|
* 3. Puts the transcript, its paths rewritten to the VM's folder, at the harness's native path in the VM and as the
|
|
9
9
|
* conversation's durable copy in the project space, before the repository is seeded.
|
|
10
10
|
* 4. The VM's guest resumes it (`claude --resume <id>`, `codex resume <id>`); this command attaches or detaches.
|
|
11
11
|
* 5. Records the move here; the live twin brings the cloud work back to this checkout, and typing in the local copy
|
|
12
|
-
* shows one line saying it diverges (a Claude Code UserPromptSubmit hook
|
|
12
|
+
* shows one line saying it diverges (a Claude Code UserPromptSubmit hook).
|
|
13
|
+
* 6. An interrupted turn receives a continuation prompt in the VM to redo the interrupted step and finish.
|
|
13
14
|
*/
|
|
14
15
|
import { createHash } from 'node:crypto';
|
|
15
16
|
import { execFileSync } from 'node:child_process';
|
|
16
17
|
import { existsSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
17
19
|
import { dirname, relative, resolve } from 'node:path';
|
|
18
20
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
19
21
|
import { ShardfluxApiError } from '@shardflux/sdk';
|
|
@@ -30,11 +32,12 @@ import { conversationTerminal } from "./conversation-terminal.js";
|
|
|
30
32
|
import { openInChoices } from "./t3-mode.js";
|
|
31
33
|
import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
|
|
32
34
|
import { openUrl } from "./browser.js";
|
|
33
|
-
import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
|
|
34
35
|
import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
|
|
35
36
|
import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
|
|
36
37
|
import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, preserveWorktreeMigration, referencedWorktrees, rewriteWorktreeTranscript } from "./move-worktrees.js";
|
|
37
|
-
|
|
38
|
+
import { interruptLocalTurn, MOVE_CONTINUATION, stopsLocalTurn } from "./move-interrupt.js";
|
|
39
|
+
import { seedPhase } from "./seed-phases.js";
|
|
40
|
+
import { homeSpelling, placeSessionFiles, sessionFiles, VM_HOME, writeChunks } from "./move-files.js";
|
|
38
41
|
const SPACE = '/shardflux/project';
|
|
39
42
|
/** A transcript above this moves with the repository's history bound. */
|
|
40
43
|
const TRANSCRIPT_LIMIT = 256 << 20;
|
|
@@ -83,14 +86,6 @@ export async function waitForTurnEnd(ctx, s, env, pollMs = 1000) {
|
|
|
83
86
|
await delay(pollMs, undefined, { signal: ctx.signal });
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
async function writeChunks(cell, path, bytes) {
|
|
87
|
-
if (!bytes.length) {
|
|
88
|
-
await cell.files.write(path, '', { mode: '0600' });
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
|
|
92
|
-
await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
|
|
93
|
-
}
|
|
94
89
|
/**
|
|
95
90
|
* The session's transcript in the conversation's VM: at its native path (where the harness resumes it) and, when the
|
|
96
91
|
* project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
|
|
@@ -213,13 +208,19 @@ export async function moveSession(ctx, values) {
|
|
|
213
208
|
// worktrees and local branches stay on this computer, untouched (as `this-worktree-only` says explicitly).
|
|
214
209
|
// --preserve-all-worktrees copies them too.
|
|
215
210
|
const preserveAll = values['preserve-all-worktrees'] === true;
|
|
211
|
+
let interrupted = false;
|
|
216
212
|
if (preserveAll && values['this-worktree-only'] === true)
|
|
217
213
|
throw new UsageError('Choose --preserve-all-worktrees or --this-worktree-only.');
|
|
218
214
|
if (values.plan !== true) {
|
|
219
215
|
// A local MCP caller is still in its turn: an unrelated folder is refused while it can still answer.
|
|
220
216
|
if (!preserveAll)
|
|
221
217
|
confirmedCwds(s, completeRecords(readTranscript(s.file)), folder);
|
|
222
|
-
|
|
218
|
+
if (stopsLocalTurn(s, env))
|
|
219
|
+
interrupted = await interruptLocalTurn(ctx, s, env);
|
|
220
|
+
else
|
|
221
|
+
await waitForTurnEnd(ctx, s, env);
|
|
222
|
+
if (interrupted)
|
|
223
|
+
seedPhase('local_turn_stopped');
|
|
223
224
|
}
|
|
224
225
|
const bytes = completeRecords(readTranscript(s.file));
|
|
225
226
|
if (bytes.length > TRANSCRIPT_LIMIT)
|
|
@@ -243,6 +244,13 @@ export async function moveSession(ctx, values) {
|
|
|
243
244
|
if (elsewhere.length && !ctx.json)
|
|
244
245
|
ctx.io.stderr.write(`This session also worked in ${elsewhere.length === 1 ? 'another worktree' : `${elsewhere.length} other worktrees`} of this repository (${elsewhere.join(', ')}); ${elsewhere.length === 1 ? 'it stays' : 'they stay'} on this computer. shard move --preserve-all-worktrees copies every worktree.\n`);
|
|
245
246
|
}
|
|
247
|
+
// Files it used outside the project (an @-mentioned ~/Downloads/handoff.md) go along at the same paths (0.25.0+).
|
|
248
|
+
const home = env.HOME || homedir();
|
|
249
|
+
const files = sessionFiles(bytes, s.harness, home, [folder, ...(plan?.trees.map(t => t.source) ?? []), ...elsewhere]);
|
|
250
|
+
if (!ctx.json && files.files.length)
|
|
251
|
+
ctx.io.stderr.write(`Bringing ${files.files.length === 1 ? 'a file' : `${files.files.length} files`} it used outside the project: ${files.files.map(f => homeSpelling(home, f.path)).join(', ')}\n`);
|
|
252
|
+
if (!ctx.json && files.left.length)
|
|
253
|
+
ctx.io.stderr.write(`Staying on this computer (past the move’s 64 files, 64 MiB): ${files.left.map(p => homeSpelling(home, p)).join(', ')}\n`);
|
|
246
254
|
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
247
255
|
if (!ctx.json)
|
|
248
256
|
ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
|
|
@@ -261,6 +269,7 @@ export async function moveSession(ctx, values) {
|
|
|
261
269
|
// The workspace's cell handle is shared with the seed that follows: it stays open.
|
|
262
270
|
beforeSeed: async (ws, c) => {
|
|
263
271
|
placed = await placeTranscript(ws.cell(), c, s, bytes, folder, ctx.signal, values['preserve-all-worktrees'] === true && plan ? plan : undefined);
|
|
272
|
+
await placeSessionFiles(ws.cell(), files, home, ctx.signal);
|
|
264
273
|
},
|
|
265
274
|
});
|
|
266
275
|
}
|
|
@@ -273,6 +282,7 @@ export async function moveSession(ctx, values) {
|
|
|
273
282
|
throw e;
|
|
274
283
|
}
|
|
275
284
|
const { conversation: c, workspace: ws } = created;
|
|
285
|
+
const terminalValues = interrupted ? { ...values, initialPrompt: MOVE_CONTINUATION } : values;
|
|
276
286
|
const moved = { api_url: ctx.apiUrl, harness: s.harness, session_id: s.session_id, folder, file: s.file, bytes: bytes.length, sha256,
|
|
277
287
|
conversation_id: c.id, key: c.key, title: s.title, moved_at: new Date().toISOString() };
|
|
278
288
|
rememberMoved(ctx.store.dir, moved);
|
|
@@ -292,14 +302,15 @@ export async function moveSession(ctx, values) {
|
|
|
292
302
|
const attach = !ctx.json && values.detach !== true && ctx.io.stdin.isTTY === true;
|
|
293
303
|
if (!attach) {
|
|
294
304
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
295
|
-
await conversationTerminal(quiet, ws, c, place, { ...
|
|
296
|
-
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {})
|
|
305
|
+
await conversationTerminal(quiet, ws, c, place, { ...terminalValues, detach: true });
|
|
306
|
+
out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {}),
|
|
307
|
+
...(files.files.length ? { files: files.files.map(f => f.path) } : {}), ...(files.left.length ? { files_left: files.left } : {}) });
|
|
297
308
|
return 0;
|
|
298
309
|
}
|
|
299
310
|
const where = openInChoices(ctx, project.slug)[s.harness];
|
|
300
311
|
if (s.harness === 'claude' && (where === 'claude-desktop' || where === 'web')) {
|
|
301
312
|
const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
|
|
302
|
-
await conversationTerminal(quiet, ws, c, place, { ...
|
|
313
|
+
await conversationTerminal(quiet, ws, c, place, { ...terminalValues, detach: true });
|
|
303
314
|
if (where === 'claude-desktop')
|
|
304
315
|
return openClaudeDesktopSession(ctx, ws, c);
|
|
305
316
|
const url = await remoteControlUrl(ctx, ws, c);
|
|
@@ -309,7 +320,7 @@ export async function moveSession(ctx, values) {
|
|
|
309
320
|
return 0;
|
|
310
321
|
}
|
|
311
322
|
ctx.io.stderr.write(`Moved to the cloud as ${c.key}: the same session, with its history.\n`);
|
|
312
|
-
return conversationTerminal(ctx, ws, c, place,
|
|
323
|
+
return conversationTerminal(ctx, ws, c, place, terminalValues);
|
|
313
324
|
}
|
|
314
325
|
/** `shard move --list`: this project's sessions on this computer, and where each moved one continues. */
|
|
315
326
|
export function listMovable(ctx, values, env, now = Date.now()) {
|
package/dist/onboarding.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ type CredentialRow = {
|
|
|
22
22
|
app: string;
|
|
23
23
|
status?: string;
|
|
24
24
|
scope?: string;
|
|
25
|
+
fingerprint?: string | null;
|
|
25
26
|
access?: {
|
|
26
27
|
verdict: 'usable' | 'refused' | 'unknown';
|
|
27
28
|
checked_at: string;
|
|
@@ -75,8 +76,11 @@ export interface T3SignInRow {
|
|
|
75
76
|
export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform, startSignIn?: boolean, scope?: Values): Promise<Connection | null>;
|
|
76
77
|
/** Claude: the account login is ready and Claude itself accepts it right now. */
|
|
77
78
|
export declare function claudeConnection(row: CredentialRow | undefined): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
78
|
-
/**
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected.
|
|
81
|
+
* `renewed`: this computer has a Codex login other than the one in the cloud (a new sign-in not copied yet).
|
|
82
|
+
*/
|
|
83
|
+
export declare function codexConnection(local: boolean, synced: boolean, connector: CodexConnectorView | null, health: CodexHealth, renewed?: boolean): Omit<Connection, 'id' | 'picked' | 'found'>;
|
|
80
84
|
/**
|
|
81
85
|
* The onboarding report, for an agent setting itself up from chat and for the menu bar app. It verifies every
|
|
82
86
|
* connection live: the Shardflux account, Claude (Claude itself accepts the login) and Codex (the login is copied and
|