@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +249 -4
  3. package/dist/account-args.js +8 -3
  4. package/dist/account-commands.js +15 -2
  5. package/dist/account-pool.js +5 -1
  6. package/dist/acp-journal.d.ts +2 -2
  7. package/dist/acp-journal.js +14 -4
  8. package/dist/acp-permission-elsewhere.d.ts +38 -0
  9. package/dist/acp-permission-elsewhere.js +85 -0
  10. package/dist/acp-rpc.d.ts +2 -1
  11. package/dist/acp-rpc.js +11 -2
  12. package/dist/acp.js +125 -30
  13. package/dist/agent-credentials.d.ts +3 -0
  14. package/dist/agent-credentials.js +7 -0
  15. package/dist/args.d.ts +1 -1
  16. package/dist/args.js +154 -8
  17. package/dist/board-args.d.ts +6 -0
  18. package/dist/board-args.js +155 -0
  19. package/dist/board-commands.d.ts +62 -0
  20. package/dist/board-commands.js +395 -0
  21. package/dist/codex-cloud.d.ts +30 -3
  22. package/dist/codex-cloud.js +86 -12
  23. package/dist/conversation-commands.d.ts +30 -0
  24. package/dist/conversation-commands.js +312 -9
  25. package/dist/conversation-transcript.d.ts +44 -0
  26. package/dist/conversation-transcript.js +206 -0
  27. package/dist/conversations.d.ts +5 -2
  28. package/dist/conversations.js +20 -10
  29. package/dist/default-volume-commands.d.ts +3 -0
  30. package/dist/default-volume-commands.js +35 -0
  31. package/dist/device-approvals.js +4 -3
  32. package/dist/device-bridge.d.ts +42 -1
  33. package/dist/device-bridge.js +241 -14
  34. package/dist/device-folders.d.ts +58 -0
  35. package/dist/device-folders.js +228 -0
  36. package/dist/device-helper.d.ts +6 -0
  37. package/dist/device-helper.js +87 -14
  38. package/dist/drive-commands.d.ts +25 -0
  39. package/dist/drive-commands.js +201 -0
  40. package/dist/filesystem-commands.js +2 -1
  41. package/dist/handlers.js +6 -1
  42. package/dist/local-sessions.d.ts +7 -1
  43. package/dist/local-sessions.js +38 -0
  44. package/dist/main.js +3 -0
  45. package/dist/move-files.d.ts +39 -0
  46. package/dist/move-files.js +167 -0
  47. package/dist/move-interrupt.d.ts +18 -0
  48. package/dist/move-interrupt.js +193 -0
  49. package/dist/move.js +28 -17
  50. package/dist/onboarding.d.ts +6 -2
  51. package/dist/onboarding.js +24 -6
  52. package/dist/project-git.d.ts +7 -1
  53. package/dist/project-git.js +16 -3
  54. package/dist/project-preparation.d.ts +7 -0
  55. package/dist/project-preparation.js +14 -0
  56. package/dist/project-template-setup.js +32 -11
  57. package/dist/router-commands.d.ts +22 -0
  58. package/dist/router-commands.js +179 -0
  59. package/dist/worktree-twin-engine.js +16 -6
  60. package/package.json +7 -5
@@ -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. Waits until the session's current turn has ended (local-sessions.ts); a turn is never cut off.
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; the local process is never stopped).
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
- const VM_HOME = '/home/user';
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
- await waitForTurnEnd(ctx, s, env);
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, { ...values, detach: true });
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, { ...values, detach: true });
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, values);
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()) {
@@ -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
- /** Codex: this computer's Codex login is copied and the account's Codex remote device (one executor) is connected. */
79
- export declare function codexConnection(local: boolean, synced: boolean, connector: CodexConnectorView | null, health: CodexHealth): Omit<Connection, 'id' | 'picked' | 'found'>;
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