@shardflux/cli 0.15.2 → 0.17.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 (77) hide show
  1. package/CHANGELOG.md +144 -14
  2. package/README.md +92 -7
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +15 -1
  5. package/dist/agent-commands.js +84 -23
  6. package/dist/agent-models.d.ts +61 -0
  7. package/dist/agent-models.js +79 -0
  8. package/dist/agent-state.d.ts +10 -0
  9. package/dist/agent-state.js +13 -0
  10. package/dist/agent-target-commands.d.ts +13 -3
  11. package/dist/agent-target-commands.js +129 -23
  12. package/dist/agent-targets.d.ts +5 -0
  13. package/dist/agent-targets.js +14 -5
  14. package/dist/agent-terminal.d.ts +8 -0
  15. package/dist/agent-terminal.js +35 -2
  16. package/dist/args.d.ts +5 -1
  17. package/dist/args.js +74 -11
  18. package/dist/cli-proxy-forward.d.ts +45 -0
  19. package/dist/cli-proxy-forward.js +432 -0
  20. package/dist/cli-proxy-hub.d.ts +273 -0
  21. package/dist/cli-proxy-hub.js +749 -0
  22. package/dist/cli-proxy.d.ts +325 -0
  23. package/dist/cli-proxy.js +1016 -0
  24. package/dist/codex-cloud.d.ts +3 -0
  25. package/dist/codex-cloud.js +17 -5
  26. package/dist/command-center-actions.js +25 -1
  27. package/dist/commands.d.ts +22 -0
  28. package/dist/commands.js +54 -14
  29. package/dist/conversation-commands.js +6 -0
  30. package/dist/conversation-local.js +2 -2
  31. package/dist/conversation-stash.js +4 -3
  32. package/dist/conversation-terminal.d.ts +5 -0
  33. package/dist/conversation-terminal.js +54 -6
  34. package/dist/conversations.d.ts +31 -3
  35. package/dist/conversations.js +53 -10
  36. package/dist/device-approvals.d.ts +12 -0
  37. package/dist/device-approvals.js +73 -0
  38. package/dist/device-helper.d.ts +3 -1
  39. package/dist/device-helper.js +14 -4
  40. package/dist/errors.d.ts +7 -0
  41. package/dist/errors.js +12 -2
  42. package/dist/files-mirror.d.ts +164 -0
  43. package/dist/files-mirror.js +761 -0
  44. package/dist/handlers.js +5 -1
  45. package/dist/index.d.ts +4 -0
  46. package/dist/index.js +3 -0
  47. package/dist/linux-helper.d.ts +12 -0
  48. package/dist/linux-helper.js +88 -0
  49. package/dist/linux-terminal.d.ts +11 -0
  50. package/dist/linux-terminal.js +42 -0
  51. package/dist/main.js +38 -8
  52. package/dist/onboarding.d.ts +2 -0
  53. package/dist/onboarding.js +53 -5
  54. package/dist/registry.d.ts +2 -42
  55. package/dist/registry.js +6 -90
  56. package/dist/t3-launch.d.ts +37 -0
  57. package/dist/t3-launch.js +133 -0
  58. package/dist/t3-mode.d.ts +121 -0
  59. package/dist/t3-mode.js +654 -0
  60. package/dist/t3-phone.d.ts +17 -0
  61. package/dist/t3-phone.js +15 -0
  62. package/dist/t3-thread-script.d.ts +18 -0
  63. package/dist/t3-thread-script.js +245 -0
  64. package/dist/terminal-onboarding.d.ts +11 -0
  65. package/dist/terminal-onboarding.js +75 -0
  66. package/dist/thread-on-computer.d.ts +35 -0
  67. package/dist/thread-on-computer.js +206 -0
  68. package/dist/update.d.ts +1 -1
  69. package/dist/update.js +12 -1
  70. package/dist/wait-status.d.ts +22 -0
  71. package/dist/wait-status.js +59 -0
  72. package/dist/worktree-twin-engine.d.ts +125 -0
  73. package/dist/worktree-twin-engine.js +615 -0
  74. package/dist/worktree-twin-land.py +331 -0
  75. package/dist/worktree-twin.d.ts +313 -0
  76. package/dist/worktree-twin.js +843 -0
  77. package/package.json +3 -3
@@ -7,6 +7,13 @@ import { ensureProjectGit } from "./project-git.js";
7
7
  import { seedAgentRepository } from "./agent-repository.js";
8
8
  import { UsageError } from "./errors.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
+ import { recordSeedSnapshot } from "./files-mirror.js";
11
+ import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
12
+ /**
13
+ * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
14
+ * while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
15
+ */
16
+ export const conversationEnded = (c) => !!c.ended_at || c.state === 'archived' || c.landed?.how === 'discarded' || (c.route === 'codex-cloud' && c.state === 'done');
10
17
  export const conversationName = (c) => {
11
18
  const name = c.key.split('/').at(-1);
12
19
  if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== `shard/${name}`)
@@ -23,11 +30,12 @@ export function localConversations(ctx) {
23
30
  const records = new AgentStateStore(ctx.store.dir).read().conversations ?? {};
24
31
  return Object.fromEntries(Object.entries(records).filter(([, r]) => r.api_url === ctx.apiUrl));
25
32
  }
26
- export function rememberConversation(ctx, c, folder, handedOff = false) {
33
+ export function rememberConversation(ctx, c, folder, handedOff = false, baseBranch) {
27
34
  validateConversation(c);
28
35
  new AgentStateStore(ctx.store.dir).update(doc => {
29
36
  const records = doc.conversations ?? {};
30
- records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}) };
37
+ const base = baseBranch ?? records[c.id]?.base_branch;
38
+ records[c.id] = { api_url: ctx.apiUrl, folder, project_id: c.project_id, conversation: c, ...(handedOff ? { handed_off: true } : {}), ...(base ? { base_branch: base } : {}) };
31
39
  doc.conversations = records;
32
40
  });
33
41
  }
@@ -68,9 +76,36 @@ export async function conversationWorkspace(ctx, c) {
68
76
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
69
77
  return ws;
70
78
  }
79
+ /**
80
+ * A thread on an existing computer (a join) is prepared by that computer's guest: its worktree, then its harness. Waits
81
+ * until the guest confirms it (the conversation no longer carries restore instructions), within the wake timeout.
82
+ */
83
+ export async function waitThreadReady(ctx, c, pollMs = 1000) {
84
+ const deadline = Date.now() + ctx.wakeTimeoutMs;
85
+ for (;;) {
86
+ const current = await getConversation(ctx, c.id);
87
+ if (!current.restore || current.restore.kind !== 'join')
88
+ return current;
89
+ if (current.state === 'done' || current.state === 'archived' || current.ended_at)
90
+ throw new UsageError('This thread ended before it started.');
91
+ if (Date.now() >= deadline)
92
+ throw new UsageError(`The computer is still preparing this thread; open it with shard agents open ${c.id} in a moment.`);
93
+ await new Promise((resolve, reject) => {
94
+ const timer = setTimeout(done, Math.min(pollMs, Math.max(1, deadline - Date.now())));
95
+ function done() { ctx.signal.removeEventListener('abort', abort); resolve(); }
96
+ function abort() { clearTimeout(timer); reject(ctx.signal.reason ?? new Error('interrupted')); }
97
+ ctx.signal.addEventListener('abort', abort, { once: true });
98
+ });
99
+ }
100
+ }
71
101
  export async function restoreConversationBinding(ctx, c, ws) {
72
102
  if (!c.restore || c.restore.kind === 'new')
73
103
  return;
104
+ // Threads on an existing computer (0.16.0+): its guest prepares them, never this command.
105
+ if (c.restore.kind === 'join') {
106
+ await waitThreadReady(ctx, c);
107
+ return;
108
+ }
74
109
  const cell = ws.cell();
75
110
  if (c.restore.kind === 'join' && c.restore.read_only === true) {
76
111
  const review = await cell.exec.run(['sf-agent', 'conversation', 'restore', '--args', '{"start":false}'], { cwd: c.worktree_path, env: { SF_CONVERSATION_ID: c.id }, timeoutMs: 120_000, signal: ctx.signal });
@@ -86,32 +121,38 @@ export async function restoreConversationBinding(ctx, c, ws) {
86
121
  if (result.exitCode !== 0)
87
122
  throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
88
123
  }
89
- export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
124
+ export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}) {
90
125
  const project = projectIdentity(folder);
91
126
  await ensureProjectGit(ctx, project.folder);
92
127
  // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
93
128
  await conversationClient(ctx).conversations.list({ limit: 1 });
94
129
  const git = gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal);
95
- const commit = await git(['rev-parse', 'HEAD']);
130
+ const head = await git(['rev-parse', 'HEAD']);
96
131
  const branch = await git(['symbolic-ref', '--quiet', '--short', 'HEAD']).catch(() => undefined);
132
+ // Uncommitted edits ride along as the twin snapshot the conversation starts from (worktree-twin.ts).
133
+ const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
134
+ const commit = seed.commit;
97
135
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
98
- base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
136
+ base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
99
137
  const c = validateConversation({ ...response.conversation, restore: response.restore });
100
138
  allocated?.(response.workspace);
101
- rememberConversation(ctx, c, project.folder);
139
+ rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
140
+ rememberConversation(ctx, c, project.folder, false, branch);
102
141
  return c;
103
142
  }
104
- export async function createConversation(ctx, folder, harness, route, name) {
143
+ export async function createConversation(ctx, folder, harness, route, name, choice = {}) {
105
144
  const project = projectIdentity(folder);
106
- const c = await createConversationRecord(ctx, project.folder, harness, route, name);
145
+ const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice);
107
146
  try {
108
147
  const ws = await conversationWorkspace(ctx, c);
109
148
  // This is a one-time seed. A conversation never watches or writes the owner's checkout.
110
149
  await seedAgentRepository(ctx, ws, project.folder, { root: c.worktree_path, populate: true });
111
150
  const cell = ws.cell();
112
- const result = await cell.exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
151
+ await sendTwinSeed(ctx.store.dir, cell, c, project.folder, ctx.signal);
152
+ const result = await cell.exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
113
153
  if (result.exitCode !== 0)
114
154
  throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
155
+ await recordSeedSnapshot(ws, c, ctx.signal);
115
156
  return { conversation: c, workspace: ws };
116
157
  }
117
158
  catch (error) {
@@ -127,8 +168,10 @@ export async function createConversation(ctx, folder, harness, route, name) {
127
168
  }
128
169
  }
129
170
  export async function resumeConversation(ctx, c) {
130
- if (c.state !== 'done' && c.state !== 'archived')
171
+ if (!conversationEnded(c))
131
172
  return getConversation(ctx, c.id);
132
173
  const response = await conversationClient(ctx).conversations.resume(c.id, { idempotencyKey: randomUUID(), signal: ctx.signal });
133
174
  return validateConversation({ ...response.conversation, restore: response.restore });
134
175
  }
176
+ /** The model and effort a conversation's harness starts with, as its record keeps them. */
177
+ export const recordedChoice = (c) => ({ ...(c.model ? { model: c.model } : {}), ...(c.effort ? { effort: c.effort } : {}) });
@@ -0,0 +1,12 @@
1
+ import type { DeviceApproval, DeviceChoice } from './device-bridge.js';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ type Pending = DeviceApproval & {
4
+ nonce: string;
5
+ pid: number;
6
+ };
7
+ export declare function pendingDeviceApprovals(dir: string): Pending[];
8
+ export declare function answerDeviceApproval(dir: string, id: string, choice: DeviceChoice): void;
9
+ /** Local user-owned files carry approvals; cloud data can never answer its own request. */
10
+ export declare function askLocalDeviceApproval(ctx: Ctx, approval: DeviceApproval): Promise<DeviceChoice>;
11
+ export declare const DEVICE_HANDLERS: Record<string, Handler>;
12
+ export {};
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readdirSync, readFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { writePrivateFile } from "./credentials.js";
7
+ import { UsageError } from "./errors.js";
8
+ import { json } from "./format.js";
9
+ const paths = (dir, id) => {
10
+ if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
11
+ throw new UsageError('Invalid device request id.');
12
+ return { pending: join(dir, 'device-approvals', `${id}.json`), answer: join(dir, 'device-answers', `${id}.json`) };
13
+ };
14
+ export function pendingDeviceApprovals(dir) {
15
+ let names;
16
+ try {
17
+ names = readdirSync(join(dir, 'device-approvals'));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ return names.filter(n => n.endsWith('.json')).flatMap(n => {
23
+ try {
24
+ const p = JSON.parse(readFileSync(paths(dir, n.slice(0, -5)).pending, 'utf8'));
25
+ process.kill(p.pid, 0);
26
+ return [p];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ });
32
+ }
33
+ export function answerDeviceApproval(dir, id, choice) {
34
+ const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
35
+ if (!pending)
36
+ throw new UsageError('That request is no longer waiting for approval. Run shard device requests.');
37
+ writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
38
+ }
39
+ /** Local user-owned files carry approvals; cloud data can never answer its own request. */
40
+ export async function askLocalDeviceApproval(ctx, approval) {
41
+ const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
42
+ rmSync(file.answer, { force: true });
43
+ writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
44
+ spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\nReview: shard device requests`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
45
+ try {
46
+ while (!ctx.signal.aborted) {
47
+ try {
48
+ const answer = JSON.parse(readFileSync(file.answer, 'utf8'));
49
+ if (answer.nonce === nonce && ['once', 'always', 'deny'].includes(answer.choice))
50
+ return answer.choice;
51
+ }
52
+ catch { /* wait for the local CLI */ }
53
+ await delay(200, undefined, { signal: ctx.signal }).catch(() => undefined);
54
+ }
55
+ return 'deny';
56
+ }
57
+ finally {
58
+ rmSync(file.pending, { force: true });
59
+ rmSync(file.answer, { force: true });
60
+ }
61
+ }
62
+ export const DEVICE_HANDLERS = {
63
+ 'device requests'(ctx) {
64
+ const requests = pendingDeviceApprovals(ctx.store.dir);
65
+ ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}\n shard device approve ${r.request_id} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
66
+ return Promise.resolve(0);
67
+ },
68
+ ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, _values, [id]) => {
69
+ answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? 'once' : 'deny');
70
+ ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action }) : `Device request ${id}: ${action === 'approve' ? 'approved once' : 'denied'}.\n`);
71
+ return Promise.resolve(0);
72
+ }])),
73
+ };
@@ -1,3 +1,5 @@
1
1
  import type { Ctx } from './commands.js';
2
2
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
3
- export declare function runDeviceHelper(ctx: Ctx): Promise<number>;
3
+ export declare function runDeviceHelper(ctx: Ctx, options?: {
4
+ service?: boolean;
5
+ }): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { DeviceExecutor, askConnectedAcp } from "./device-bridge.js";
3
3
  import { ConversationMirrorState, mirrorProjectConversations } from "./conversation-stash.js";
4
+ import { FilesMirrorState, mirrorFilesOnce } from "./files-mirror.js";
4
5
  import { withTargetLock } from "./target-lock.js";
5
6
  import { join } from 'node:path';
6
7
  import { conversationsUnavailable } from "./conversations.js";
@@ -10,13 +11,15 @@ import { readFileSync } from 'node:fs';
10
11
  import { writePrivateFile } from "./credentials.js";
11
12
  import { conversationClient } from "./conversation-commands.js";
12
13
  import { Readable } from 'node:stream';
14
+ import { runWorktreeTwin } from "./worktree-twin-engine.js";
15
+ import { askLocalDeviceApproval } from "./device-approvals.js";
13
16
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
14
- export async function runDeviceHelper(ctx) {
17
+ export async function runDeviceHelper(ctx, options = {}) {
15
18
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
16
19
  ctx = { ...ctx, signal };
17
20
  const pending = new Map();
18
21
  const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
19
- const input = (async () => {
22
+ const input = options.service ? Promise.resolve() : (async () => {
20
23
  let tail = '';
21
24
  for await (const bytes of inputSource) {
22
25
  tail += Buffer.from(bytes).toString();
@@ -39,6 +42,8 @@ export async function runDeviceHelper(ctx) {
39
42
  const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
40
43
  if (acp)
41
44
  return acp;
45
+ if (options.service)
46
+ return askLocalDeviceApproval(ctx, approval);
42
47
  return new Promise(resolve => {
43
48
  const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
44
49
  signal.addEventListener('abort', denied, { once: true });
@@ -50,7 +55,7 @@ export async function runDeviceHelper(ctx) {
50
55
  ctx.io.stdout.write(JSON.stringify({ type: 'approval_resolved', request_id: approval.request_id }) + '\n'); });
51
56
  };
52
57
  const working = new Map();
53
- const mirrored = new ConversationMirrorState();
58
+ const mirrored = new ConversationMirrorState(), files = new FilesMirrorState();
54
59
  const deviceFile = join(ctx.store.dir, 'device-id');
55
60
  let deviceId;
56
61
  try {
@@ -62,15 +67,19 @@ export async function runDeviceHelper(ctx) {
62
67
  }
63
68
  const executor = new DeviceExecutor(ctx, ask, deviceId);
64
69
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
65
- let mirroring;
70
+ let mirroring, twin;
66
71
  try {
67
72
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
73
+ // Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
74
+ twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
68
75
  // A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
69
76
  mirroring = (async () => {
70
77
  let failures = 0;
71
78
  const reported = new Set();
72
79
  while (!signal.aborted) {
73
80
  let wait = 1000;
81
+ // Committed files of every conversation, under ~/Shardflux; its own interval and backoff.
82
+ await mirrorFilesOnce(ctx, files);
74
83
  try {
75
84
  await mirrorProjectConversations(ctx, mirrored);
76
85
  failures = 0;
@@ -124,6 +133,7 @@ export async function runDeviceHelper(ctx) {
124
133
  await executor.close();
125
134
  await Promise.all([...working.values()]);
126
135
  await mirroring;
136
+ await twin;
127
137
  await input.catch(() => undefined);
128
138
  }
129
139
  }
package/dist/errors.d.ts CHANGED
@@ -114,6 +114,8 @@ export declare class ExecSessionRunningError extends HintedError {
114
114
  export interface ErrorInfo {
115
115
  code: string;
116
116
  message: string;
117
+ /** A refusal `shard` detects itself (CodedError, 0.17.0+): the next step, as plain text (the `hint:` line). */
118
+ fix?: string;
117
119
  status?: number;
118
120
  request_id?: string;
119
121
  retryable?: boolean;
@@ -121,6 +123,11 @@ export interface ErrorInfo {
121
123
  details?: Record<string, unknown>;
122
124
  source?: 'api' | 'cell' | 'unknown';
123
125
  last_state?: string;
126
+ /** A wait that timed out (0.16.0+): the operation's last state_reason. */
127
+ last_reason?: string;
128
+ /** A wait that timed out on a queued start (0.16.0+): its deadline (RFC 3339) and place in the queue. */
129
+ deadline_at?: string;
130
+ queue_position?: number;
124
131
  operation?: unknown;
125
132
  /** A file-first execution the error is about (failed, lost, still running, or left running by Ctrl-C). */
126
133
  execution_id?: string;
package/dist/errors.js CHANGED
@@ -199,7 +199,17 @@ export function describeError(err) {
199
199
  };
200
200
  }
201
201
  if (err instanceof OperationTimeoutError) {
202
- return { code: 'timeout', message: err.message, operation_id: err.operationId, last_state: err.lastState, ...(err.durable ? { details: { durable: false, state: 'pending' } } : {}) };
202
+ // A queued start (0.16.0+): its last reason, deadline and place in the queue.
203
+ return {
204
+ code: 'timeout',
205
+ message: err.message,
206
+ operation_id: err.operationId,
207
+ last_state: err.lastState,
208
+ ...(err.lastReason ? { last_reason: err.lastReason } : {}),
209
+ ...(err.deadlineAt ? { deadline_at: err.deadlineAt } : {}),
210
+ ...(err.queuePosition !== null ? { queue_position: err.queuePosition } : {}),
211
+ ...(err.durable ? { details: { durable: false, state: 'pending' } } : {}),
212
+ };
203
213
  }
204
214
  // --durable (0.7.0+): the suspend succeeded; its durable copy could not be made (see the lifecycle reference).
205
215
  if (err instanceof DurabilityLostError) {
@@ -231,7 +241,7 @@ export function describeError(err) {
231
241
  if (err instanceof GitError)
232
242
  return { code: 'git_error', message: err.message, retryable: false };
233
243
  if (err instanceof CodedError)
234
- return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
244
+ return { code: err.code, message: err.message, retryable: false, ...(err.hint ? { fix: err.hint } : {}), ...(err.details ? { details: err.details } : {}) };
235
245
  if (err instanceof CheckoutTimeoutError) {
236
246
  return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
237
247
  }
@@ -0,0 +1,164 @@
1
+ import type { Workspace } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import type { Conversation, LocalConversation } from './conversations.js';
4
+ /**
5
+ * Files across conversations on the Mac (docs/progress/cross-conversation-files.md): a read-only folder per
6
+ * conversation, `~/Shardflux/<repository>/<base branch>/<conversation>/`, holding what that conversation committed
7
+ * relative to its fork point. Git (the project hub) is the only channel; the user's own checkouts are never written.
8
+ * Files above the per-file limit, or past the conversation's total, are placeholders until `shard files pull`.
9
+ */
10
+ export declare const HUB_PATH = "/shardflux/project/git/hub.git";
11
+ export declare const MANIFEST = ".shardflux-manifest.json";
12
+ export declare const PLACEHOLDER = ".placeholder.txt";
13
+ export declare const FILES_CONFIG = ".shardflux/files.yaml";
14
+ export declare const DEFAULT_MAX_FILE: number;
15
+ export declare const DEFAULT_MAX_CONVERSATION: number;
16
+ export type MirrorConversation = Conversation & {
17
+ name?: string;
18
+ join_mode?: string | null;
19
+ base_branch?: string | null;
20
+ repo_remote?: string;
21
+ landed?: {
22
+ how: string;
23
+ ref?: string;
24
+ at?: string;
25
+ } | null;
26
+ };
27
+ export interface ManifestFile {
28
+ path: string;
29
+ oid: string;
30
+ size: number;
31
+ mode: string;
32
+ state: 'present' | 'placeholder' | 'submodule' | 'symlink';
33
+ reason?: 'file_limit' | 'conversation_limit';
34
+ fetched?: boolean;
35
+ }
36
+ export interface Manifest {
37
+ version: 1;
38
+ conversation_id: string;
39
+ project_id: string;
40
+ name: string;
41
+ title: string;
42
+ repository: string;
43
+ branch: string;
44
+ base_branch: string | null;
45
+ commit: string;
46
+ fork_point: string;
47
+ seed?: string;
48
+ max_file_bytes: number;
49
+ max_conversation_bytes: number;
50
+ files: ManifestFile[];
51
+ deleted: string[];
52
+ landed?: {
53
+ how: string;
54
+ ref: string;
55
+ at: string;
56
+ note: string;
57
+ };
58
+ updated_at: string;
59
+ }
60
+ export interface Limits {
61
+ maxFile: number;
62
+ maxConversation: number;
63
+ }
64
+ export interface Change {
65
+ status: string;
66
+ mode: string;
67
+ oid: string;
68
+ path: string;
69
+ }
70
+ export declare function filesRoot(env: Record<string, string | undefined>): string;
71
+ /** A base branch as one path component: slashes and other separators become "-". */
72
+ export declare function branchFolder(branch: string | null | undefined): string;
73
+ /** A human name as one Finder path component. */
74
+ export declare function humanFolder(text: string, fallback: string): string;
75
+ export declare function repositoryName(identity: string): string;
76
+ export declare function parseFilesConfig(text: string | undefined): Limits;
77
+ export declare function parseDiffTree(bytes: Buffer): Change[];
78
+ export declare function safeRelative(path: string): boolean;
79
+ /** Smallest first while the conversation's total stays within its limit; fetched files stay while unchanged. */
80
+ export declare function planFiles(files: ManifestFile[], limits: Limits, prev: Map<string, ManifestFile>): ManifestFile[];
81
+ export declare function humanBytes(n: number): string;
82
+ export declare function placeholderText(m: Manifest, f: ManifestFile): string;
83
+ export interface GitResult {
84
+ code: number;
85
+ stdout: Buffer;
86
+ stderr: string;
87
+ }
88
+ /**
89
+ * Read-only git against the project hub. `blobs` delivers `git cat-file --batch` output for the given ids, any number
90
+ * of files at once. (The hub's upload-pack, reached through the platform Git bridge, serves advertised refs only, so a
91
+ * fetch of single blobs by id is refused; this needs no hub configuration.)
92
+ */
93
+ export interface Hub {
94
+ git(args: string[], stdin?: string): Promise<GitResult>;
95
+ blobs(oids: string[], onData: (chunk: Uint8Array) => void): Promise<void>;
96
+ close(): void;
97
+ }
98
+ /** A hub on this machine (tests). */
99
+ export declare function localHub(path: string): Hub;
100
+ /** The project hub through a ready VM of the project: git runs there, file contents stream back over the same exec API. */
101
+ export declare function cellHub(ws: Workspace, signal: AbortSignal): Hub;
102
+ /** Copy files' contents from the hub into root, in one stream. */
103
+ export declare function copyFromHub(hub: Hub, root: string, files: ManifestFile[]): Promise<void>;
104
+ export interface PlaceholderEntry {
105
+ conversation_id: string;
106
+ conversation: string;
107
+ title: string;
108
+ folder: string;
109
+ path: string;
110
+ size: number;
111
+ local_path: string;
112
+ }
113
+ export declare class FilesMirrorState {
114
+ readonly revisions: Map<string, string>;
115
+ retryAt: number;
116
+ failures: number;
117
+ listedAt: number;
118
+ readonly reported: Set<string>;
119
+ }
120
+ /** The repository a conversation belongs to: its normalized remote; worktrees of an origin-less repository share its Git common directory. */
121
+ export declare function repositoryIdentity(c: MirrorConversation, local?: LocalConversation): {
122
+ id: string;
123
+ name: string;
124
+ };
125
+ export declare class FilesMirror {
126
+ #private;
127
+ readonly root: string;
128
+ readonly ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>;
129
+ readonly connect: (projectId: string, all: MirrorConversation[]) => Promise<Hub>;
130
+ constructor(ctx: Pick<Ctx, 'io' | 'store' | 'apiUrl' | 'signal'>, connect: (projectId: string, all: MirrorConversation[]) => Promise<Hub>, root?: string);
131
+ get stateFile(): string;
132
+ get indexFile(): string;
133
+ /** One pass: only conversations whose API record moved touch the hub. */
134
+ sync(all: MirrorConversation[], state?: FilesMirrorState): Promise<void>;
135
+ /** Find a mirrored conversation by id, name or folder name. */
136
+ find(ref: string): {
137
+ rel: string;
138
+ manifest: Manifest;
139
+ };
140
+ /** Download one listed file, whatever its size. */
141
+ pull(ref: string, path: string, all: () => Promise<MirrorConversation[]>): Promise<{
142
+ conversation: string;
143
+ path: string;
144
+ local_path: string;
145
+ bytes: number;
146
+ already: boolean;
147
+ }>;
148
+ }
149
+ /**
150
+ * The seed snapshot (sh; $1 repository, $2 conversation name, $3 hub): HEAD plus the working tree the conversation was
151
+ * seeded with, committed in the conversation's VM with a temporary index (never on this computer), kept per worktree
152
+ * as refs/worktree/shard/seed and in the hub as refs/shard/seed/<name>. Mirrors leave out what it already contains,
153
+ * so the user's own uncommitted edits never show as the conversation's change. Threads on one computer are worktrees
154
+ * of one repository (0.16.0+), so the seed is per worktree; the shared refs/shard/seed of earlier seeds is read, and
155
+ * still written, only in the repository's main worktree.
156
+ */
157
+ export declare const SEED_SNAPSHOT = "set -eu\ncd \"$1\"\nmain=0; [ \"$(git rev-parse --absolute-git-dir)\" = \"$(git rev-parse --path-format=absolute --git-common-dir)\" ] && main=1\nif git rev-parse -q --verify refs/worktree/shard/seed >/dev/null; then seed=$(git rev-parse refs/worktree/shard/seed)\nelif [ \"$main\" = 1 ] && git rev-parse -q --verify refs/shard/seed >/dev/null; then seed=$(git rev-parse refs/shard/seed); else\nidx=$(mktemp); trap 'rm -f \"$idx\"' EXIT\ncp \"$(git rev-parse --path-format=absolute --git-path index)\" \"$idx\" 2>/dev/null || { rm -f \"$idx\"; GIT_INDEX_FILE=\"$idx\" git read-tree HEAD; }\nGIT_INDEX_FILE=\"$idx\" git add -A\ntree=$(GIT_INDEX_FILE=\"$idx\" git write-tree)\nif [ \"$tree\" = \"$(git rev-parse 'HEAD^{tree}')\" ]; then seed=$(git rev-parse HEAD); else\nseed=$(GIT_AUTHOR_NAME=Shardflux GIT_AUTHOR_EMAIL=seed@shardflux.dev GIT_COMMITTER_NAME=Shardflux GIT_COMMITTER_EMAIL=seed@shardflux.dev git commit-tree \"$tree\" -p HEAD -m \"Shardflux: the working tree this conversation started from\"); fi\ngit update-ref refs/worktree/shard/seed \"$seed\"\nif [ \"$main\" = 1 ]; then git update-ref refs/shard/seed \"$seed\"; fi\nfi\nif [ -d \"$3\" ]; then git -c core.hooksPath=/dev/null push -q \"$3\" \"+$seed:refs/shard/seed/$2\" >/dev/null 2>&1 || true; fi\nprintf '%s\n' \"$seed\"\n";
158
+ /** Best effort: without a snapshot the folder also lists the start state's own edits. */
159
+ export declare function recordSeedSnapshot(ws: Pick<Workspace, 'cell'>, c: Pick<Conversation, 'worktree_path' | 'branch'>, signal?: AbortSignal): Promise<string | null>;
160
+ /** A ready VM of the project (never woken), else the project's space reader, woken when needed. */
161
+ export declare function workspaceHub(ctx: Ctx): (projectId: string, all: MirrorConversation[]) => Promise<Hub>;
162
+ export declare function listMirrorConversations(ctx: Ctx): Promise<MirrorConversation[]>;
163
+ /** The device helper's pass: list every few seconds; touch the hub only for conversations whose record moved. */
164
+ export declare function mirrorFilesOnce(ctx: Ctx, state: FilesMirrorState, intervalMs?: number): Promise<void>;