@shardflux/cli 0.20.0 → 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 (110) hide show
  1. package/CHANGELOG.md +214 -1
  2. package/README.md +215 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  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-return.d.ts +46 -2
  16. package/dist/agent-config-return.js +110 -4
  17. package/dist/agent-credentials.js +7 -3
  18. package/dist/agent-repository.d.ts +16 -3
  19. package/dist/agent-repository.js +149 -74
  20. package/dist/agent-setup.d.ts +14 -3
  21. package/dist/agent-setup.js +78 -36
  22. package/dist/agent-target-commands.js +24 -13
  23. package/dist/args.d.ts +1 -1
  24. package/dist/args.js +94 -54
  25. package/dist/bin.js +5 -1
  26. package/dist/browser.d.ts +13 -2
  27. package/dist/browser.js +30 -14
  28. package/dist/claude-cloud-login.d.ts +24 -2
  29. package/dist/claude-cloud-login.js +151 -11
  30. package/dist/cli-proxy-forward.d.ts +2 -0
  31. package/dist/cli-proxy-forward.js +9 -2
  32. package/dist/cli-proxy-hub.d.ts +69 -25
  33. package/dist/cli-proxy-hub.js +231 -54
  34. package/dist/cli-proxy.d.ts +70 -1
  35. package/dist/cli-proxy.js +298 -32
  36. package/dist/client-settings.d.ts +39 -0
  37. package/dist/client-settings.js +91 -0
  38. package/dist/codex-cloud.d.ts +5 -0
  39. package/dist/codex-cloud.js +88 -17
  40. package/dist/command-center-actions.js +5 -1
  41. package/dist/commands.js +171 -22
  42. package/dist/conversation-commands.d.ts +5 -0
  43. package/dist/conversation-commands.js +8 -5
  44. package/dist/conversation-terminal.d.ts +10 -2
  45. package/dist/conversation-terminal.js +77 -13
  46. package/dist/conversations.d.ts +73 -1
  47. package/dist/conversations.js +257 -23
  48. package/dist/device-bridge.d.ts +1 -1
  49. package/dist/device-bridge.js +6 -4
  50. package/dist/device-helper.js +43 -22
  51. package/dist/errors.d.ts +27 -1
  52. package/dist/errors.js +45 -5
  53. package/dist/filesystem-commands.d.ts +16 -9
  54. package/dist/filesystem-commands.js +108 -54
  55. package/dist/format.d.ts +2 -1
  56. package/dist/format.js +10 -1
  57. package/dist/handlers.js +3 -1
  58. package/dist/initial-prompt.d.ts +8 -0
  59. package/dist/initial-prompt.js +43 -1
  60. package/dist/large-media.d.ts +14 -0
  61. package/dist/large-media.js +100 -0
  62. package/dist/linux-terminal.d.ts +5 -3
  63. package/dist/linux-terminal.js +6 -20
  64. package/dist/local-sessions.d.ts +26 -2
  65. package/dist/local-sessions.js +113 -24
  66. package/dist/machine-setup.d.ts +11 -1
  67. package/dist/machine-setup.js +69 -22
  68. package/dist/machine-status.d.ts +67 -0
  69. package/dist/machine-status.js +140 -0
  70. package/dist/main.js +11 -13
  71. package/dist/move-worktrees-restore.py +144 -0
  72. package/dist/move-worktrees.d.ts +63 -0
  73. package/dist/move-worktrees.js +201 -0
  74. package/dist/move.d.ts +10 -1
  75. package/dist/move.js +116 -16
  76. package/dist/onboarding.d.ts +11 -7
  77. package/dist/onboarding.js +59 -27
  78. package/dist/prepared-repository.d.ts +202 -0
  79. package/dist/prepared-repository.js +860 -0
  80. package/dist/prepared-repository.py +1306 -0
  81. package/dist/project-preparation.d.ts +48 -0
  82. package/dist/project-preparation.js +304 -0
  83. package/dist/project-sync-manifest.d.ts +4 -0
  84. package/dist/project-sync-manifest.js +5 -1
  85. package/dist/project-sync-transfer.js +1 -1
  86. package/dist/project-sync.d.ts +19 -4
  87. package/dist/project-sync.js +31 -8
  88. package/dist/project-template-setup.d.ts +33 -1
  89. package/dist/project-template-setup.js +59 -2
  90. package/dist/secret-ask.d.ts +72 -0
  91. package/dist/secret-ask.js +351 -0
  92. package/dist/seed-phases.d.ts +8 -1
  93. package/dist/seed-phases.js +17 -2
  94. package/dist/setup-cache.d.ts +7 -0
  95. package/dist/setup-cache.js +32 -0
  96. package/dist/t3-launch.js +1 -4
  97. package/dist/t3-mode.d.ts +8 -2
  98. package/dist/t3-mode.js +19 -4
  99. package/dist/t3-phone.d.ts +4 -2
  100. package/dist/t3-phone.js +12 -4
  101. package/dist/terminal-apps.d.ts +157 -0
  102. package/dist/terminal-apps.js +561 -0
  103. package/dist/terminal-onboarding.js +6 -1
  104. package/dist/this-computer.d.ts +11 -0
  105. package/dist/this-computer.js +36 -0
  106. package/dist/thread-on-computer.js +4 -1
  107. package/dist/update.js +11 -3
  108. package/dist/worktree-twin.d.ts +10 -2
  109. package/dist/worktree-twin.js +27 -9
  110. package/package.json +5 -5
@@ -1,6 +1,7 @@
1
- import { linuxTerminalSession, openLinuxTerminal } from "./linux-terminal.js";
1
+ import { linuxTerminalSession } from "./linux-terminal.js";
2
+ import { attachable, currentTerminal, openShardInTerminal, referenceFolder, reportTerminalOpen, storedTerminal } from "./terminal-apps.js";
2
3
  import { repositoryName } from "./files-mirror.js";
3
- import { prepareSetupCache } from "./setup-cache.js";
4
+ import { prepareSetupCache, recordSetupCacheFailure } from "./setup-cache.js";
4
5
  import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
5
6
  import { agentReachability } from "./agent-reachability.js";
6
7
  import { configureCredentialFill, watchCredentialPrompts } from "./credential-prompts.js";
@@ -21,12 +22,14 @@ import { preflightProjectSeed, projectWorkspacePath, seedAgentRepository, worksp
21
22
  import { agentName } from "./agent-git.js";
22
23
  import { cloudClaudeLogin, ensureCloudClaude, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
23
24
  import { progress } from "./progress.js";
25
+ import { seedEvent, seedPhase } from "./seed-phases.js";
24
26
  import { rememberLaunch } from "./agent-live.js";
25
27
  import { prepareAgentMachine } from "./agent-machine.js";
26
28
  import { installedTargets } from "./agent-targets.js";
27
29
  import { openInFor } from "./t3-mode.js";
30
+ import { storedTitlePrefix } from "./client-settings.js";
28
31
  import { openClaudeDesktopSession, openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
29
- import { submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
32
+ import { guestFirstPrompt, INBOX_MAX_CHARS, submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
30
33
  import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
31
34
  import { conversationClient } from "./conversation-commands.js";
32
35
  import { conversationTerminal, reopenConversation } from "./conversation-terminal.js";
@@ -53,7 +56,8 @@ export async function projectTemplate(ctx, remote) {
53
56
  }
54
57
  }
55
58
  /** `place` is the repository or folder name session titles end with ("todo-app"), `task` what a new session is titled
56
- * with before the guest names it from its first prompt (a first prompt line or the conversation's title). */
59
+ * with before the guest names it from its first prompt (a first prompt line or the conversation's title), and
60
+ * `launch.titlePrefix` the marker titles start with when this computer chose one ("" for none; 0.20.1+). */
57
61
  export async function agentPty(cell, app, agent, approvals, term, signal, place, setupHash, cwd, ptyName, onLaunch, deferSetup = false, conversation, task, launch) {
58
62
  const id = `shard-${ptyName || agent}-${app}`;
59
63
  // Arrival and changed locks queue the same durable guest job even when the
@@ -81,7 +85,8 @@ export async function agentPty(cell, app, agent, approvals, term, signal, place,
81
85
  }
82
86
  // A model and effort choice reaches only a guest whose run accepts it (the caller checked).
83
87
  const started = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...conversationArgs, ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : []), ...runFlags(launch?.choice)], {
84
- env: { TERM: term, ...(place ? { SF_PROJECT_NAME: place, SF_REPO_NAME: place } : {}), ...(task ? { SF_TASK_TITLE: task } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
88
+ // SF_TITLE_PREFIX: the chosen marker for a launch without a conversation record (a guest before it ignores it).
89
+ env: { TERM: term, ...(place ? { SF_PROJECT_NAME: place, SF_REPO_NAME: place } : {}), ...(task ? { SF_TASK_TITLE: task } : {}), ...(launch?.titlePrefix !== undefined ? { SF_TITLE_PREFIX: launch.titlePrefix } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
85
90
  });
86
91
  if (started.exitCode !== 0)
87
92
  throw new Error(`agent launch failed: ${started.stderr.trim() || `exit ${started.exitCode}`}`);
@@ -122,7 +127,12 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
122
127
  if (app === 'codex' && typeof values.initialPrompt === 'string') {
123
128
  await prepareInitialCodexProject(cell, cwd, `shard-${ptyName || agent}-${app}`);
124
129
  }
125
- const credentialFill = await configureCredentialFill(ctx, ws);
130
+ // Credential fill serves the agent's later credential prompts: configured beside the first prompt, never before it.
131
+ const credentialFill = configureCredentialFill(ctx, ws).catch((error) => {
132
+ if (!ctx.signal.aborted)
133
+ ctx.io.stderr.write(`Credential fill is off for this session: ${error instanceof Error ? error.message : String(error)}\n`);
134
+ return false;
135
+ });
126
136
  // This command's --model/--effort, else the conversation's own (a relaunch keeps its model). A computer whose guest
127
137
  // predates the choice starts the agent with its defaults, and this command says so once.
128
138
  const help = runHelp(cell, app, ctx.signal), explicit = modelChoice(values, app);
@@ -131,16 +141,21 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
131
141
  ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
132
142
  // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
133
143
  const term = ctx.io.env.TERM && ctx.io.env.TERM !== 'dumb' ? ctx.io.env.TERM : 'xterm-256color';
134
- const session = await agentPty(cell, app, agent, values.approvals === true, term, ctx.signal, repositoryName(ws.labels.repo_remote || project), setupHash, cwd, ptyName, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation && { id: conversation.id, session: conversation.session }, sessionTask(typeof values.initialPrompt === 'string' ? values.initialPrompt : conversation?.title), { ...(choice ? { choice } : {}), help });
144
+ const titlePrefix = storedTitlePrefix(ctx.store.dir);
145
+ const session = await agentPty(cell, app, agent, values.approvals === true, term, ctx.signal, repositoryName(ws.labels.repo_remote || project), setupHash, cwd, ptyName, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation && { id: conversation.id, session: conversation.session }, sessionTask(typeof values.initialPrompt === 'string' ? values.initialPrompt : conversation?.title), { ...(choice ? { choice } : {}), help, ...(titlePrefix !== undefined ? { titlePrefix } : {}) });
146
+ seedPhase('harness_launch');
147
+ // When the harness's terminal started, by the VM's clock (the guest may start it before this command asks).
148
+ if (session.created_at)
149
+ seedEvent('harness_started', Date.parse(session.created_at));
135
150
  // A new conversation's guest can start the agent before this command reaches it, so the launch above never ran.
136
151
  // Remember it either way: listing and `agents open` then read the guest until the projection lists the session.
137
152
  rememberLaunch(ctx, ws.id);
138
153
  // The real agent is running before any cache provisioning or mount wait.
139
- const preparation = prepareSetupCache(ctx, ws).catch((error) => {
140
- ctx.io.stderr.write(`Account setup cache unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
141
- }).then(async () => {
154
+ const preparation = prepareSetupCache(ctx, ws).catch((error) => recordSetupCacheFailure(ctx, ws, error)).then(async () => {
142
155
  if (!setupHash)
143
156
  return;
157
+ // A new conversation's VM carries the setup labels before its setup starts (0.21.1+).
158
+ await conversation?.setupLabelled;
144
159
  const queued = await cell.exec.run(['sf-agent', 'setup-start', '--deps-hash', setupHash], { cwd, timeoutMs: 30_000, maxOutputBytes: 4096, signal: ctx.signal });
145
160
  if (queued.exitCode !== 0)
146
161
  throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
@@ -151,12 +166,22 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
151
166
  const text = values.initialPrompt;
152
167
  // A conversation's guest delivers the prompt itself when this terminal never showed its input box (0.19.1+).
153
168
  const queue = conversation ? () => conversationClient(ctx).conversations.send(conversation.id, text, { signal: ctx.signal }) : undefined;
154
- delivery = await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), text, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes), queue);
169
+ // The VM's guest types it where it can (0.21.1+); this terminal types it for an older guest. A harness the guest
170
+ // waited for without seeing its input box gets it through the inbox, not a second wait here.
171
+ const guest = await guestFirstPrompt(cell, session.session_id, app, text, ws.key, ctx.signal);
172
+ if (guest === 'submitted')
173
+ delivery = 'submitted';
174
+ else if (guest === 'not_ready' && queue && text.length <= INBOX_MAX_CHARS) {
175
+ await queue();
176
+ delivery = 'queued';
177
+ }
178
+ else
179
+ delivery = await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), text, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes), queue);
155
180
  }
156
181
  // Work that must never delay the first prompt (the laptop's agent config, 0.19.0+) starts once the agent has it.
157
182
  const after = conversation?.afterStart?.();
158
183
  if (!attach) {
159
- await preparation;
184
+ await Promise.all([preparation, credentialFill]);
160
185
  // conversation (0.16.0+): which conversation this session is, also when its computer hosts several threads.
161
186
  // model_choice (0.17.0+): the requested model and effort, and whether this thread started with them.
162
187
  ctx.io.stdout.write(json({ workspace: ws.data, session, ...(conversation ? { conversation: { id: conversation.id } } : {}), ...(delivery ? { initial_prompt: delivery === 'submitted' ? { submitted: true } : { submitted: false, queued: true } } : {}),
@@ -166,7 +191,7 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
166
191
  }
167
192
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
168
193
  const attached = attachTerminal({
169
- ...(credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
194
+ ...(await credentialFill ? { credentialPrompts: (ask, signal) => watchCredentialPrompts(ctx, ws, ask, signal) } : {}),
170
195
  io: ctx.io, signal: ctx.signal, connect: cellConnector(cell, session.session_id, { passive: true }),
171
196
  wake: (signal) => ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal }),
172
197
  asleep: () => workspaceAsleep(ws),
@@ -294,12 +319,54 @@ export async function launchProjectWith(ctx, values, [path = './', explicitApp],
294
319
  await prepareAgentMachine(ctx, ws);
295
320
  return await terminal(ctx, ws, app, agent, projectKey, values, prepared?.setupHash);
296
321
  }
322
+ /**
323
+ * Whether `agents open` hands the conversation to the person's terminal app (0.20.1+) rather than attaching here:
324
+ * `--in terminal` with --json (an app) or `--window`, any open from a shell nobody types into (a coding agent's, or no
325
+ * terminal), and `--in terminal` typed in a terminal other than the chosen one.
326
+ */
327
+ function opensInTerminalApp(ctx, values) {
328
+ if (values.detach === true)
329
+ return false;
330
+ const here = attachable(ctx.io);
331
+ if (values.in === undefined)
332
+ return !ctx.json && !here;
333
+ if (values.in !== 'terminal')
334
+ return false;
335
+ if (ctx.json || !here || values.window === true)
336
+ return true;
337
+ const stored = storedTerminal(ctx.store.dir);
338
+ return !!stored && stored.id !== currentTerminal(ctx.io.env);
339
+ }
340
+ /** `shard agents open <ref>` (its attach, no --in) in a new tab of the person's terminal app, in the project folder. */
341
+ async function openInTerminalApp(ctx, ref, values, extra = {}) {
342
+ const words = ['agents', 'open', ref, ...(typeof values.app === 'string' ? ['--app', values.app] : [])];
343
+ const result = await openShardInTerminal(ctx, words, { cwd: referenceFolder(ctx.store.dir, ctx.apiUrl, ref, ctx.io.env), window: values.window === true });
344
+ reportTerminalOpen(ctx, result, extra);
345
+ return 0;
346
+ }
347
+ /**
348
+ * `--in auto` for a terminal conversation: the person's terminal app (a tab there, 0.20.1+), or this terminal when it
349
+ * is the one they use. --json without a chosen terminal app keeps the command for an app to run (a menu bar before
350
+ * 0.20.1 opens Terminal with it).
351
+ */
352
+ function terminalAuto(ctx, ws, opened, values) {
353
+ if (ctx.json && !storedTerminal(ctx.store.dir)) {
354
+ out(ctx, `shard agents open ${opened}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', opened] });
355
+ return Promise.resolve(0);
356
+ }
357
+ const next = { ...values, in: 'terminal' };
358
+ if (opensInTerminalApp(ctx, next))
359
+ return openInTerminalApp(ctx, opened, next, { workspace: ws.data });
360
+ delete next.in;
361
+ return AGENT_HANDLERS['agents open'](ctx, next, [opened]);
362
+ }
297
363
  export const AGENT_HANDLERS = {
298
364
  async 'agents open'(ctx, values, [agent = 'main']) {
299
365
  if (values.app !== undefined && values.app !== 'claude' && values.app !== 'codex')
300
366
  throw new UsageError('--app must be claude or codex');
301
- if (process.platform === 'linux' && !ctx.json && !ctx.io.stdin.isTTY && values.detach !== true && (values.in === undefined || values.in === 'terminal'))
302
- return openLinuxTerminal(ctx, agent, typeof values.app === 'string' ? values.app : undefined);
367
+ // The person's terminal app (0.20.1+): before any cloud work, the new tab owns the attach, credentials and prompts.
368
+ if (opensInTerminalApp(ctx, values))
369
+ return openInTerminalApp(ctx, agent, values);
303
370
  let resolvedConversation;
304
371
  if (/^conv_[a-z0-9]{26}$/.test(agent)) {
305
372
  resolvedConversation = await getConversation(ctx, agent);
@@ -330,15 +397,9 @@ export const AGENT_HANDLERS = {
330
397
  }
331
398
  if (chosen === 'claude-desktop' && app === 'claude')
332
399
  return openClaudeDesktopSession(ctx, ws, resolvedConversation);
333
- if (chosen === 'terminal' && app === 'claude') {
334
- if (ctx.json) {
335
- out(ctx, `shard agents open ${opened}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', opened] });
336
- return 0;
337
- }
338
- const next = { ...values };
339
- delete next.in;
340
- return AGENT_HANDLERS['agents open'](ctx, next, [opened]);
341
- }
400
+ // A terminal app attaches to the conversation's own session, Claude Code's or Codex's (Codex 0.21.1+).
401
+ if (chosen === 'terminal' && (app === 'claude' || app === 'codex'))
402
+ return terminalAuto(ctx, ws, opened, values);
342
403
  const saved = chosen === 'codex-app' ? undefined : store.read().projects?.[project]?.target ??
343
404
  (project.startsWith('file://') ? store.read().projects?.[new URL(project).pathname]?.target : undefined);
344
405
  // Codex's default open implementation owns Cloud/desktop routing. PAS-XB replaces that path; do not pin
@@ -360,14 +421,7 @@ export const AGENT_HANDLERS = {
360
421
  if (detected.clients.includes('claude-desktop')) {
361
422
  return openClaudeDesktopSession(ctx, ws, resolvedConversation);
362
423
  }
363
- if (ctx.json) {
364
- // An app runs this in the person's terminal (the menu bar opens Terminal with it).
365
- out(ctx, `shard agents open ${opened}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', opened] });
366
- return 0;
367
- }
368
- const next = { ...values };
369
- delete next.in;
370
- return AGENT_HANDLERS['agents open'](ctx, next, [opened]);
424
+ return terminalAuto(ctx, ws, opened, values);
371
425
  }
372
426
  if (values.in !== undefined && values.in !== 'terminal') {
373
427
  const { HANDLERS } = await import("./handlers.js");
@@ -2,6 +2,8 @@ import type { CellClient } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
3
  import type { Conversation, LocalConversation } from './conversations.js';
4
4
  export declare const RETURN_ROOT = "/shardflux/project/agent-config";
5
+ /** The latest agent-config line ({notice, at}), for the Linux app's top-bar panel (CLI 0.20.2+). */
6
+ export declare const AGENT_CONFIG_NOTICE = "agent-config-notice.json";
5
7
  /** A machine fold waits for this much quiet after the last change, and saves the machine at most this often. */
6
8
  export declare const FOLD_QUIET_MS = 60000;
7
9
  export declare const FOLD_INTERVAL_MS: number;
@@ -116,8 +118,50 @@ export declare class AgentConfigReturnState {
116
118
  }
117
119
  /** How often the device helper looks for agent config to bring back: its own loop, never behind a files or transcript pass. */
118
120
  export declare const AGENT_CONFIG_POLL_MS = 2000;
119
- /** One pass of the device helper's own agent-config loop: list the account's conversations and apply what came back. */
120
- export declare function returnAgentConfigsOnce(ctx: Ctx, state: AgentConfigReturnState): Promise<string | undefined>;
121
+ /** With none of this user's conversations active, the full listing backs off (doubling) to this. */
122
+ export declare const AGENT_CONFIG_IDLE_MS = 60000;
123
+ /** While backed off, one cheap request (`state=working`, usually empty) this often notices a turn starting anywhere. */
124
+ export declare const AGENT_CONFIG_PROBE_MS = 10000;
125
+ /** A conversation counts as active for this long after its last activity (a turn's start or end). */
126
+ export declare const AGENT_CONFIG_ACTIVE_WINDOW_MS: number;
127
+ /**
128
+ * A working conversation counts as active only this long after its last activity: one left "working" by a VM that went
129
+ * away (staging QA FE1: 60 such, all more than a day old) must not hold every computer at the fast cadence. A longer
130
+ * turn still comes back, at the idle cadence.
131
+ */
132
+ export declare const AGENT_CONFIG_WORKING_WINDOW_MS: number;
133
+ /** This user's conversations that are working (recently) or ended a turn within the active window. */
134
+ export declare function activeConversations(all: Conversation[], records: Record<string, LocalConversation>, me: string | undefined, now?: number): Conversation[];
135
+ export interface CadenceOptions {
136
+ activeMs: number;
137
+ idleMs: number;
138
+ probeMs: number;
139
+ }
140
+ export declare const CADENCE: CadenceOptions;
141
+ /**
142
+ * When the next listing is due. Active (one of this user's conversations works or just ended a turn): every 2 s.
143
+ * Idle: the interval doubles to 60 s, with a cheap working-only probe every 10 s and a check of this computer's own
144
+ * conversation records each second; either snaps it back to 2 s at once.
145
+ */
146
+ export declare class AgentConfigCadence {
147
+ readonly options: CadenceOptions;
148
+ interval: number;
149
+ fullAt: number;
150
+ probeAt: number;
151
+ recordsMtime: number;
152
+ constructor(options?: CadenceOptions);
153
+ /** After a full pass: active keeps 2 s, idle doubles toward the idle ceiling. */
154
+ listed(active: boolean, now?: number): void;
155
+ snap(): void;
156
+ fullDue(now?: number): boolean;
157
+ probeDue(now?: number): boolean;
158
+ }
159
+ /** One full pass: list the account's conversations, apply what came back; true while one of this user's is active. */
160
+ export declare function returnAgentConfigsOnce(ctx: Ctx, state: AgentConfigReturnState): Promise<boolean>;
161
+ /** The cheap probe: is any of this user's conversations working right now (one request, usually an empty page)? */
162
+ export declare function workingProbe(ctx: Ctx, state: AgentConfigReturnState): Promise<boolean>;
163
+ /** The device helper's agent-config loop: activity-driven cadence, its own backoff on errors, never another loop's. */
164
+ export declare function runAgentConfigLoop(ctx: Ctx, state: AgentConfigReturnState, signal: AbortSignal, cadence?: AgentConfigCadence, tickMs?: number): Promise<void>;
121
165
  /**
122
166
  * This computer's checkout of a conversation's repository: the folder it was started from here, else the folder of
123
167
  * another conversation of the same repository started here. A Shardflux project can hold many repositories (staging
@@ -16,16 +16,20 @@
16
16
  import { createHash, randomBytes } from 'node:crypto';
17
17
  import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
18
18
  import { homedir } from 'node:os';
19
+ import { setTimeout as delay } from 'node:timers/promises';
19
20
  import { basename, dirname, extname, join } from 'node:path';
20
21
  import { ShardfluxApiError } from '@shardflux/sdk';
21
- import { listConversations, localConversations } from "./conversations.js";
22
+ import { conversationsUnavailable, listConversations, localConversations } from "./conversations.js";
22
23
  import { MEMORY_PATH, localConfigPath, memoryRoot, returnablePath } from "./agent-config-files.js";
23
24
  import { writePrivateFile } from "./credentials.js";
24
25
  import { conversationClient } from "./conversation-commands.js";
25
26
  import { configManifests } from "./agent-config-sync.js";
26
27
  import { savedMachine } from "./filesystem-state.js";
27
28
  import { openMachine } from "./filesystem-commands.js";
29
+ import { thisComputer } from "./this-computer.js";
28
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';
29
33
  const HASH = /^[0-9a-f]{64}$/;
30
34
  const MAX_BYTES = 16 << 20;
31
35
  const READ_CHUNK = 8 << 20;
@@ -328,7 +332,7 @@ function list(names) {
328
332
  */
329
333
  export function returnNotice(app, items, platform = process.platform) {
330
334
  const who = app === 'codex' ? 'Codex' : 'Claude';
331
- const here = platform === 'darwin' ? 'this Mac' : 'this computer';
335
+ const here = thisComputer(platform);
332
336
  // A memory's MEMORY.md index line is part of saving that memory, not a memory of its own.
333
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));
334
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);
@@ -379,9 +383,104 @@ async function projectCell(ctx, c, all) {
379
383
  }
380
384
  /** How often the device helper looks for agent config to bring back: its own loop, never behind a files or transcript pass. */
381
385
  export const AGENT_CONFIG_POLL_MS = 2000;
382
- /** One pass of the device helper's own agent-config loop: list the account's conversations and apply what came back. */
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. */
383
431
  export async function returnAgentConfigsOnce(ctx, state) {
384
- return returnAgentConfigs(ctx, localConversations(ctx), await listConversations(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
+ }
385
484
  }
386
485
  /**
387
486
  * This computer's checkout of a conversation's repository: the folder it was started from here, else the folder of
@@ -498,6 +597,13 @@ export async function returnAgentConfigs(ctx, records, all, state) {
498
597
  if (line) {
499
598
  ctx.io.stdout.write(JSON.stringify({ type: 'agent_config', notice: line }) + '\n');
500
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
+ }
501
607
  }
502
608
  // SHARDFLUX_AGENT_CONFIG_FOLD=off holds machine folds (an operator testing that machine); the queue keeps them.
503
609
  if (!state.folding && ctx.io.env.SHARDFLUX_AGENT_CONFIG_FOLD !== 'off')
@@ -3,7 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { json } from "./format.js";
5
5
  import { UsageError } from "./errors.js";
6
- import { loginClaudeInWorkspace, requireClaudeTerminal, cloudClaudeLogin } from "./claude-cloud-login.js";
6
+ import { claudeTerminal, cloudClaudeLogin, loginClaudeInWorkspace } from "./claude-cloud-login.js";
7
+ import { askTimeout } from "./secret-ask.js";
7
8
  import { resolveWorkspace } from "./commands.js";
8
9
  import { projectIdentity, projectLabels } from "./project-identity.js";
9
10
  async function optionalFile(file) {
@@ -74,7 +75,10 @@ export const AGENT_CREDENTIAL_HANDLERS = {
74
75
  output(ctx, existing);
75
76
  return 0;
76
77
  }
77
- requireClaudeTerminal(ctx);
78
+ // In a coding agent's shell, with --json, without a terminal or with --ask (0.20.1+): the claude.ai sign-in and a
79
+ // page for its code open in the browser. On a terminal the sign-in attaches to it.
80
+ const headless = values.ask === true || !claudeTerminal(ctx);
81
+ const timeoutMs = askTimeout(values);
78
82
  const project = projectIdentity('./');
79
83
  let ws;
80
84
  if (typeof values.workspace === 'string')
@@ -85,7 +89,7 @@ export const AGENT_CREDENTIAL_HANDLERS = {
85
89
  const current = await cloud.workspaces.findByKey(key, { includeDeleted: false, signal: ctx.signal });
86
90
  ws = await cloud.workspaces.open({ key, template: 'coding', labels: { ...current?.labels, ...projectLabels(project, 'main', 'claude') }, wait: { signal: ctx.signal, timeoutMs: ctx.wakeTimeoutMs } });
87
91
  }
88
- output(ctx, await loginClaudeInWorkspace(ctx, ws));
92
+ output(ctx, await loginClaudeInWorkspace(ctx, ws, headless ? { headless: { timeoutMs } } : {}));
89
93
  return 0;
90
94
  },
91
95
  'agents credentials sync': async (ctx) => { output(ctx, await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME ?? ctx.io.env.USERPROFILE, redact: (value) => ctx.addSecret(value) })); return 0; },
@@ -22,12 +22,21 @@ export declare function seedAgentRepository(ctx: Ctx, ws: Workspace, folder: str
22
22
  populate?: boolean;
23
23
  initialFiles?: readonly string[];
24
24
  onInitialFiles?: () => void;
25
+ changes?: Promise<LocalChanges>;
26
+ background?: (started: Promise<unknown>) => void;
27
+ beforeHistory?: Promise<unknown>;
25
28
  }): Promise<void>;
26
29
  /**
27
30
  * Above this (an estimate: the repository's packed and loose objects) the workspace fetches the history from origin as a
28
31
  * blobless partial clone, and receives only the commits origin lacks (0.19.0+). Below it, one small bundle is faster.
29
32
  */
30
33
  export declare const ORIGIN_FIRST_BYTES: number;
34
+ /**
35
+ * A Git step of the seed that writes the workspace repository's index, refs or config: run again, for up to ~5 s,
36
+ * while another Git process in the VM holds the lock (a conversation's agent starts as soon as its repository appears,
37
+ * and its own `git status` takes the index lock). Every step passed here is safe to repeat.
38
+ */
39
+ export declare function runGitStep(cell: Pick<SyncCell, 'exec'>, argv: string[], options: Parameters<SyncCell['exec']['run']>[1] & object, attempts?: number): Promise<Awaited<ReturnType<SyncCell['exec']['run']>>>;
31
40
  /** The repository's object store size, an upper bound of any one branch's history bundle. Reads sizes only. */
32
41
  export declare function historyEstimate(folder: string, signal?: AbortSignal): Promise<number>;
33
42
  export declare const FETCH_ORIGIN: string;
@@ -39,7 +48,10 @@ export declare const CHECKOUT_HEAD: string;
39
48
  * from origin as a blobless partial clone (the workspace's own Git credentials), plus a bundle of the commits origin
40
49
  * lacks. Without a reachable origin the whole bundle is streamed in chunks: no history size refuses (0.19.0+).
41
50
  */
42
- export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void): Promise<{
51
+ export declare function importProjectHistory(cell: SyncCell, folder: string, root: string, head: string, branch: string, origin: string | null, signal?: AbortSignal, originFirst?: number, report?: (message: string) => void, configure?: {
52
+ settings: string[];
53
+ excludes: string;
54
+ }): Promise<{
43
55
  partial: string | null;
44
56
  }>;
45
57
  /**
@@ -47,11 +59,12 @@ export declare function importProjectHistory(cell: SyncCell, folder: string, roo
47
59
  * untracked files Git does not ignore, the .env files we always carry, Git LFS files (their content lives outside Git),
48
60
  * and deletions. Ignored files, nested repositories and submodule contents stay out.
49
61
  */
50
- export declare function localChanges(folder: string, signal?: AbortSignal): Promise<{
62
+ export type LocalChanges = {
51
63
  copy: string[];
52
64
  remove: string[];
53
65
  skipped: {
54
66
  path: string;
55
67
  reason: string;
56
68
  }[];
57
- }>;
69
+ };
70
+ export declare function localChanges(folder: string, signal?: AbortSignal): Promise<LocalChanges>;