@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
@@ -14,6 +14,7 @@ import { UsageError } from "./errors.js";
14
14
  import { conversationClient } from "./conversation-commands.js";
15
15
  import { withTargetLock } from "./target-lock.js";
16
16
  import { checkConversationWorktree, rememberConversationWorktree } from "./conversation-worktree.js";
17
+ import { yourComputer } from "./this-computer.js";
17
18
  const LIMIT = 256 << 20, OUTPUT = (192 << 10) - 128;
18
19
  const inside = (root, target) => target === root || target.startsWith(root + sep);
19
20
  const safeId = (value) => { if (!/^[a-zA-Z0-9_-]{1,96}$/.test(value))
@@ -67,14 +68,15 @@ export class DeviceGrants {
67
68
  });
68
69
  }
69
70
  }
70
- export function deviceSummary(r) {
71
+ export function deviceSummary(r, platform = process.platform) {
72
+ const yours = yourComputer(platform);
71
73
  if (tryLocallyAction(r))
72
74
  return tryLocallyAction(r) === 'stop' ? 'Stop this conversation’s local sync and ports.' : 'Try this conversation in an isolated local worktree with two-way sync and localhost ports.';
73
75
  if (r.kind === 'exec')
74
- return `Run on your Mac: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
76
+ return `Run on ${yours}: ${JSON.stringify(r.exec?.argv)} · directory ${r.exec?.cwd_relative ?? '.'}`;
75
77
  if (r.files)
76
- return `${r.kind === 'files_pull' ? 'Share with conversation' : 'Save on your Mac'}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
77
- return `${r.kind === 'port_forward' ? 'Open conversation preview on your Mac' : 'Connect conversation to your Mac'}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
78
+ return `${r.kind === 'files_pull' ? 'Share with conversation' : `Save on ${yours}`}: ${r.files.local_path_relative} ↔ ${r.files.remote_path}`;
79
+ return `${r.kind === 'port_forward' ? `Open conversation preview on ${yours}` : `Connect conversation to ${yours}`}: ${r.port?.remote_port} ↔ localhost:${r.port?.local_port}`;
78
80
  }
79
81
  /** Structured local approval details; the summary remains for older menu bar and ACP clients. */
80
82
  export function deviceApproval(r, c, target, folder) {
@@ -4,16 +4,17 @@ import { ConversationMirrorState, mirrorProjectConversations } from "./conversat
4
4
  import { FilesMirrorState, mirrorFilesOnce } from "./files-mirror.js";
5
5
  import { withTargetLock } from "./target-lock.js";
6
6
  import { join } from 'node:path';
7
- import { conversationsUnavailable } from "./conversations.js";
8
- import { localConversations } from "./conversations.js";
7
+ import { completeDeferredPreparation, conversationsUnavailable, localConversations } from "./conversations.js";
9
8
  import { randomUUID } from 'node:crypto';
10
9
  import { readFileSync } from 'node:fs';
11
10
  import { writePrivateFile } from "./credentials.js";
12
11
  import { conversationClient } from "./conversation-commands.js";
13
12
  import { Readable } from 'node:stream';
14
13
  import { runWorktreeTwin } from "./worktree-twin-engine.js";
15
- import { AGENT_CONFIG_POLL_MS, AgentConfigReturnState, returnAgentConfigsOnce } from "./agent-config-return.js";
14
+ import { AgentConfigReturnState, runAgentConfigLoop } from "./agent-config-return.js";
16
15
  import { askLocalDeviceApproval } from "./device-approvals.js";
16
+ import { resumeCloudStartup, startCarried } from "./codex-cloud.js";
17
+ import { runProjectPreparation } from "./project-preparation.js";
17
18
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
18
19
  export async function runDeviceHelper(ctx, options = {}) {
19
20
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
@@ -68,33 +69,51 @@ export async function runDeviceHelper(ctx, options = {}) {
68
69
  }
69
70
  const executor = new DeviceExecutor(ctx, ask, deviceId);
70
71
  const report = (e) => { ctx.io.stderr.write(`Device bridge: ${e instanceof Error ? e.message : String(e)}\n`); };
71
- let mirroring, twin, returning;
72
+ let mirroring, twin, returning, preparing, projects;
72
73
  try {
73
74
  return await withTargetLock(join(ctx.store.dir, 'device-helper-lock'), async () => {
74
75
  // Finished conversations land in their local branches (worktree-twin-engine.ts); its own loop and backoff.
75
76
  twin = runWorktreeTwin(ctx, deviceId, signal).catch(report);
76
77
  // Skills, memories and CLAUDE.md lines saved in the cloud come back on their own cadence (agent-config-return.ts):
77
- // never behind a files-mirror pass, which takes minutes for an account with hundreds of conversations.
78
- returning = (async () => {
79
- let failures = 0;
80
- const reported = new Set();
78
+ // never behind a files-mirror pass, which takes minutes for an account with hundreds of conversations. Every 2 s
79
+ // while one of this user's conversations is active, backing off to 60 s when none is.
80
+ returning = runAgentConfigLoop(ctx, config, signal).catch(report);
81
+ projects = runProjectPreparation(ctx).catch(report);
82
+ // Starts outlive the foreground command (0.22.0+): an accepted Codex Cloud start resumes with its own revision
83
+ // and idempotent admission and message; large media that followed a first prompt land while the VM is awake.
84
+ preparing = (async () => {
85
+ const failures = new Map();
81
86
  while (!signal.aborted) {
82
- let wait = AGENT_CONFIG_POLL_MS;
83
- try {
84
- await returnAgentConfigsOnce(ctx, config);
85
- failures = 0;
86
- }
87
- catch (e) {
88
- if (signal.aborted)
89
- break;
90
- wait = Math.min(300_000, 5000 * 2 ** Math.min(failures++, 6));
91
- const message = e instanceof Error ? e.message : String(e);
92
- if (!conversationsUnavailable(e) && !reported.has(message)) {
93
- reported.add(message);
94
- ctx.io.stderr.write(`Agent config: ${message} (retrying in ${wait / 1000}s).\n`);
87
+ let deferred = false;
88
+ for (const local of Object.values(localConversations(ctx))) {
89
+ if (local.conversation.ended_at || (failures.get(local.conversation.id) ?? 0) > Date.now())
90
+ continue;
91
+ try {
92
+ // A start a foreground command still carries is its own: resumed only once nobody does.
93
+ if (local.cloud_start && local.cloud_start.state !== 'active' && !local.cloud_start.attachments_pending) {
94
+ if (!startCarried(local.cloud_start))
95
+ await resumeCloudStartup(ctx, local.conversation);
96
+ }
97
+ else if (local.preparation?.state === 'deferred') {
98
+ // A parked VM is never woken for this: its media land the next time it is awake.
99
+ if (await completeDeferredPreparation(ctx, local.conversation))
100
+ failures.delete(local.conversation.id);
101
+ else {
102
+ deferred = true;
103
+ failures.set(local.conversation.id, Date.now() + 15_000);
104
+ }
105
+ continue;
106
+ }
107
+ failures.delete(local.conversation.id);
108
+ }
109
+ catch (error) {
110
+ if (signal.aborted)
111
+ break;
112
+ report(error);
113
+ failures.set(local.conversation.id, Date.now() + 60_000);
95
114
  }
96
115
  }
97
- await delay(wait, undefined, { signal }).catch(() => undefined);
116
+ await delay(deferred ? 5_000 : 30_000, undefined, { signal }).catch(() => undefined);
98
117
  }
99
118
  })();
100
119
  // A slow or failed transcript copy must not delay heartbeats, request polling or approvals.
@@ -161,6 +180,8 @@ export async function runDeviceHelper(ctx, options = {}) {
161
180
  await returning;
162
181
  await config.folding;
163
182
  await twin;
183
+ await preparing;
184
+ await projects;
164
185
  await input.catch(() => undefined);
165
186
  }
166
187
  }
package/dist/errors.d.ts CHANGED
@@ -58,9 +58,14 @@ export declare class TermsNotAcceptedError extends CodedError {
58
58
  export declare class AuthConfigError extends HintedError {
59
59
  name: string;
60
60
  }
61
- /** A workspace key that no workspace of this project has (or another name that resolves to nothing). */
61
+ /**
62
+ * A workspace key that no workspace of this project has (or another name that resolves to nothing). `details` keeps the
63
+ * API's reason when shard rewords its 404 (0.20.1+: `filesystem_not_found` from shard template delete).
64
+ */
62
65
  export declare class NotFoundError extends HintedError {
63
66
  name: string;
67
+ readonly details: Record<string, unknown> | undefined;
68
+ constructor(message: string, hint?: string, details?: Record<string, unknown>);
64
69
  }
65
70
  /**
66
71
  * `shard machine setup --from-this-computer` (0.20.0+) ran but did not save a machine: the agent failed, ran past its
@@ -147,6 +152,27 @@ export interface ErrorInfo {
147
152
  */
148
153
  export declare function redact(text: string, ...secrets: (string | null | undefined)[]): string;
149
154
  /** The `hint:` a CLI error carries itself, if any. */
155
+ /**
156
+ * After a save that failed (0.20.2+): the version in use and the source VM are as they were, so the same save can run
157
+ * again; the build's log says what happened.
158
+ */
159
+ export declare function saveRecovery(level: 'machine' | 'template', inUse: number, buildId?: string, failure?: SaveFailure): string;
160
+ /** What a failed save reports about its build (SDK 0.26.0+ on an API that carries them). */
161
+ export interface SaveFailure {
162
+ code?: string | undefined;
163
+ retryable?: boolean | undefined;
164
+ details?: Record<string, unknown> | undefined;
165
+ }
166
+ /**
167
+ * A save larger than the plan's workspace disk (0.21.1+): resource_limit with details.limit template_org_bytes_max;
168
+ * `limit` is the plan's workspace disk and `actual` the version's size, in bytes.
169
+ */
170
+ export declare function planDiskLimit(failure: SaveFailure | undefined): {
171
+ limit: number;
172
+ actual: number;
173
+ } | undefined;
174
+ /** Bytes as the plan names a disk: `2 GiB`, `2.5 GiB`. */
175
+ export declare const gib: (bytes: number) => string;
150
176
  export declare function ownHint(err: unknown): string | undefined;
151
177
  export declare function describeError(err: unknown): ErrorInfo;
152
178
  /** Maps an error to the documented exit code. */
package/dist/errors.js CHANGED
@@ -83,9 +83,17 @@ export class TermsNotAcceptedError extends CodedError {
83
83
  export class AuthConfigError extends HintedError {
84
84
  name = 'AuthConfigError';
85
85
  }
86
- /** A workspace key that no workspace of this project has (or another name that resolves to nothing). */
86
+ /**
87
+ * A workspace key that no workspace of this project has (or another name that resolves to nothing). `details` keeps the
88
+ * API's reason when shard rewords its 404 (0.20.1+: `filesystem_not_found` from shard template delete).
89
+ */
87
90
  export class NotFoundError extends HintedError {
88
91
  name = 'NotFoundError';
92
+ details;
93
+ constructor(message, hint, details) {
94
+ super(message, hint);
95
+ this.details = details;
96
+ }
89
97
  }
90
98
  /**
91
99
  * `shard machine setup --from-this-computer` (0.20.0+) ran but did not save a machine: the agent failed, ran past its
@@ -175,6 +183,34 @@ export function redact(text, ...secrets) {
175
183
  return out;
176
184
  }
177
185
  /** The `hint:` a CLI error carries itself, if any. */
186
+ /**
187
+ * After a save that failed (0.20.2+): the version in use and the source VM are as they were, so the same save can run
188
+ * again; the build's log says what happened.
189
+ */
190
+ export function saveRecovery(level, inUse, buildId, failure) {
191
+ const kept = inUse > 0 ? `version ${inUse} stays in use and ` : '';
192
+ const vm = level === 'machine' ? 'machine' : 'project';
193
+ // Larger than the plan's workspace disk (0.21.1+): the same save fails again until the VM or the plan changes.
194
+ const disk = planDiskLimit(failure);
195
+ if (disk) {
196
+ const open = level === 'machine' ? ' (shard machine open)' : '';
197
+ return `${kept}your ${vm} VM is untouched; delete what it does not need there${open}, such as caches and build output, then run shard ${level} save --wait; or upgrade to a plan with more than ${gib(disk.limit)} of disk: shard billing plans`;
198
+ }
199
+ if (failure?.retryable === false)
200
+ return `${kept}your ${vm} VM is untouched; change what the message names in it, then run shard ${level} save --wait`;
201
+ const log = buildId ? `; build log: shard templates builds log ${buildId}` : '';
202
+ return `${kept}your ${vm} VM is untouched; run shard ${level} save --wait again (it saves from the same VM)${log}`;
203
+ }
204
+ /**
205
+ * A save larger than the plan's workspace disk (0.21.1+): resource_limit with details.limit template_org_bytes_max;
206
+ * `limit` is the plan's workspace disk and `actual` the version's size, in bytes.
207
+ */
208
+ export function planDiskLimit(failure) {
209
+ const d = failure?.details;
210
+ return failure?.code === 'resource_limit' && d?.limit === 'template_org_bytes_max' && typeof d.value === 'number' && typeof d.actual === 'number' ? { limit: d.value, actual: d.actual } : undefined;
211
+ }
212
+ /** Bytes as the plan names a disk: `2 GiB`, `2.5 GiB`. */
213
+ export const gib = (bytes) => `${Number((bytes / 2 ** 30).toFixed(2))} GiB`;
178
214
  export function ownHint(err) {
179
215
  return err instanceof HintedError ? err.hint : undefined;
180
216
  }
@@ -260,10 +296,14 @@ export function describeError(err) {
260
296
  if (err instanceof TemplateBuildTimeoutError)
261
297
  return { code: 'timeout', message: `${err.message}; follow it with: shard templates builds get ${err.build.id}`, last_state: err.build.state };
262
298
  // machine/template save --wait (0.13.3+): the save ended without its version, or is still building at the deadline.
263
- if (err instanceof FilesystemSaveFailedError)
264
- return { code: 'filesystem_save_failed', message: err.message, retryable: true, details: { level: err.state.level, version: err.version } };
299
+ // 0.20.2+: details.build_id, and a failure's recovery as `fix` (the hint line).
300
+ // 0.21.1+: the build's failure (error_code and its details, e.g. limit/value/actual) and whether the same save can
301
+ // succeed (retryable false: larger than the plan's workspace disk, or another refusal that repeats until the VM changes).
302
+ if (err instanceof FilesystemSaveFailedError) {
303
+ return { code: 'filesystem_save_failed', message: err.message, retryable: err.retryable, fix: saveRecovery(err.state.level, err.state.version, err.buildId, err), details: { ...(err.details ?? {}), ...(err.code ? { error_code: err.code } : {}), level: err.state.level, version: err.version, ...(err.buildId ? { build_id: err.buildId } : {}), ...(err.progress ? { last_progress: err.progress } : {}) } };
304
+ }
265
305
  if (err instanceof FilesystemSaveTimeoutError)
266
- return { code: 'timeout', message: `${err.message}; check with shard ${err.state.level}`, last_state: err.state.state, details: { level: err.state.level, version: err.version } };
306
+ return { code: 'timeout', message: `${err.message}; check with shard ${err.state.level}`, last_state: err.state.state, details: { level: err.state.level, version: err.version, ...(err.buildId ? { build_id: err.buildId } : {}), ...(err.progress ? { last_progress: err.progress } : {}) } };
267
307
  if (err instanceof TemplateUploadError)
268
308
  return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
269
309
  if (err instanceof AuthConfigError)
@@ -271,7 +311,7 @@ export function describeError(err) {
271
311
  if (err instanceof MfaPendingError)
272
312
  return { code: 'mfa_required', message: err.message };
273
313
  if (err instanceof NotFoundError)
274
- return { code: 'not_found', message: err.message };
314
+ return { code: 'not_found', message: err.message, ...(err.details ? { details: err.details } : {}) };
275
315
  if (err instanceof MachineSetupError)
276
316
  return { code: 'machine_setup_failed', message: err.message, retryable: true };
277
317
  if (err instanceof InterruptedError) {
@@ -1,6 +1,9 @@
1
- import type { AgentFilesystem, Workspace } from '@shardflux/sdk';
1
+ import type { AgentFilesystem, AgentFilesystemDeleted, Workspace } from '@shardflux/sdk';
2
2
  import type { Ctx, Handler } from './commands.js';
3
- /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
3
+ /**
4
+ * One line per status: what is in use and what is saving or failed, its platform base and setup cache. A project
5
+ * template unused for 30 days (rebase_deferred, 0.20.1+) follows the machine when the next conversation starts.
6
+ */
4
7
  export declare function filesystemLine(state: AgentFilesystem & {
5
8
  cache_scope?: string;
6
9
  }): string;
@@ -10,15 +13,19 @@ export interface BuildQueue {
10
13
  slots: number;
11
14
  expected_wait_seconds: number;
12
15
  }
13
- /** "about 40 s" / "about 3 min": the queue's estimate, rounded the way people say it. */
14
- export declare function aboutWait(seconds: number): string;
15
- /** One line for a build that waits for a slot (0.20.0+): "Queued behind 3 builds · about 40 s" (running ones + those ahead). */
16
- export declare function queueLine(q: BuildQueue): string;
17
- /** How often a waited save reads its build's place in the queue. */
18
- export declare const QUEUE_POLL_MS = 2500;
16
+ /** The pending build's queue, if it waits for a slot; nothing when the API predates it or the build cannot be read. */
17
+ export declare function pendingQueue(ctx: Ctx, state: AgentFilesystem): Promise<BuildQueue | undefined>;
18
+ /** Within one phase of a waited save, a changed counter line is printed at most this often (0.20.2+). */
19
+ export declare const SAVE_PROGRESS_LINE_MS = 30000;
19
20
  /** The save's one line about software under the home that went into the image (0.18.0+), as the in-VM shard says it. */
20
21
  export declare function movedLine(state: AgentFilesystem, level: 'machine' | 'template'): string;
21
- /** Creates or resumes the machine VM of the saved machine version; the first one gets this computer's agent config. */
22
+ /** `shard template delete` (0.20.1+): what went, in one line. A binding-only delete has no template. */
23
+ export declare function deletedLine(deleted: AgentFilesystemDeleted): string;
24
+ /**
25
+ * The VM `shard machine save` saves: the machine VM of the version in use, else the one the last save came from. A
26
+ * deleted one is not there (0.20.2+: the machine row offers Save again only while it is).
27
+ */
28
+ export declare function machineSaveSource(ctx: Ctx, state: AgentFilesystem): Promise<Workspace | null>;
22
29
  /** The account's machine VM, created or resumed; `sync: false` skips the first-open config sync (an account sign-in). */
23
30
  export declare function openMachine(ctx: Ctx, state?: AgentFilesystem, opts?: {
24
31
  sync?: boolean;
@@ -1,6 +1,8 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { FilesystemSaveFailedError, FilesystemSaveTimeoutError, SavePhases, ShardfluxApiError, savePreflightText, saveProgressText } from '@shardflux/sdk';
1
3
  import { codingAgentShell } from "./agent-setup.js";
2
4
  import { waitTimeout } from "./commands.js";
3
- import { UsageError } from "./errors.js";
5
+ import { NotFoundError, UsageError } from "./errors.js";
4
6
  import { projectIdentity, normalizeRemote } from "./project-identity.js";
5
7
  import { AGENT_HANDLERS } from "./agent-commands.js";
6
8
  import { readSetupProgress } from "./assisted-setup.js";
@@ -10,9 +12,13 @@ function remote() {
10
12
  const project = projectIdentity('./');
11
13
  return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
12
14
  }
13
- /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
15
+ /**
16
+ * One line per status: what is in use and what is saving or failed, its platform base and setup cache. A project
17
+ * template unused for 30 days (rebase_deferred, 0.20.1+) follows the machine when the next conversation starts.
18
+ */
14
19
  export function filesystemLine(state) {
15
- const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${state.rebase_pending ? ', rebase pending' : ''}` : '';
20
+ const rebase = state.rebase_deferred ? ', follows your machine on next use' : state.rebase_pending ? ', rebase pending' : '';
21
+ const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${rebase}` : '';
16
22
  return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
17
23
  }
18
24
  function usageLine(state) {
@@ -29,21 +35,8 @@ function output(ctx, state) {
29
35
  ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
30
36
  return 0;
31
37
  }
32
- /** "about 40 s" / "about 3 min": the queue's estimate, rounded the way people say it. */
33
- export function aboutWait(seconds) {
34
- if (seconds < 90)
35
- return `about ${Math.max(10, Math.round(seconds / 10) * 10)} s`;
36
- return `about ${Math.round(seconds / 60)} min`;
37
- }
38
- /** One line for a build that waits for a slot (0.20.0+): "Queued behind 3 builds · about 40 s" (running ones + those ahead). */
39
- export function queueLine(q) {
40
- const behind = q.slots + q.position - 1;
41
- return `Queued behind ${behind} build${behind === 1 ? '' : 's'} · ${aboutWait(q.expected_wait_seconds)}`;
42
- }
43
- /** How often a waited save reads its build's place in the queue. */
44
- export const QUEUE_POLL_MS = 2_500;
45
38
  /** The pending build's queue, if it waits for a slot; nothing when the API predates it or the build cannot be read. */
46
- async function pendingQueue(ctx, state) {
39
+ export async function pendingQueue(ctx, state) {
47
40
  if (!state.pending)
48
41
  return undefined;
49
42
  try {
@@ -54,53 +47,74 @@ async function pendingQueue(ctx, state) {
54
47
  return undefined;
55
48
  }
56
49
  }
50
+ /** Within one phase of a waited save, a changed counter line is printed at most this often (0.20.2+). */
51
+ export const SAVE_PROGRESS_LINE_MS = 30_000;
57
52
  /**
58
- * Save output: one line naming the version being saved (and its place in the queue when the plan's build slots are in
59
- * use, 0.20.0+), or with --wait the saved version (progress on stderr, the queue line updating while it waits).
53
+ * Save output: the preflight line (what the save copies and about how long it takes, 0.20.2+), then one line naming
54
+ * the version being saved and its state (its place in the queue while the plan's build slots are in use, 0.20.0+), or
55
+ * with --wait one stderr line per phase with its counters, and the saved version. --json: one document at the end
56
+ * (`phases` with --wait, `preflight` when known), the progress as {"event":"save_progress"} lines on stderr.
60
57
  */
61
- async function saved(ctx, values, save, level) {
58
+ async function saved(ctx, values, save, preflight, level) {
62
59
  if (values.wait !== true && values.timeout !== undefined)
63
60
  throw new UsageError('--timeout needs --wait');
64
- const stop = new AbortController();
65
- let watcher;
66
- // While the save waits, its build's place in the queue: one line each time it changes.
67
- const watchQueue = (state) => {
68
- if (watcher || !state.pending)
61
+ const pf = await preflight();
62
+ if (pf && !ctx.json)
63
+ progress(ctx, savePreflightText(pf));
64
+ else if (pf && values.wait === true)
65
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_preflight', ...pf })}\n`);
66
+ const phases = new SavePhases();
67
+ // The first line names the version; later lines say what changed: a new phase or place in the queue at once, newer
68
+ // counters within a phase at most every SAVE_PROGRESS_LINE_MS.
69
+ let announced = false;
70
+ let last = { line: '', milestone: '', at: 0 };
71
+ const onProgress = (p, final = false) => {
72
+ phases.update(p);
73
+ const line = saveProgressText(p);
74
+ const milestone = JSON.stringify([p.state, p.progress?.phase ?? null, p.queue?.position ?? null, p.queue?.slots ?? null]);
75
+ const now = Date.now();
76
+ if (announced && (line === last.line || (!final && milestone === last.milestone && now - last.at < SAVE_PROGRESS_LINE_MS)))
69
77
  return;
70
- watcher = (async () => {
71
- let last = '';
72
- while (!stop.signal.aborted) {
73
- const q = await pendingQueue(ctx, state);
74
- const line = q ? queueLine(q) : '';
75
- if (line && line !== last && !stop.signal.aborted)
76
- progress(ctx, line);
77
- last = line;
78
- await new Promise(resolve => {
79
- const timer = setTimeout(resolve, QUEUE_POLL_MS);
80
- stop.signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
81
- });
82
- }
83
- })();
78
+ if (ctx.json)
79
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_progress', ...p })}\n`);
80
+ else
81
+ progress(ctx, announced ? line : `${level}: saving version ${p.version} · ${line}`);
82
+ announced = true;
83
+ last = { line, milestone, at: now };
84
84
  };
85
- const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onChange: (state) => {
86
- if (state.state === 'building' && state.pending) {
87
- progress(ctx, `Saving ${level} version ${state.pending.version}…`);
88
- watchQueue(state);
85
+ const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onProgress, onChange: (state) => {
86
+ // An API that does not show the build: the version alone.
87
+ if (!announced && state.state === 'building' && state.pending) {
88
+ // The filesystem says saving; the unavailable build view cannot tell us queued versus running.
89
+ if (ctx.json)
90
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_started', level, version: state.pending.version, build_id: state.pending.build_id })}\n`);
91
+ else
92
+ progress(ctx, `${level}: saving version ${state.pending.version}`);
93
+ announced = true;
89
94
  }
90
95
  } } : undefined;
96
+ const document = (state, extra = {}) => ({ ...state, ...extra, ...(wait ? { phases: phases.list() } : {}), ...(pf ? { preflight: pf } : {}) });
91
97
  let state;
92
98
  try {
93
99
  state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
94
100
  }
95
- finally {
96
- stop.abort();
97
- await watcher;
101
+ catch (err) {
102
+ if ((err instanceof FilesystemSaveTimeoutError || err instanceof FilesystemSaveFailedError) && err.progress)
103
+ onProgress(err.progress, true);
104
+ // --json: stdout still gets its one document, with how far the save got (still building at a timeout).
105
+ if (ctx.json && (err instanceof FilesystemSaveTimeoutError || err instanceof FilesystemSaveFailedError)) {
106
+ if (err instanceof FilesystemSaveFailedError)
107
+ phases.end();
108
+ ctx.io.stdout.write(`${JSON.stringify(document(err.state))}\n`);
109
+ }
110
+ throw err;
98
111
  }
112
+ phases.end();
99
113
  const queue = wait ? undefined : await pendingQueue(ctx, state);
100
114
  if (ctx.json)
101
- return output(ctx, queue ? { ...state, queue } : state);
115
+ return output(ctx, document(state, queue ? { queue } : {}));
102
116
  const moved = movedLine(state, level);
103
- const queued = queue ? `; ${queueLine(queue).replace(/^Q/, 'q')}` : '';
117
+ const queued = queue ? `; ${saveProgressText({ version: state.pending?.version ?? 0, build_id: state.pending?.build_id ?? '', state: 'queued', queue })}` : '';
104
118
  ctx.io.stdout.write(wait ? `${level}: version ${state.version} saved${moved}\n` : state.pending ? `${filesystemLine(state)}${queued}; shard ${level} shows when it is ready${moved}\n` : `${filesystemLine(state)}${moved}\n`);
105
119
  return 0;
106
120
  }
@@ -111,6 +125,12 @@ export function movedLine(state, level) {
111
125
  return '';
112
126
  return `; ${moved.length} large tool folder${moved.length === 1 ? '' : 's'} went into the ${level} image (${moved.join(', ')}); they keep working at the same paths`;
113
127
  }
128
+ /** `shard template delete` (0.20.1+): what went, in one line. A binding-only delete has no template. */
129
+ export function deletedLine(deleted) {
130
+ const versions = deleted.template ? ` (${deleted.template.versions} saved version${deleted.template.versions === 1 ? '' : 's'})` : '';
131
+ const builds = deleted.canceled_builds > 0 ? `, stopped ${deleted.canceled_builds} build${deleted.canceled_builds === 1 ? '' : 's'}` : '';
132
+ return `template: deleted${versions}${builds}; new conversations start from your machine until setup saves a new one`;
133
+ }
114
134
  const rollbackTo = (value) => {
115
135
  if (value === undefined)
116
136
  return undefined;
@@ -119,7 +139,17 @@ const rollbackTo = (value) => {
119
139
  throw new UsageError('--to must be a saved version number');
120
140
  return n;
121
141
  };
122
- /** Creates or resumes the machine VM of the saved machine version; the first one gets this computer's agent config. */
142
+ /**
143
+ * The VM `shard machine save` saves: the machine VM of the version in use, else the one the last save came from. A
144
+ * deleted one is not there (0.20.2+: the machine row offers Save again only while it is).
145
+ */
146
+ export async function machineSaveSource(ctx, state) {
147
+ const cloud = ctx.cloud();
148
+ const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { includeDeleted: false, signal: ctx.signal }) ??
149
+ (state.workspace_id ? await cloud.workspaces.get(state.workspace_id).catch((e) => { if (e instanceof ShardfluxApiError && e.status === 404)
150
+ return null; throw e; }) : null);
151
+ return ws && !ws.data.deleted_at ? ws : null;
152
+ }
123
153
  /** The account's machine VM, created or resumed; `sync: false` skips the first-open config sync (an account sign-in). */
124
154
  export async function openMachine(ctx, state, opts = {}) {
125
155
  const cloud = ctx.cloud();
@@ -161,12 +191,10 @@ export const FILESYSTEM_HANDLERS = {
161
191
  },
162
192
  async 'machine save'(ctx, values) {
163
193
  const cloud = ctx.cloud();
164
- const state = await cloud.machine.get({ signal: ctx.signal });
165
- const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { signal: ctx.signal }) ??
166
- (state.workspace_id ? await cloud.workspaces.get(state.workspace_id) : null);
194
+ const ws = await machineSaveSource(ctx, await cloud.machine.get({ signal: ctx.signal }));
167
195
  if (!ws)
168
196
  throw new UsageError('Open your machine and change it first, then run shard machine save', 'shard machine in a terminal, or shard machine open --json for an agent (0.17.1+)');
169
- return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), 'machine');
197
+ return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), () => cloud.machine.preflight({ workspaceId: ws.id }, { signal: ctx.signal }), 'machine');
170
198
  },
171
199
  async 'machine rollback'(ctx, values) {
172
200
  return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
@@ -185,9 +213,35 @@ export const FILESYSTEM_HANDLERS = {
185
213
  const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
186
214
  if (!ws)
187
215
  throw new UsageError('Run shard ./ for this project before saving its template');
188
- return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), 'template');
216
+ return saved(ctx, values, options => ctx.cloud().templates.saveProject({ repoRemote, workspaceId: ws.id }, options), () => ctx.cloud().templates.preflightProject({ workspaceId: ws.id }, { signal: ctx.signal }), 'template');
189
217
  },
190
218
  async 'template rollback'(ctx, values) {
191
219
  return output(ctx, await ctx.cloud().templates.rollbackProject({ repoRemote: remote().repoRemote, to: rollbackTo(values.to) }, { signal: ctx.signal }));
192
220
  },
221
+ /** `shard template delete` (0.20.1+): asks first in a terminal; elsewhere it needs --yes. */
222
+ async 'template delete'(ctx, values) {
223
+ const { repoRemote } = remote();
224
+ if (values.yes !== true) {
225
+ if (!ctx.input.interactive)
226
+ throw new UsageError(`refusing to delete without --yes (the project template of ${repoRemote} and its saved versions are deleted; new conversations start from your machine until setup saves a new one)`, 'run: shard template delete --yes');
227
+ const answer = (await ctx.input.answer(`Delete the project template for ${repoRemote}? Its saved versions go; new conversations start from your machine until setup saves a new one. [y/N] `))?.trim() ?? '';
228
+ if (!/^y(es)?$/i.test(answer)) {
229
+ ctx.io.stderr.write('Kept the project template.\n');
230
+ return 0;
231
+ }
232
+ }
233
+ let deleted;
234
+ try {
235
+ // One key per run: a retry after a dropped connection replays the same delete.
236
+ deleted = await ctx.cloud().templates.deleteProject({ repoRemote }, { idempotencyKey: randomUUID(), signal: ctx.signal });
237
+ }
238
+ catch (err) {
239
+ if (err instanceof ShardfluxApiError && err.status === 404 && err.details?.reason === 'filesystem_not_found') {
240
+ throw new NotFoundError(`template: nothing saved yet for ${repoRemote}, so there is nothing to delete`, 'setup saves it after shard ./, or run shard template save', { reason: 'filesystem_not_found', repo_remote: repoRemote });
241
+ }
242
+ throw err;
243
+ }
244
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(deleted) + '\n' : `${deletedLine(deleted)}\n`);
245
+ return 0;
246
+ },
193
247
  };
package/dist/format.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, SpendPolicy, SuspendRequest, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, TemplateVersion, WorkspaceChange, WorkspaceSecretBindings, WorkspaceMemory, WorkspaceView, MemoryGrow, BurstSummary, ResizeResult, ExposePortResult, ExposedPort } from '@shardflux/sdk';
1
+ import type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, SpendPolicy, SuspendRequest, SuspendProgress, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, TemplateVersion, WorkspaceChange, WorkspaceSecretBindings, WorkspaceMemory, WorkspaceView, MemoryGrow, BurstSummary, ResizeResult, ExposePortResult, ExposedPort } from '@shardflux/sdk';
2
2
  import type { ErrorInfo } from './errors.js';
3
3
  /** A workspace as the port commands name it. */
4
4
  interface PortWorkspace {
@@ -70,6 +70,7 @@ export declare function operationTable(ops: readonly Operation[]): string;
70
70
  */
71
71
  export declare function durableText(op: Operation): string;
72
72
  export declare function operationDetail(op: Operation): string;
73
+ export declare function suspendProgressText(p: SuspendProgress): string;
73
74
  export declare function sessionTable(sessions: readonly AgentSession[]): string;
74
75
  /** `files stat`: one path's metadata; the revision only when requested and returned. */
75
76
  export declare function fileInfoText(f: FileInfo): string;
package/dist/format.js CHANGED
@@ -300,6 +300,15 @@ export function operationDetail(op) {
300
300
  ['updated', time(op.updated_at)],
301
301
  ['completed', time(op.completed_at)],
302
302
  ['error', err ? `${scalar(err.code)}${err.message ? `: ${scalar(err.message)}` : ''}` : '-'],
303
+ ]) + (op.progress ? `\n${suspendProgressText(op.progress)}` : '');
304
+ }
305
+ export function suspendProgressText(p) {
306
+ return kv([
307
+ ['operation', p.operation_id], ['phase', p.phase], ['operation state', p.state], ['workspace state', p.workspace_state],
308
+ ['cancelable', p.cancelable ? 'yes' : 'no'], ['progress supported', p.supported ? 'yes' : 'unavailable'], ['next action', p.safe_action],
309
+ ...(p.phase_started_at ? [['phase started', p.phase_started_at]] : []),
310
+ ...(p.deadline_at ? [['host deadline', p.deadline_at]] : []),
311
+ ...(p.accepted === true ? [['cancel request', 'accepted; use the operation state to confirm recovery']] : []),
303
312
  ]);
304
313
  }
305
314
  export function sessionTable(sessions) {
@@ -546,7 +555,7 @@ export function feedbackCommand(e) {
546
555
  if (SHELL_SAFE.test(code))
547
556
  parts.push(`--error-code ${code}`);
548
557
  parts.push('"<what you expected>"');
549
- return parts.join(' ');
558
+ return `${parts.join(' ')} # preview only; show the message and context, then use --yes only after explicit user authorization`;
550
559
  }
551
560
  /**
552
561
  * One stderr line: `error: <message> [code=... status=... request_id=...]` plus a hint when there is one, and the
package/dist/handlers.js CHANGED
@@ -24,4 +24,6 @@ import { ACCOUNT_POOL_HANDLERS } from "./account-pool.js";
24
24
  import { MOVE_HANDLERS } from "./move.js";
25
25
  import { AGENT_CONFIG_HANDLERS } from "./agent-config-sync.js";
26
26
  import { MACHINE_SETUP_HANDLERS } from "./machine-setup.js";
27
- export const HANDLERS = { ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS };
27
+ import { SETTINGS_HANDLERS } from "./client-settings.js";
28
+ import { PROJECT_PREPARATION_HANDLERS } from "./project-preparation.js";
29
+ export const HANDLERS = { ...PROJECT_PREPARATION_HANDLERS, ...MOVE_HANDLERS, ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...ACCOUNT_POOL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...MACHINE_SETUP_HANDLERS, ...SETTINGS_HANDLERS, ...UPDATE_HANDLERS, ...AGENT_CONFIG_HANDLERS };
@@ -30,6 +30,14 @@ export declare const INBOX_MAX_CHARS = 65536;
30
30
  * options. The guest's own first-message delivery reads the screen the same way (coding 39+).
31
31
  */
32
32
  export declare function inputBoxReady(tail: string, glyph: string): boolean;
33
+ /**
34
+ * The first prompt typed by the VM's own guest (0.21.1+, with a guest that has it): `sf-agent conversation first-prompt` waits for
35
+ * the input box, pastes, waits for the paste to show and presses Enter inside the VM, with the same checks as
36
+ * `submitInitialPrompt`, in one request from here instead of an attachment and three round trips. Nothing was typed
37
+ * when the result is `unsupported` (an older guest without the command: this terminal types it) or `not_ready` (the
38
+ * harness never showed its input box within the guest's wait: the conversation's inbox takes it, where there is one).
39
+ */
40
+ export declare function guestFirstPrompt(cell: Pick<CellClient, 'exec'>, ptyId: string, app: 'claude' | 'codex', prompt: string, workspaceKey: string, signal: AbortSignal): Promise<'submitted' | 'unsupported' | 'not_ready'>;
33
41
  /**
34
42
  * Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay.
35
43
  * When nothing was typed yet and the terminal never became ready, `queue` hands the prompt to the conversation's guest