@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -0,0 +1,724 @@
1
+ /**
2
+ * User-level agent config written in a cloud VM comes back to this computer (0.20.0+): a skill, a memory, a command, an
3
+ * agent, an output style or a CLAUDE.md / AGENTS.md line that Claude or Codex saved in a conversation's VM.
4
+ *
5
+ * - The guest records the agent's changes at each turn end in the project space (sf-agent, agentconfig.go):
6
+ * /shardflux/project/agent-config/<conversation>/state.json plus one blob per content hash.
7
+ * - The device helper reads that record when the conversation's turn ends and applies it here:
8
+ * a new file is created; a file unchanged here since the VM's version is replaced; a file changed on both sides keeps
9
+ * this computer's copy and gets the cloud's next to it (<name>.cloud.md); a deletion moves the file to the Trash.
10
+ * - Claude keys project memory by the project's absolute path: the VM's project folder maps to this computer's
11
+ * checkout of the same conversation, and back (agent-config-sync.ts copies local memory into the VM).
12
+ * - Every action is a line in agent-config-return.jsonl in the config directory. With a saved machine the change is
13
+ * also folded into the machine (written into the machine VM, then one machine save per batch), so new conversations
14
+ * start with it; without one, the next config copy into a new VM carries this computer's copy.
15
+ */
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+ import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+ import { basename, dirname, extname, join } from 'node:path';
21
+ import { ShardfluxApiError } from '@shardflux/sdk';
22
+ import { conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
23
+ import { MEMORY_PATH, localConfigPath, memoryRoot, returnablePath } from "./agent-config-files.js";
24
+ import { writePrivateFile } from "./credentials.js";
25
+ import { conversationClient } from "./conversation-commands.js";
26
+ import { configManifests } from "./agent-config-sync.js";
27
+ import { savedMachine } from "./filesystem-state.js";
28
+ import { openMachine } from "./filesystem-commands.js";
29
+ import { thisComputer } from "./this-computer.js";
30
+ export const RETURN_ROOT = '/shardflux/project/agent-config';
31
+ /** The latest agent-config line ({notice, at}), for the Linux app's top-bar panel (CLI 0.20.2+). */
32
+ export const AGENT_CONFIG_NOTICE = 'agent-config-notice.json';
33
+ const HASH = /^[0-9a-f]{64}$/;
34
+ const MAX_BYTES = 16 << 20;
35
+ const READ_CHUNK = 8 << 20;
36
+ /** A machine fold waits for this much quiet after the last change, and saves the machine at most this often. */
37
+ export const FOLD_QUIET_MS = 60_000;
38
+ export const FOLD_INTERVAL_MS = 10 * 60_000;
39
+ /** A machine VM someone is using is left alone this long before a pending fold goes ahead anyway. */
40
+ export const FOLD_DEFER_MS = 30 * 60_000;
41
+ // ---------------------------------------------------------------------------------------------------------------
42
+ // Paths
43
+ /** SKILL.md → SKILL.cloud.md, next to the user's own copy. */
44
+ export function conflictCopyPath(file) {
45
+ const ext = extname(basename(file));
46
+ return join(dirname(file), `${basename(file, ext)}.cloud${ext}`);
47
+ }
48
+ /**
49
+ * `local` is this computer's content hash (undefined: no file), `cloud` the VM's ('' when the agent deleted it), and
50
+ * `known` every version the VM side held or was sent: when the local file is one of them, it has not changed here
51
+ * since, and the cloud's version may replace it.
52
+ */
53
+ export function decideReturn(local, cloud, known) {
54
+ if (!cloud)
55
+ return local === undefined ? 'unchanged' : known.has(local) ? 'trash' : 'keep';
56
+ if (local === cloud)
57
+ return 'unchanged';
58
+ if (local === undefined)
59
+ return 'create';
60
+ return known.has(local) ? 'overwrite' : 'conflict';
61
+ }
62
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
63
+ export function validRecord(value, id) {
64
+ const r = value;
65
+ if (!r || typeof r !== 'object' || r.v !== 1 || r.conversation_id !== id || !Number.isSafeInteger(r.seq) || r.seq < 1)
66
+ return false;
67
+ if (typeof r.worktree !== 'string' || typeof r.project_root !== 'string' || !r.files || typeof r.files !== 'object' || Array.isArray(r.files))
68
+ return false;
69
+ return Object.values(r.files).every(f => f && typeof f === 'object' && (f.sha256 === '' || HASH.test(f.sha256)) && Array.isArray(f.history) && f.history.every(h => HASH.test(h))
70
+ && Number.isSafeInteger(f.size) && f.size >= 0 && f.size <= MAX_BYTES);
71
+ }
72
+ // ---------------------------------------------------------------------------------------------------------------
73
+ // Local files: atomic writes, the Trash, the journal and what was applied
74
+ /** Moves a file to the user's Trash (macOS ~/.Trash, or the freedesktop Trash on Linux); never a hard delete. */
75
+ export function moveToTrash(file, env) {
76
+ const home = env.HOME || homedir();
77
+ const linux = process.platform !== 'darwin';
78
+ const dir = linux ? join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'Trash') : join(home, '.Trash');
79
+ const files = linux ? join(dir, 'files') : dir;
80
+ mkdirSync(files, { recursive: true, mode: 0o700 });
81
+ const ext = extname(basename(file)), stem = basename(file, ext);
82
+ let name = basename(file);
83
+ for (let n = 2; existsSync(join(files, name)) || (linux && existsSync(join(dir, 'info', `${name}.trashinfo`))); n++)
84
+ name = `${stem} ${n}${ext}`;
85
+ if (linux) {
86
+ mkdirSync(join(dir, 'info'), { recursive: true, mode: 0o700 });
87
+ writeFileSync(join(dir, 'info', `${name}.trashinfo`), `[Trash Info]\nPath=${encodeURI(file)}\nDeletionDate=${new Date().toISOString().slice(0, 19)}\n`, { mode: 0o600 });
88
+ }
89
+ const dest = join(files, name);
90
+ try {
91
+ renameSync(file, dest);
92
+ }
93
+ catch (error) {
94
+ if (error.code !== 'EXDEV')
95
+ throw error;
96
+ copyFileSync(file, dest);
97
+ unlinkSync(file);
98
+ }
99
+ return dest;
100
+ }
101
+ /** Writes through a symlinked file (a CLAUDE.md linked from a dotfiles repository keeps its link). */
102
+ function writeAtomic(file, bytes, mode) {
103
+ let target = file;
104
+ try {
105
+ if (lstatSync(file).isSymbolicLink())
106
+ target = realpathSync(file);
107
+ }
108
+ catch { /* absent */ }
109
+ mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
110
+ const tmp = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(6).toString('hex')}.sf-tmp`);
111
+ const fd = openSync(tmp, 'wx', mode);
112
+ try {
113
+ for (let at = 0; at < bytes.length;)
114
+ at += writeSync(fd, bytes, at, bytes.length - at);
115
+ fsyncSync(fd);
116
+ }
117
+ catch (error) {
118
+ closeSync(fd);
119
+ rmSync(tmp, { force: true });
120
+ throw error;
121
+ }
122
+ closeSync(fd);
123
+ chmodSync(tmp, mode);
124
+ try {
125
+ renameSync(tmp, target);
126
+ }
127
+ catch (error) {
128
+ rmSync(tmp, { force: true });
129
+ throw error;
130
+ }
131
+ }
132
+ function localHash(file) {
133
+ try {
134
+ const info = statSync(file);
135
+ if (!info.isFile())
136
+ return 'not-a-file';
137
+ return sha(readFileSync(file));
138
+ }
139
+ catch (error) {
140
+ if (error.code === 'ENOENT')
141
+ return undefined;
142
+ throw error;
143
+ }
144
+ }
145
+ const stateFile = (dir) => join(dir, 'agent-config-return.json');
146
+ export const journalFile = (dir) => join(dir, 'agent-config-return.jsonl');
147
+ const blobDir = (dir) => join(dir, 'agent-config-return', 'blobs');
148
+ export function readReturnState(dir) {
149
+ try {
150
+ const value = JSON.parse(readFileSync(stateFile(dir), 'utf8'));
151
+ if (value?.v === 1 && value.conversations)
152
+ return value;
153
+ }
154
+ catch { /* first run, or unreadable: start over; applying again is idempotent */ }
155
+ return { v: 1, conversations: {} };
156
+ }
157
+ function saveReturnState(dir, state) { writePrivateFile(dir, stateFile(dir), JSON.stringify(state) + '\n'); }
158
+ function journal(dir, entry) {
159
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
160
+ appendFileSync(journalFile(dir), JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n', { mode: 0o600 });
161
+ }
162
+ const missing = (e) => e instanceof ShardfluxApiError && e.status === 404;
163
+ async function readBlob(cell, path, file, signal) {
164
+ const parts = [];
165
+ for (let offset = 0; offset < file.size;) {
166
+ signal?.throwIfAborted();
167
+ const chunk = await cell.files.read(path, { offset, length: Math.min(READ_CHUNK, file.size - offset) });
168
+ if (!chunk.length)
169
+ break;
170
+ parts.push(chunk);
171
+ offset += chunk.length;
172
+ }
173
+ const bytes = Buffer.concat(parts);
174
+ if (sha(bytes) !== file.sha256)
175
+ throw new Error(`${path} does not match its recorded hash`);
176
+ return bytes;
177
+ }
178
+ /** Applies a conversation's record; returns what happened to each path that changed since the last application. */
179
+ export async function applyAgentConfigReturn(cell, conversationId, options) {
180
+ let raw;
181
+ try {
182
+ raw = await cell.files.readText(`${RETURN_ROOT}/${conversationId}/state.json`);
183
+ }
184
+ catch (error) {
185
+ if (missing(error))
186
+ return { app: '', items: [] };
187
+ throw error;
188
+ }
189
+ const record = JSON.parse(raw);
190
+ if (!validRecord(record, conversationId))
191
+ throw new Error('agent config record is not valid');
192
+ const state = readReturnState(options.storeDir);
193
+ const applied = state.conversations[conversationId] ?? { seq: 0, files: {} };
194
+ if (applied.seq >= record.seq)
195
+ return { app: record.app, items: [], record };
196
+ const localRoot = options.folder ? memoryRoot(options.folder) : undefined;
197
+ const items = [];
198
+ let complete = false;
199
+ // What was applied stays applied and journalled when a later file fails; the next pass retries the rest.
200
+ try {
201
+ for (const vmPath of Object.keys(record.files).sort()) {
202
+ const file = record.files[vmPath];
203
+ if (applied.files[vmPath] === file.sha256)
204
+ continue;
205
+ const rel = localConfigPath(vmPath, record, localRoot);
206
+ if (!rel) {
207
+ items.push({ vm_path: vmPath, action: 'skipped', sha256: file.sha256, reason: returnablePath(vmPath) ? 'memory of a VM folder with no checkout on this computer' : 'not a path that travels' });
208
+ applied.files[vmPath] = file.sha256;
209
+ continue;
210
+ }
211
+ const path = join(options.home, rel);
212
+ const local = localHash(path);
213
+ if (local === 'not-a-file') {
214
+ items.push({ vm_path: vmPath, path, action: 'skipped', sha256: file.sha256, reason: 'not a regular file on this computer' });
215
+ applied.files[vmPath] = file.sha256;
216
+ continue;
217
+ }
218
+ // Only versions this file's own chain held: what that VM had (its baseline and earlier turns) and what the config
219
+ // copy sent it. A version another conversation wrote here is not one this VM's file was made from.
220
+ const known = new Set([...file.history, ...(options.sent?.[vmPath] ? [options.sent[vmPath]] : [])]);
221
+ const decision = decideReturn(local, file.sha256, known);
222
+ const blobPath = `${RETURN_ROOT}/${conversationId}/blobs/${file.sha256}`;
223
+ const mode = () => {
224
+ try {
225
+ return statSync(path).mode & 0o777;
226
+ }
227
+ catch {
228
+ return file.mode === '0700' ? 0o755 : 0o644;
229
+ }
230
+ };
231
+ const item = { vm_path: vmPath, path, action: 'unchanged', sha256: file.sha256, ...(local ? { local_sha256: local } : {}) };
232
+ if (decision === 'create' || decision === 'overwrite') {
233
+ const bytes = await readBlob(cell, blobPath, file, options.signal);
234
+ if (decision === 'overwrite')
235
+ keepBackup(options.storeDir, path);
236
+ writeAtomic(path, bytes, mode());
237
+ item.action = decision === 'create' ? 'created' : 'updated';
238
+ storeBlob(options.storeDir, file.sha256, bytes);
239
+ }
240
+ else if (decision === 'conflict') {
241
+ const bytes = await readBlob(cell, blobPath, file, options.signal);
242
+ const copy = conflictCopyPath(path);
243
+ writeAtomic(copy, bytes, mode());
244
+ item.action = 'conflict';
245
+ item.copy = copy;
246
+ storeBlob(options.storeDir, file.sha256, bytes);
247
+ }
248
+ else if (decision === 'trash') {
249
+ item.trash = moveToTrash(path, options.env);
250
+ item.action = 'trashed';
251
+ }
252
+ else if (decision === 'keep') {
253
+ item.action = 'kept';
254
+ item.reason = 'changed on this computer; the cloud deleted it';
255
+ }
256
+ items.push(item);
257
+ applied.files[vmPath] = file.sha256;
258
+ }
259
+ complete = true;
260
+ }
261
+ finally {
262
+ if (complete)
263
+ applied.seq = record.seq;
264
+ state.conversations[conversationId] = { ...state.conversations[conversationId], ...applied };
265
+ saveReturnState(options.storeDir, state);
266
+ for (const item of items)
267
+ journal(options.storeDir, { conversation_id: conversationId, app: record.app, ...item });
268
+ }
269
+ return { app: record.app, items, record };
270
+ }
271
+ function keepBackup(storeDir, path) {
272
+ const dir = join(storeDir, 'agent-config-return', 'backups', new Date().toISOString().replace(/[:.]/g, '-'));
273
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
274
+ copyFileSync(path, join(dir, `${basename(path)}.${randomBytes(3).toString('hex')}`));
275
+ // Backups older than 14 days go.
276
+ const root = dirname(dir), cutoff = Date.now() - 14 * 86_400_000;
277
+ for (const name of readdirSync(root))
278
+ if (statSync(join(root, name)).mtimeMs < cutoff)
279
+ rmSync(join(root, name), { recursive: true, force: true });
280
+ }
281
+ /** The cloud's bytes, kept for a machine fold that runs later. */
282
+ function storeBlob(storeDir, hash, bytes) {
283
+ const dir = blobDir(storeDir);
284
+ if (existsSync(join(dir, hash)))
285
+ return;
286
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
287
+ writeAtomic(join(dir, hash), bytes, 0o600);
288
+ }
289
+ function readStoredBlob(storeDir, hash) {
290
+ try {
291
+ const bytes = readFileSync(join(blobDir(storeDir), hash));
292
+ return sha(bytes) === hash ? bytes : undefined;
293
+ }
294
+ catch {
295
+ return undefined;
296
+ }
297
+ }
298
+ // ---------------------------------------------------------------------------------------------------------------
299
+ // The one line the user sees
300
+ function itemName(vmPath) {
301
+ let m;
302
+ if ((m = /^\.(?:claude|codex)\/skills\/([^/]+)\//.exec(vmPath)))
303
+ return { kind: 'skill', name: `the skill ${m[1]}` };
304
+ if ((m = /^\.claude\/commands\/(.+)\.md$/.exec(vmPath)))
305
+ return { kind: 'command', name: `the command /${m[1].replace(/\//g, ':')}` };
306
+ if ((m = /^\.codex\/prompts\/(.+)\.md$/.exec(vmPath)))
307
+ return { kind: 'prompt', name: `the prompt /${m[1]}` };
308
+ if ((m = /^\.claude\/agents\/(.+)\.md$/.exec(vmPath)))
309
+ return { kind: 'agent', name: `the agent ${m[1]}` };
310
+ if ((m = /^\.claude\/output-styles\/(.+)\.md$/.exec(vmPath)))
311
+ return { kind: 'style', name: `the output style ${m[1]}` };
312
+ if (MEMORY_PATH.test(vmPath))
313
+ return { kind: 'memory', name: 'a memory' };
314
+ if (vmPath === '.claude/CLAUDE.md')
315
+ return { kind: 'rule', name: 'your CLAUDE.md' };
316
+ if (vmPath === '.codex/AGENTS.md')
317
+ return { kind: 'rule', name: 'your AGENTS.md' };
318
+ return { kind: 'file', name: basename(vmPath) };
319
+ }
320
+ function list(names) {
321
+ const memories = names.filter(n => n === 'a memory').length;
322
+ const shown = [...new Set(names.filter(n => n !== 'a memory'))];
323
+ if (memories)
324
+ shown.push(memories === 1 ? 'a memory' : `${memories} memories`);
325
+ if (shown.length > 3)
326
+ return `${shown.slice(0, 2).join(', ')} and ${shown.length - 2} more`;
327
+ return shown.length > 1 ? `${shown.slice(0, -1).join(', ')} and ${shown[shown.length - 1]}` : shown[0] ?? '';
328
+ }
329
+ /**
330
+ * One line for a batch: "Claude saved the skill deploy-checks in the cloud: it's on this Mac now". Undefined when
331
+ * nothing reached this computer (an unchanged or skipped path says nothing).
332
+ */
333
+ export function returnNotice(app, items, platform = process.platform) {
334
+ const who = app === 'codex' ? 'Codex' : 'Claude';
335
+ const here = thisComputer(platform);
336
+ // A memory's MEMORY.md index line is part of saving that memory, not a memory of its own.
337
+ const memoryNotes = items.some(i => MEMORY_PATH.test(i.vm_path) && !i.vm_path.endsWith('/memory/MEMORY.md') && ['created', 'updated', 'trashed'].includes(i.action));
338
+ const saved = items.filter(i => (i.action === 'created' || i.action === 'updated') && !(memoryNotes && i.vm_path.endsWith('/memory/MEMORY.md'))).map(i => itemName(i.vm_path).name);
339
+ const removed = items.filter(i => i.action === 'trashed').map(i => itemName(i.vm_path).name);
340
+ const both = items.filter(i => i.action === 'conflict');
341
+ const parts = [];
342
+ if (saved.length) {
343
+ const many = new Set(saved).size > 1 || saved.filter(n => n === 'a memory').length > 1 || /^\d/.test(list(saved));
344
+ parts.push(`${who} saved ${list(saved)} in the cloud: ${many ? 'they\'re' : 'it\'s'} on ${here} now`);
345
+ }
346
+ if (removed.length)
347
+ parts.push(`${parts.length ? 'removed' : `${who} removed`} ${list(removed)} in the cloud (moved to the Trash)`);
348
+ if (both.length) {
349
+ const first = both[0];
350
+ parts.push(`${parts.length ? 'changed' : `${who} changed`} ${list(both.map(i => itemName(i.vm_path).name))} in the cloud while you changed ${both.length === 1 ? 'it' : 'them'} here: both kept${both.length === 1 && first.copy ? ` (${basename(first.copy)})` : ''}`);
351
+ }
352
+ return parts.length ? parts.join('; ') : undefined;
353
+ }
354
+ // ---------------------------------------------------------------------------------------------------------------
355
+ // The device helper's pass
356
+ export class AgentConfigReturnState {
357
+ retries = new Map();
358
+ machine = { checkedAt: 0, saved: false };
359
+ /** The user signed in on this computer: the session's, or the creator of its API key (refreshed every 10 minutes). */
360
+ identity = { checkedAt: 0 };
361
+ folding;
362
+ }
363
+ const revisionOf = (c) => JSON.stringify([c.workspace_id, c.state, c.last_activity_at ?? null, c.ended_at, c.durable?.branch_sha ?? null]);
364
+ /** A VM that can read the project space without waking anything: the conversation's own, then any awake one. */
365
+ async function projectCell(ctx, c, all) {
366
+ for (const candidate of [c, ...all.filter(o => o.project_id === c.project_id && o.id !== c.id && !o.ended_at)]) {
367
+ try {
368
+ const ws = await conversationClient(ctx).workspaces.get(candidate.workspace_id);
369
+ if (!ws.data.deleted_at && ws.ready)
370
+ return ws;
371
+ }
372
+ catch { /* another may be awake */ }
373
+ }
374
+ // An ended conversation's last changes: the project's space reader (the transcript mirror wakes it for the same turn).
375
+ if (!c.ended_at)
376
+ return undefined;
377
+ const client = conversationClient(ctx);
378
+ const reader = await client.conversations.spaceReader(c.project_id, { signal: ctx.signal });
379
+ const ws = await client.workspaces.get(reader.workspace.id);
380
+ if (!ws.ready)
381
+ await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
382
+ return ws;
383
+ }
384
+ /** How often the device helper looks for agent config to bring back: its own loop, never behind a files or transcript pass. */
385
+ export const AGENT_CONFIG_POLL_MS = 2000;
386
+ /** With none of this user's conversations active, the full listing backs off (doubling) to this. */
387
+ export const AGENT_CONFIG_IDLE_MS = 60_000;
388
+ /** While backed off, one cheap request (`state=working`, usually empty) this often notices a turn starting anywhere. */
389
+ export const AGENT_CONFIG_PROBE_MS = 10_000;
390
+ /** A conversation counts as active for this long after its last activity (a turn's start or end). */
391
+ export const AGENT_CONFIG_ACTIVE_WINDOW_MS = 5 * 60_000;
392
+ /**
393
+ * A working conversation counts as active only this long after its last activity: one left "working" by a VM that went
394
+ * away (staging QA FE1: 60 such, all more than a day old) must not hold every computer at the fast cadence. A longer
395
+ * turn still comes back, at the idle cadence.
396
+ */
397
+ export const AGENT_CONFIG_WORKING_WINDOW_MS = 60 * 60_000;
398
+ /** This user's conversations that are working (recently) or ended a turn within the active window. */
399
+ export function activeConversations(all, records, me, now = Date.now()) {
400
+ return all.filter(c => {
401
+ if (c.route === 'codex-cloud' || c.ended_at || !ownConversation(c, records, me))
402
+ return false;
403
+ const idle = now - Date.parse(c.last_activity_at ?? c.created_at);
404
+ return idle < (c.state === 'working' ? AGENT_CONFIG_WORKING_WINDOW_MS : AGENT_CONFIG_ACTIVE_WINDOW_MS);
405
+ });
406
+ }
407
+ export const CADENCE = { activeMs: AGENT_CONFIG_POLL_MS, idleMs: AGENT_CONFIG_IDLE_MS, probeMs: AGENT_CONFIG_PROBE_MS };
408
+ /**
409
+ * When the next listing is due. Active (one of this user's conversations works or just ended a turn): every 2 s.
410
+ * Idle: the interval doubles to 60 s, with a cheap working-only probe every 10 s and a check of this computer's own
411
+ * conversation records each second; either snaps it back to 2 s at once.
412
+ */
413
+ export class AgentConfigCadence {
414
+ options;
415
+ interval;
416
+ fullAt = 0;
417
+ probeAt = 0;
418
+ recordsMtime = 0;
419
+ constructor(options = CADENCE) { this.options = options; this.interval = options.activeMs; }
420
+ /** After a full pass: active keeps 2 s, idle doubles toward the idle ceiling. */
421
+ listed(active, now = Date.now()) {
422
+ this.fullAt = now;
423
+ this.probeAt = now;
424
+ this.interval = active ? this.options.activeMs : Math.min(this.options.idleMs, Math.max(this.options.activeMs, this.interval * 2));
425
+ }
426
+ snap() { this.interval = this.options.activeMs; this.fullAt = 0; }
427
+ fullDue(now = Date.now()) { return now - this.fullAt >= this.interval; }
428
+ probeDue(now = Date.now()) { return this.interval > this.options.probeMs && now - this.probeAt >= this.options.probeMs; }
429
+ }
430
+ /** One full pass: list the account's conversations, apply what came back; true while one of this user's is active. */
431
+ export async function returnAgentConfigsOnce(ctx, state) {
432
+ const records = localConversations(ctx), all = await listConversations(ctx);
433
+ await returnAgentConfigs(ctx, records, all, state);
434
+ const me = all.some(c => c.started_by?.user_id) ? await myUserId(ctx, state) : undefined;
435
+ return activeConversations(all, records, me).length > 0;
436
+ }
437
+ /** The cheap probe: is any of this user's conversations working right now (one request, usually an empty page)? */
438
+ export async function workingProbe(ctx, state) {
439
+ const page = await conversationClient(ctx).conversations.list({ state: 'working', limit: 200 });
440
+ const working = page.data;
441
+ const me = working.some(c => c.started_by?.user_id) ? await myUserId(ctx, state) : undefined;
442
+ return activeConversations(working, localConversations(ctx), me).length > 0;
443
+ }
444
+ /** The device helper's agent-config loop: activity-driven cadence, its own backoff on errors, never another loop's. */
445
+ export async function runAgentConfigLoop(ctx, state, signal, cadence = new AgentConfigCadence(), tickMs = 1000) {
446
+ let failures = 0, retryAt = 0;
447
+ const reported = new Set();
448
+ const agents = join(ctx.store.dir, 'agents.json');
449
+ while (!signal.aborted) {
450
+ const now = Date.now();
451
+ // A conversation started on this computer is recorded in agents.json: back to 2 s before its first turn ends.
452
+ try {
453
+ const m = statSync(agents).mtimeMs;
454
+ if (cadence.recordsMtime && m !== cadence.recordsMtime)
455
+ cadence.snap();
456
+ cadence.recordsMtime = m;
457
+ }
458
+ catch { /* none yet */ }
459
+ if (now >= retryAt) {
460
+ try {
461
+ if (cadence.fullDue(now))
462
+ cadence.listed(await returnAgentConfigsOnce(ctx, state));
463
+ else if (cadence.probeDue(now)) {
464
+ cadence.probeAt = now;
465
+ if (await workingProbe(ctx, state))
466
+ cadence.snap();
467
+ }
468
+ failures = 0;
469
+ }
470
+ catch (e) {
471
+ if (signal.aborted)
472
+ break;
473
+ const wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
474
+ retryAt = Date.now() + wait;
475
+ const message = e instanceof Error ? e.message : String(e);
476
+ if (!conversationsUnavailable(e) && !reported.has(message)) {
477
+ reported.add(message);
478
+ ctx.io.stderr.write(`Agent config: ${message} (retrying in ${wait / 1000}s).\n`);
479
+ }
480
+ }
481
+ }
482
+ await delay(tickMs, undefined, { signal }).catch(() => undefined);
483
+ }
484
+ }
485
+ /**
486
+ * This computer's checkout of a conversation's repository: the folder it was started from here, else the folder of
487
+ * another conversation of the same repository started here. A Shardflux project can hold many repositories (staging
488
+ * QA FE1: 31 in one project), so the project never decides. Undefined when there is none (a conversation started on
489
+ * the phone, the web, T3 or another computer for a repository this computer never opened): its user-level files still
490
+ * apply, its project memory has nowhere to go.
491
+ */
492
+ export function localCheckout(c, records) {
493
+ if (records[c.id])
494
+ return records[c.id].folder;
495
+ if (!c.repo_remote)
496
+ return undefined;
497
+ return Object.values(records).filter(r => r.conversation.repo_remote === c.repo_remote)
498
+ .sort((a, b) => (b.conversation.created_at ?? '').localeCompare(a.conversation.created_at ?? ''))[0]?.folder;
499
+ }
500
+ /** This computer's user, or undefined when the API cannot say (an older API, a key without a known creator). */
501
+ async function myUserId(ctx, state) {
502
+ if (Date.now() - state.identity.checkedAt < 10 * 60_000)
503
+ return state.identity.userId;
504
+ let userId;
505
+ try {
506
+ const me = await conversationClient(ctx).me();
507
+ userId = me.user?.id ?? me.api_key?.created_by_user_id ?? undefined;
508
+ }
509
+ catch { /* unknown: only conversations recorded here apply */ }
510
+ state.identity = { checkedAt: Date.now(), ...(userId ? { userId } : {}) };
511
+ return userId;
512
+ }
513
+ /**
514
+ * User-level config is personal. A conversation that names who started it applies only when that is this computer's
515
+ * user, wherever it started; a teammate's never does. One that does not (an older API, a conversation from before it
516
+ * was recorded) applies only when this computer started it.
517
+ */
518
+ export function ownConversation(c, records, me) {
519
+ const by = c.started_by?.user_id;
520
+ return by ? !!me && by === me : !!records[c.id];
521
+ }
522
+ /**
523
+ * Called by the transcript mirror's pass with the conversations it listed: every conversation of this computer's user
524
+ * (ownConversation), wherever it started (this computer, the phone, the web, T3, another computer). Each one's record is
525
+ * read once per turn (its revision; a restart reads nothing again) and applied. Conversations idle since before this
526
+ * computer started applying are never read. One notice line per pass, on the device helper's stdout for the menu bar.
527
+ */
528
+ export async function returnAgentConfigs(ctx, records, all, state) {
529
+ let saved = readReturnState(ctx.store.dir);
530
+ // A turn that ended in the 10 minutes before this computer started applying (an update, a restart) still counts.
531
+ if (!saved.enabled_at) {
532
+ saved.enabled_at = Date.now() - 10 * 60_000;
533
+ saveReturnState(ctx.store.dir, saved);
534
+ saved = readReturnState(ctx.store.dir);
535
+ }
536
+ const items = [];
537
+ let app = '';
538
+ const manifests = (() => { try {
539
+ return configManifests(ctx);
540
+ }
541
+ catch {
542
+ return {};
543
+ } })();
544
+ const me = all.some(c => c.started_by?.user_id) ? await myUserId(ctx, state) : undefined;
545
+ // The turn that just ended first: one slow conversation (a reader to wake) never holds back a fresh one.
546
+ for (const c of [...all].sort((a, b) => (b.last_activity_at ?? '').localeCompare(a.last_activity_at ?? ''))) {
547
+ if (!ownConversation(c, records, me))
548
+ continue;
549
+ // A handed-off conversation ended before this pass; codex-cloud threads have no VM home.
550
+ if (records[c.id]?.handed_off || c.route === 'codex-cloud')
551
+ continue;
552
+ const revision = revisionOf(c);
553
+ if (saved.conversations[c.id]?.revision === revision)
554
+ continue;
555
+ if (!saved.conversations[c.id] && Date.parse(c.last_activity_at ?? c.created_at) < saved.enabled_at)
556
+ continue;
557
+ let retry = state.retries.get(c.id);
558
+ if (!retry || retry.revision !== revision) {
559
+ retry = { revision, failures: 0, retryAt: 0 };
560
+ state.retries.set(c.id, retry);
561
+ }
562
+ if (Date.now() < retry.retryAt)
563
+ continue;
564
+ try {
565
+ const ws = await projectCell(ctx, c, all);
566
+ if (!ws)
567
+ continue;
568
+ const cell = ws.cell({ wake: null });
569
+ try {
570
+ const sent = Object.values(manifests).find(m => m.workspace_id === c.workspace_id && m.api_url === ctx.apiUrl);
571
+ const result = await applyAgentConfigReturn(cell, c.id, { home: ctx.io.env.HOME || homedir(), storeDir: ctx.store.dir, env: ctx.io.env, signal: ctx.signal,
572
+ folder: localCheckout(c, records), sent: Object.fromEntries(Object.entries(sent?.files ?? {}).map(([p, f]) => [p, f.hash])) });
573
+ if (result.items.length) {
574
+ app ||= result.app;
575
+ items.push(...result.items);
576
+ }
577
+ if (result.items.some(i => ['created', 'updated', 'trashed', 'conflict'].includes(i.action)))
578
+ await queueMachineFold(ctx, state, result);
579
+ }
580
+ finally {
581
+ cell.close();
582
+ }
583
+ const next = readReturnState(ctx.store.dir);
584
+ next.conversations[c.id] = { ...(next.conversations[c.id] ?? { seq: 0, files: {} }), revision };
585
+ saveReturnState(ctx.store.dir, next);
586
+ state.retries.delete(c.id);
587
+ }
588
+ catch (error) {
589
+ if (ctx.signal.aborted)
590
+ break;
591
+ const backoff = Math.min(300_000, 5000 * 2 ** Math.min(retry.failures++, 6));
592
+ retry.retryAt = Date.now() + backoff;
593
+ ctx.io.stderr.write(`Agent config from ${c.id}: ${error instanceof Error ? error.message : String(error)} (retrying in ${backoff / 1000}s).\n`);
594
+ }
595
+ }
596
+ const line = returnNotice(app, items);
597
+ if (line) {
598
+ ctx.io.stdout.write(JSON.stringify({ type: 'agent_config', notice: line }) + '\n');
599
+ ctx.io.stderr.write(`${new Date().toISOString()} ${line}\n`);
600
+ // The Linux app reads the line here (its helper runs under systemd, whose standard output no app sees).
601
+ try {
602
+ writePrivateFile(ctx.store.dir, join(ctx.store.dir, AGENT_CONFIG_NOTICE), JSON.stringify({ notice: line, at: new Date().toISOString() }) + '\n');
603
+ }
604
+ catch (error) {
605
+ ctx.io.stderr.write(`Agent config notice: ${error instanceof Error ? error.message : String(error)}\n`);
606
+ }
607
+ }
608
+ // SHARDFLUX_AGENT_CONFIG_FOLD=off holds machine folds (an operator testing that machine); the queue keeps them.
609
+ if (!state.folding && ctx.io.env.SHARDFLUX_AGENT_CONFIG_FOLD !== 'off')
610
+ state.folding = foldIntoMachine(ctx, state).then(() => undefined, (error) => {
611
+ ctx.io.stderr.write(`Agent config into the machine: ${error instanceof Error ? error.message : String(error)}\n`);
612
+ }).finally(() => { state.folding = undefined; });
613
+ return line;
614
+ }
615
+ // ---------------------------------------------------------------------------------------------------------------
616
+ // Folding into the saved machine
617
+ /**
618
+ * The saved machine belongs to the organization: every member's new conversations start from it. A user's personal
619
+ * config goes into it only while that user is its only member (`members` 1); a shared machine, or an API that does not
620
+ * say, gets nothing.
621
+ */
622
+ const personalMachine = (m) => !!m?.template && m.members === 1;
623
+ async function hasSavedMachine(ctx, state) {
624
+ if (Date.now() - state.machine.checkedAt > 10 * 60_000) {
625
+ state.machine = { checkedAt: Date.now(), saved: personalMachine(await savedMachine(ctx)) };
626
+ }
627
+ return state.machine.saved;
628
+ }
629
+ /** Memory is per project and never part of the machine; everything else the machine delivers live. */
630
+ export async function queueMachineFold(ctx, state, result) {
631
+ if (!result.record || !(await hasSavedMachine(ctx, state)))
632
+ return;
633
+ const saved = readReturnState(ctx.store.dir);
634
+ const queue = saved.machine_queue ?? {};
635
+ for (const item of result.items) {
636
+ if (MEMORY_PATH.test(item.vm_path) || !['created', 'updated', 'trashed', 'conflict'].includes(item.action))
637
+ continue;
638
+ const file = result.record.files[item.vm_path];
639
+ queue[item.vm_path] = { vm_path: item.vm_path, sha256: file.sha256, known: [...new Set([...file.history, ...(queue[item.vm_path] ? [queue[item.vm_path].sha256, ...queue[item.vm_path].known] : [])])], at: Date.now() };
640
+ }
641
+ saved.machine_queue = queue;
642
+ saveReturnState(ctx.store.dir, saved);
643
+ }
644
+ /**
645
+ * Writes the queued changes into the machine VM under the same rules (a machine copy changed since is kept) and saves
646
+ * the machine once for the batch, after a quiet period and at most every FOLD_INTERVAL_MS. A machine VM that is running
647
+ * (someone may be changing it) is left alone for up to FOLD_DEFER_MS.
648
+ */
649
+ export async function foldIntoMachine(ctx, state, now = Date.now()) {
650
+ const saved = readReturnState(ctx.store.dir);
651
+ const queue = Object.values(saved.machine_queue ?? {});
652
+ if (!queue.length)
653
+ return 'nothing';
654
+ if (now - Math.max(...queue.map(e => e.at)) < FOLD_QUIET_MS || now - (saved.machine_folded_at ?? 0) < FOLD_INTERVAL_MS)
655
+ return 'waiting';
656
+ const cloud = ctx.cloud();
657
+ const machine = await cloud.machine.get({ signal: ctx.signal });
658
+ if (machine.template && !personalMachine(machine)) {
659
+ // A member joined since these were queued: the machine is shared now, and one user's config stays theirs.
660
+ const after = readReturnState(ctx.store.dir);
661
+ for (const entry of queue)
662
+ journal(ctx.store.dir, { machine: machine.organization_id, vm_path: entry.vm_path, action: 'machine_skipped', sha256: entry.sha256,
663
+ reason: `the machine is shared by ${machine.members ?? 'several'} members` });
664
+ delete after.machine_queue;
665
+ saveReturnState(ctx.store.dir, after);
666
+ return 'shared';
667
+ }
668
+ if (!machine.template || machine.state === 'building')
669
+ return 'waiting';
670
+ const existing = await cloud.workspaces.findByKey(`machine/${machine.organization_id}/v${machine.version}`, { includeDeleted: false, signal: ctx.signal });
671
+ if (existing?.data.observed_state === 'running' && now - (saved.machine_deferred_since ?? now) < FOLD_DEFER_MS) {
672
+ if (!saved.machine_deferred_since) {
673
+ saved.machine_deferred_since = now;
674
+ saveReturnState(ctx.store.dir, saved);
675
+ }
676
+ return 'deferred';
677
+ }
678
+ const { ws } = await openMachine({ ...ctx, io: { ...ctx.io, stderr: { write: () => true } } }, machine, { sync: false });
679
+ const cell = ws.cell({ agentLabel: ctx.agentLabel, transitionTimeoutMs: ctx.wakeTimeoutMs });
680
+ let changed = 0;
681
+ try {
682
+ for (const entry of queue) {
683
+ const path = `/home/user/${entry.vm_path}`;
684
+ let current;
685
+ try {
686
+ const info = await cell.files.stat(path, { revision: true });
687
+ current = info.type === 'file' ? info.revision ?? undefined : 'not-a-file';
688
+ }
689
+ catch (error) {
690
+ if (!missing(error))
691
+ throw error;
692
+ }
693
+ const decision = decideReturn(current, entry.sha256, new Set(entry.known));
694
+ if (decision === 'create' || decision === 'overwrite') {
695
+ const bytes = readStoredBlob(ctx.store.dir, entry.sha256);
696
+ if (!bytes)
697
+ continue;
698
+ await cell.files.write(path, bytes, { createParents: true, mode: '0600' });
699
+ changed++;
700
+ }
701
+ else if (decision === 'trash') {
702
+ await cell.files.remove(path);
703
+ changed++;
704
+ }
705
+ journal(ctx.store.dir, { machine: machine.organization_id, vm_path: entry.vm_path, action: decision === 'create' ? 'machine_created' : decision === 'overwrite' ? 'machine_updated' : decision === 'trash' ? 'machine_removed' : decision === 'unchanged' ? 'machine_unchanged' : 'machine_kept', sha256: entry.sha256 });
706
+ }
707
+ }
708
+ finally {
709
+ cell.close();
710
+ }
711
+ let version;
712
+ if (changed)
713
+ version = (await cloud.machine.save({ workspaceId: ws.id }, { signal: ctx.signal })).pending?.version;
714
+ const after = readReturnState(ctx.store.dir);
715
+ for (const entry of queue)
716
+ if (after.machine_queue?.[entry.vm_path]?.at === entry.at)
717
+ delete after.machine_queue[entry.vm_path];
718
+ after.machine_folded_at = now;
719
+ delete after.machine_deferred_since;
720
+ saveReturnState(ctx.store.dir, after);
721
+ if (changed)
722
+ ctx.io.stderr.write(`${new Date().toISOString()} machine: ${changed} agent config file${changed === 1 ? '' : 's'} from the cloud folded in; version ${version ?? '?'} saving\n`);
723
+ return changed ? 'saved' : 'nothing';
724
+ }