@shardflux/cli 0.24.0 → 0.25.0

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 (59) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +263 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/acp-permission-elsewhere.d.ts +38 -0
  7. package/dist/acp-permission-elsewhere.js +85 -0
  8. package/dist/acp-rpc.d.ts +2 -1
  9. package/dist/acp-rpc.js +11 -2
  10. package/dist/acp.js +95 -22
  11. package/dist/agent-credentials.d.ts +3 -0
  12. package/dist/agent-credentials.js +7 -0
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +172 -9
  15. package/dist/board-args.d.ts +6 -0
  16. package/dist/board-args.js +172 -0
  17. package/dist/board-commands.d.ts +62 -0
  18. package/dist/board-commands.js +412 -0
  19. package/dist/codex-cloud.d.ts +30 -3
  20. package/dist/codex-cloud.js +86 -12
  21. package/dist/conversation-commands.d.ts +30 -0
  22. package/dist/conversation-commands.js +328 -9
  23. package/dist/conversation-transcript.d.ts +44 -0
  24. package/dist/conversation-transcript.js +206 -0
  25. package/dist/conversations.d.ts +7 -3
  26. package/dist/conversations.js +20 -10
  27. package/dist/device-approvals.js +4 -3
  28. package/dist/device-bridge.d.ts +42 -1
  29. package/dist/device-bridge.js +223 -12
  30. package/dist/device-folders.d.ts +66 -0
  31. package/dist/device-folders.js +236 -0
  32. package/dist/device-helper.d.ts +6 -0
  33. package/dist/device-helper.js +98 -14
  34. package/dist/drive-commands.d.ts +25 -0
  35. package/dist/drive-commands.js +201 -0
  36. package/dist/errors.js +2 -1
  37. package/dist/format.js +6 -2
  38. package/dist/handlers.js +7 -1
  39. package/dist/land.d.ts +116 -0
  40. package/dist/land.js +238 -0
  41. package/dist/main.js +10 -1
  42. package/dist/move-files.d.ts +39 -0
  43. package/dist/move-files.js +167 -0
  44. package/dist/move.js +12 -11
  45. package/dist/onboarding.d.ts +10 -6
  46. package/dist/onboarding.js +24 -6
  47. package/dist/project-git.d.ts +7 -1
  48. package/dist/project-git.js +16 -3
  49. package/dist/project-preparation.d.ts +7 -0
  50. package/dist/project-preparation.js +14 -0
  51. package/dist/project-template-setup.js +32 -11
  52. package/dist/router-commands.d.ts +33 -0
  53. package/dist/router-commands.js +179 -0
  54. package/dist/task-land-hub.py +150 -0
  55. package/dist/transcribe-commands.d.ts +7 -0
  56. package/dist/transcribe-commands.js +80 -0
  57. package/dist/worktree-twin-engine.d.ts +8 -0
  58. package/dist/worktree-twin-engine.js +28 -22
  59. package/package.json +2 -2
package/dist/land.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Landing a result for real (0.25.0+): `shard task land <task>` and `shard conversations land <id> merge`.
3
+ *
4
+ * A board task has one result: its master merges its workers' branches into shard/<task id> on the project hub before it
5
+ * seals done (a master that did not is helped here: its workers' branches are merged into it when they merge cleanly).
6
+ * Landing then
7
+ * 1. merges the result into the project's default branch on the hub, in a VM of the project (task-land-hub.py): a
8
+ * fast-forward, or a merge commit when the merge is clean, never by force; and checks there that the result
9
+ * commit is contained in the branch;
10
+ * 2. brings it into the person's linked folder on this computer through the live twin's own landing
11
+ * (worktree-twin.ts landingRound from the hub): the branch moves to the new commits, uncommitted edits stay, every
12
+ * rewritten file is backed up first, and a Git operation in progress or a file being edited waits;
13
+ * 3. only then records it (POST /v1/tasks/{id}/land with the commit; for a conversation POST /land merge into the
14
+ * branch). A conflict changes nothing: the task's master is asked to merge (a steer) and nothing is recorded.
15
+ */
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+ import { conversationClient } from "./conversation-commands.js";
21
+ import { localConversations } from "./conversations.js";
22
+ import { CodedError } from "./errors.js";
23
+ import { selectedProjects } from "./project-preparation.js";
24
+ import { HUB_PATH, backupRoot, deviceId, git, gitText, inspectWorktree, landingRound, listWorktrees } from "./worktree-twin.js";
25
+ import { projectHubCell } from "./worktree-twin-engine.js";
26
+ let script;
27
+ /** The hub side of a landing, shipped next to this module (src in the monorepo, dist when published). */
28
+ export function hubScript() { return script ??= readFileSync(new URL('./task-land-hub.py', import.meta.url), 'utf8'); }
29
+ /** Merges `result` (made from `sources` when missing) into the hub's default branch, in a VM that reads the hub. */
30
+ export async function landOnHub(cell, input, signal) {
31
+ const body = { hub: input.hub ?? HUB_PATH, result: input.result, sources: input.sources, title: input.title, ...(input.into ? { default: input.into } : {}) };
32
+ const r = await cell.exec.run(['python3', '-c', hubScript()], { stdin: JSON.stringify(body), timeoutMs: 300_000, maxOutputBytes: 1 << 20, ...(signal ? { signal } : {}) });
33
+ const line = r.stdout.trim().split('\n').at(-1) ?? '';
34
+ if (r.exitCode !== 0 || !line.startsWith('{'))
35
+ return { state: 'error', into: input.into ?? '', reason: 'script', message: `the landing script exited ${r.exitCode}: ${r.stderr.trim().slice(-400)}` };
36
+ return JSON.parse(line);
37
+ }
38
+ /** The branch to land in: the one checked out in the folder, else the hub's default branch when the folder has it. */
39
+ export async function folderBranch(folder, fallback) {
40
+ const info = await inspectWorktree(folder);
41
+ const own = info.branch?.replace(/^refs\/heads\//, '');
42
+ if (own && !own.startsWith('shard/'))
43
+ return own;
44
+ const has = await git(folder, ['rev-parse', '-q', '--verify', `refs/heads/${fallback}^{commit}`], { ok: [0, 1, 128] });
45
+ return has.code === 0 ? fallback : null;
46
+ }
47
+ /**
48
+ * The result into the folder through the twin's landing (from the hub): the branch moves, the person's uncommitted
49
+ * edits stay, and the landed commit is checked to be on the branch.
50
+ */
51
+ export async function landInFolder(cell, f) {
52
+ const deadline = Date.now() + (f.patienceMs ?? 20_000);
53
+ const target = (await listWorktrees(f.folder)).find(w => w.branch === `refs/heads/${f.into}`);
54
+ const repo = target?.root ?? f.folder;
55
+ for (let attempt = 0;; attempt++) {
56
+ const worktree = target ? await inspectWorktree(target.root) : null;
57
+ const o = await landingRound(cell, {
58
+ conversation: { id: f.name, name: f.name, title: f.title, summary: '', branch: f.branch, base_commit: f.base, worktree_path: '' },
59
+ into: f.into, mode: 'auto', device: f.device, repo, worktree, stateDir: f.stateDir, backupRoot: backupRoot(f.stateDir), fromHub: {},
60
+ ...(f.hub ? { hub: f.hub } : {}), ...(f.quietMs !== undefined ? { quietMs: f.quietMs } : {}), ...(f.signal ? { signal: f.signal } : {}),
61
+ });
62
+ const tip = async () => gitText(repo, ['rev-parse', `refs/heads/${f.into}`]);
63
+ switch (o.state) {
64
+ case 'landed': {
65
+ await o.confirmed.catch(() => undefined);
66
+ const landed = o.land.tip, now = await tip();
67
+ const on = await git(repo, ['merge-base', '--is-ancestor', landed, `refs/heads/${f.into}`], { ok: [0, 1] });
68
+ if (on.code !== 0)
69
+ throw new CodedError('land_unverified', `${f.into} in ${f.folder} does not contain the landed commit ${landed.slice(0, 7)}.`);
70
+ return { path: f.folder, into: f.into, state: 'landed', sha: now, method: o.land.method ?? 'landed', files: o.applied.files.length, kept: o.applied.raced, backup: o.applied.backup };
71
+ }
72
+ case 'noop': return { path: f.folder, into: f.into, state: 'already', sha: await tip() };
73
+ case 'conflict': return { path: f.folder, into: f.into, state: 'conflict', conflicts: o.land.conflicts ?? [] };
74
+ case 'error': throw new CodedError('land_failed', `Could not land into ${f.folder}: ${o.land.message ?? o.land.reason ?? 'the landing failed'}.`);
75
+ case 'pr':
76
+ case 'deferred': return { path: f.folder, into: f.into, state: 'waiting', reason: o.state };
77
+ default: {
78
+ // paused (a Git operation), moved (the branch or a file changed while landing), busy (a file just saved).
79
+ const wait = o.state === 'busy' ? Math.min(o.waitMs ?? 1000, 3000) : o.state === 'moved' ? 200 : 1000;
80
+ if (Date.now() + wait > deadline || attempt >= 20)
81
+ return { path: f.folder, into: f.into, state: 'waiting', reason: o.reason };
82
+ await delay(wait, undefined, f.signal ? { signal: f.signal } : {});
83
+ }
84
+ }
85
+ }
86
+ }
87
+ /** The person's folder for a project on this computer: a linked folder (shard projects link) or where it was started. */
88
+ export function linkedFolder(ctx, projectId, conversationId) {
89
+ const repo = (folder) => folder && existsSync(join(folder, '.git')) ? folder : null;
90
+ const records = localConversations(ctx);
91
+ const own = conversationId ? repo(records[conversationId]?.folder) : null;
92
+ if (own)
93
+ return own;
94
+ // A folder linked with `shard projects link` belongs to the signed-in project until its preparation names one.
95
+ const profile = ctx.store.profile(ctx.apiUrl), mine = profile.project_id ?? profile.api_key?.project_id;
96
+ const linked = selectedProjects(ctx).filter(p => (p.project_id ?? mine) === projectId).map(p => repo(p.folder)).find(Boolean);
97
+ if (linked)
98
+ return linked;
99
+ return Object.values(records).filter(r => r.project_id === projectId).map(r => repo(r.folder)).find(Boolean) ?? null;
100
+ }
101
+ export const displayPath = (path) => { const home = homedir(); return path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; };
102
+ const short = (sha) => (sha ?? '').slice(0, 7);
103
+ const list = (files) => files.length > 4 ? `${files.slice(0, 4).join(', ')} and ${files.length - 4} more` : files.join(', ');
104
+ function message(o) {
105
+ const f = o.folder;
106
+ if (f && (f.state === 'landed' || f.state === 'already'))
107
+ return `Landed in ${f.display} · ${short(f.sha)}`;
108
+ const hub = `Landed in ${o.hub.into} on the project hub · ${short(o.hub.sha)}`;
109
+ if (f?.state === 'conflict')
110
+ return `${hub}. Not in ${f.display}: your commits on ${f.into} change ${list(f.conflicts ?? [])} too.`;
111
+ if (f?.state === 'waiting')
112
+ return `${hub}. ${f.display} gets it once it is not busy (${f.reason ?? 'busy'}).`;
113
+ return hub;
114
+ }
115
+ export function landDeps(ctx) {
116
+ return { client: conversationClient(ctx), hubCell: p => projectHubCell(ctx, p, ctx.signal, 'shard-land'), folder: (p, c) => linkedFolder(ctx, p, c),
117
+ device: deviceId(ctx.store.dir), stateDir: ctx.store.dir, signal: ctx.signal };
118
+ }
119
+ async function land(d, l, onConflict) {
120
+ const vm = await d.hubCell(l.projectId);
121
+ try {
122
+ const input = { result: l.result, sources: l.sources.map(s => s.branch), title: l.title, ...(d.hub ? { hub: d.hub } : {}) };
123
+ let hub = await landOnHub(vm.cell, input, d.signal);
124
+ if (hub.state === 'raced')
125
+ hub = await landOnHub(vm.cell, input, d.signal);
126
+ if (hub.state === 'conflict') {
127
+ await onConflict(hub);
128
+ throw new CodedError('land_conflict', `Not landed: ${list(hub.conflicts ?? [])} changed on both sides (${hub.stage === 'integrate' ? `${hub.source} and ${l.result}` : `${l.result} and ${hub.into}`}).`, 'its master merges them and asks to land again', { stage: hub.stage, into: hub.into, conflicts: hub.conflicts ?? [] });
129
+ }
130
+ if (hub.state === 'missing')
131
+ throw new CodedError('land_nothing', `Nothing to land: the project hub has no ${l.result} yet.`, 'land after the work is done', { branch: l.result });
132
+ if (hub.state !== 'landed')
133
+ throw new CodedError('land_failed', `Could not land on the project hub: ${hub.message ?? hub.reason ?? hub.state}.`, 'try again in a moment', { stage: hub.stage ?? null });
134
+ const contained = l.sources.filter(s => hub.contained?.includes(s.branch)).map(s => s.conversation_id);
135
+ let folder = null;
136
+ if (l.folder) {
137
+ const into = await folderBranch(l.folder, hub.into);
138
+ if (!into)
139
+ folder = { path: l.folder, display: displayPath(l.folder), into: hub.into, state: 'skipped', reason: 'detached' };
140
+ else {
141
+ const f = await landInFolder(vm.cell, { folder: l.folder, into, name: l.name, title: l.title, branch: l.result, base: l.base ?? hub.merge_base ?? hub.before ?? hub.result,
142
+ device: d.device, stateDir: d.stateDir, ...(d.hub ? { hub: d.hub } : {}), ...(d.quietMs !== undefined ? { quietMs: d.quietMs } : {}), ...(d.signal ? { signal: d.signal } : {}) });
143
+ folder = { ...f, display: displayPath(f.path) };
144
+ }
145
+ }
146
+ return { title: l.title, result: { branch: l.result, sha: hub.result }, hub: { into: hub.into, sha: hub.after, method: hub.method }, folder, contained };
147
+ }
148
+ finally {
149
+ vm.close();
150
+ }
151
+ }
152
+ /** What a landing did, for people: the line, then what was not recorded. */
153
+ export function landedText(o) {
154
+ return `${o.message}\n${o.recorded ? '' : `Not recorded on the board: ${o.record_error ?? 'unknown'}.\n`}`;
155
+ }
156
+ const recordFailure = (error) => {
157
+ const e = error;
158
+ return e.status === 404 ? 'this API does not record task landings yet' : (e.message ?? String(error));
159
+ };
160
+ /** The task's current result: its master's latest final report, when it is done. */
161
+ function currentResult(d) {
162
+ const own = d.task.master_id ? d.reports.filter(r => r.author_id === d.task.master_id) : d.reports;
163
+ const final = [...own].reverse().find(r => r.milestone === 'done' || r.milestone === 'failed' || r.milestone === 'planned');
164
+ return final?.milestone === 'done' ? final.id : null;
165
+ }
166
+ const resultBranch = (taskId) => `shard/${taskId}`;
167
+ /** `shard task land <task>`: the task's result into the project's default branch and the linked folder, then recorded. */
168
+ export async function landTask(d, taskId, opts = {}) {
169
+ const client = d.client, signal = d.signal ? { signal: d.signal } : {};
170
+ const detail = await client.tasks.get(taskId, signal);
171
+ const task = detail.task, title = task.title ?? task.text.split('\n')[0].slice(0, 80);
172
+ const report = currentResult(detail);
173
+ if (!report || (task.state !== 'done' && task.state !== 'unverified'))
174
+ throw new CodedError('task_not_done', 'This task has no finished result to land yet.', 'land it after its master’s done report', { state: task.state });
175
+ const workers = [];
176
+ for (const c of task.conversations) {
177
+ if (c.role === 'master' || c.role === 'reviewer')
178
+ continue;
179
+ const w = await client.conversations.get(c.id);
180
+ if (w.join_mode !== 'reviewer' && (w.changes?.commits ?? 0) > 0 && w.landed?.how !== 'discarded')
181
+ workers.push(w);
182
+ }
183
+ const folder = opts.path ?? d.folder(task.project_id);
184
+ const done = await land(d, { projectId: task.project_id, title, name: taskId, result: resultBranch(taskId), sources: workers.map(w => ({ branch: w.branch, conversation_id: w.id })),
185
+ base: workers.find(w => w.base_commit)?.base_commit ?? null, folder }, async (hub) => {
186
+ const what = hub.stage === 'integrate' ? `${hub.source} into ${resultBranch(taskId)}` : `${resultBranch(taskId)} into ${hub.into}`;
187
+ await client.tasks.steer(taskId, `Land could not merge ${what}: ${list(hub.conflicts ?? [])} changed on both sides. Merge ${hub.into} from the project hub and every worker branch into ${resultBranch(taskId)}, resolve the conflicts so both changes work (ask the person only if the choice is theirs), run the tests, push ${resultBranch(taskId)} to the hub and seal done again.`, signal);
188
+ });
189
+ let recorded = false, record_error;
190
+ try {
191
+ await client.tasks.recordLanding(taskId, { sha: done.result.sha, into: done.hub.into, branch: done.result.branch, reportId: report, conversationIds: done.contained }, signal);
192
+ recorded = true;
193
+ }
194
+ catch (error) {
195
+ record_error = recordFailure(error);
196
+ // An API without task landings (before train 41) keeps a land item per worker: the workers whose branches are in
197
+ // the landed commit are recorded as merged into the branch, which is true, so those items go.
198
+ if (error.status === 404 && done.contained.length) {
199
+ let all = true;
200
+ for (const id of done.contained) {
201
+ try {
202
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...signal });
203
+ }
204
+ catch {
205
+ all = false;
206
+ }
207
+ }
208
+ if (all) {
209
+ recorded = true;
210
+ record_error = undefined;
211
+ }
212
+ }
213
+ }
214
+ const { title: t, result, hub, folder: f } = done;
215
+ const o = { title: t, result, hub, folder: f, landed: true, task_id: taskId, recorded, ...(record_error ? { record_error } : {}) };
216
+ return { ...o, message: message(o) };
217
+ }
218
+ /** `shard conversations land <id> merge`: the conversation's branch into the default branch and its folder, then recorded. */
219
+ export async function landConversation(d, id, opts = {}) {
220
+ const client = d.client;
221
+ const c = await client.conversations.get(id);
222
+ if (c.changes?.uncommitted)
223
+ throw new CodedError('uncommitted_changes', 'The conversation has uncommitted changes.', 'ask its agent to commit them, then land');
224
+ const folder = opts.path ?? d.folder(c.project_id, c.id);
225
+ const done = await land(d, { projectId: c.project_id, title: c.title || c.branch, name: c.name ?? c.branch.replace(/^shard\//, ''), result: c.branch, sources: [],
226
+ base: c.base_commit ?? null, folder }, () => Promise.resolve());
227
+ let recorded = false, record_error;
228
+ try {
229
+ await client.conversations.land(id, 'merge', { into: done.hub.into, ...(d.signal ? { signal: d.signal } : {}) });
230
+ recorded = true;
231
+ }
232
+ catch (error) {
233
+ record_error = recordFailure(error);
234
+ }
235
+ const { title: t, result, hub, folder: f } = done;
236
+ const o = { title: t, result, hub, folder: f, landed: true, conversation_id: id, recorded, ...(record_error ? { record_error } : {}) };
237
+ return { ...o, message: message(o) };
238
+ }
package/dist/main.js CHANGED
@@ -66,7 +66,11 @@ function hintFor(info, cred, file, command = '') {
66
66
  if (info.code === 'timeout' && info.operation_id)
67
67
  return `the operation continues server side; keep waiting with: shard operations wait ${info.operation_id}`;
68
68
  if (info.code === 'operation_failed' && info.retryable) {
69
- return info.details?.error_code === 'capacity_unavailable' ? 'nothing was started; run the same command again' : 'the operation may succeed if you retry it later.';
69
+ if (info.details?.error_code === 'capacity_unavailable') {
70
+ const retry = info.details.retry_after_seconds;
71
+ return `nothing was started; run the same command again${typeof retry === 'number' && retry > 0 ? ` after ${retry} s` : ''}`;
72
+ }
73
+ return 'the operation may succeed if you retry it later.';
70
74
  }
71
75
  if (info.code === 'interrupted' && info.operation_id)
72
76
  return `keep waiting with: shard operations wait ${info.operation_id}`;
@@ -175,6 +179,8 @@ function allowanceHint(info) {
175
179
  const resets = typeof info.details?.resets_at === 'string' ? ` (the period resets ${time(info.details.resets_at)})` : '';
176
180
  switch (info.details?.reason) {
177
181
  case 'allowance_used':
182
+ if (info.details?.spend_cap === null)
183
+ return 'choose a paid plan: shard billing plans; overage is unavailable on the current plan';
178
184
  return `upgrade (shard billing plans), or turn on overage: shard billing overage on --cap <dollars>${resets}`;
179
185
  case 'spend_cap_reached':
180
186
  return `the spend cap is reached: raise it (shard billing overage cap <dollars>, at most the plan price) or upgrade${resets}`;
@@ -371,6 +377,9 @@ const NO_NUDGE_REASONS = new Set([
371
377
  // Inbound ports (0.9.0): the hint names the command to expose or close a port.
372
378
  'port_not_exposed',
373
379
  'port_limit',
380
+ // Permission requests (0.25.0+): answered already (here or where the agent runs), or its agent no longer waits.
381
+ 'permission_answered',
382
+ 'permission_canceled',
374
383
  ]);
375
384
  /**
376
385
  * 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
+ }
package/dist/move.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { createHash } from 'node:crypto';
16
16
  import { execFileSync } from 'node:child_process';
17
17
  import { existsSync, realpathSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
18
19
  import { dirname, relative, resolve } from 'node:path';
19
20
  import { setTimeout as delay } from 'node:timers/promises';
20
21
  import { ShardfluxApiError } from '@shardflux/sdk';
@@ -31,13 +32,12 @@ import { conversationTerminal } from "./conversation-terminal.js";
31
32
  import { openInChoices } from "./t3-mode.js";
32
33
  import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
33
34
  import { openUrl } from "./browser.js";
34
- import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
35
35
  import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
36
36
  import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
37
37
  import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, preserveWorktreeMigration, referencedWorktrees, rewriteWorktreeTranscript } from "./move-worktrees.js";
38
38
  import { interruptLocalTurn, MOVE_CONTINUATION, stopsLocalTurn } from "./move-interrupt.js";
39
39
  import { seedPhase } from "./seed-phases.js";
40
- const VM_HOME = '/home/user';
40
+ import { homeSpelling, placeSessionFiles, sessionFiles, VM_HOME, writeChunks } from "./move-files.js";
41
41
  const SPACE = '/shardflux/project';
42
42
  /** A transcript above this moves with the repository's history bound. */
43
43
  const TRANSCRIPT_LIMIT = 256 << 20;
@@ -86,14 +86,6 @@ export async function waitForTurnEnd(ctx, s, env, pollMs = 1000) {
86
86
  await delay(pollMs, undefined, { signal: ctx.signal });
87
87
  }
88
88
  }
89
- async function writeChunks(cell, path, bytes) {
90
- if (!bytes.length) {
91
- await cell.files.write(path, '', { mode: '0600' });
92
- return;
93
- }
94
- for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
95
- await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
96
- }
97
89
  /**
98
90
  * The session's transcript in the conversation's VM: at its native path (where the harness resumes it) and, when the
99
91
  * project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
@@ -252,6 +244,13 @@ export async function moveSession(ctx, values) {
252
244
  if (elsewhere.length && !ctx.json)
253
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`);
254
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`);
255
254
  const sha256 = createHash('sha256').update(bytes).digest('hex');
256
255
  if (!ctx.json)
257
256
  ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
@@ -270,6 +269,7 @@ export async function moveSession(ctx, values) {
270
269
  // The workspace's cell handle is shared with the seed that follows: it stays open.
271
270
  beforeSeed: async (ws, c) => {
272
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);
273
273
  },
274
274
  });
275
275
  }
@@ -303,7 +303,8 @@ export async function moveSession(ctx, values) {
303
303
  if (!attach) {
304
304
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
305
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 } : {}) });
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 } : {}) });
307
308
  return 0;
308
309
  }
309
310
  const where = openInChoices(ctx, project.slug)[s.harness];
@@ -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
@@ -107,7 +111,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
107
111
  };
108
112
  conversation: {
109
113
  id: string;
110
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
114
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
111
115
  summary: string;
112
116
  } | null;
113
117
  commands: never[];
@@ -138,7 +142,7 @@ export declare function onboardingReport(ctx: Ctx, values: Values, platform?: No
138
142
  };
139
143
  conversation: {
140
144
  id: string;
141
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
145
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
142
146
  summary: string;
143
147
  } | null;
144
148
  commands: string[];
@@ -191,7 +195,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
191
195
  };
192
196
  conversation: {
193
197
  id: string;
194
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
198
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
195
199
  summary: string;
196
200
  } | null;
197
201
  commands: never[];
@@ -222,7 +226,7 @@ export declare function liveActivity(ctx: Ctx, ref: string): Promise<{
222
226
  };
223
227
  conversation: {
224
228
  id: string;
225
- state: "archived" | "working" | "needs_you" | "unresolved" | "done";
229
+ state: "deleted" | "archived" | "working" | "needs_you" | "unresolved" | "done";
226
230
  summary: string;
227
231
  } | null;
228
232
  commands: string[];