@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -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-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  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-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -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
@@ -1,4 +1,4 @@
1
- import type { CheckoutStatus, ExecSession, ExecutionResult } from '@shardflux/sdk';
1
+ import type { RateLimitInfo, CheckoutStatus, ExecSession, ExecutionResult } from '@shardflux/sdk';
2
2
  export declare const EXIT: {
3
3
  readonly ok: 0;
4
4
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -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
@@ -140,6 +145,7 @@ export interface ErrorInfo {
140
145
  execution_id?: string;
141
146
  /** The refusal's Retry-After in seconds (0.9.0+), e.g. 429 quota_exceeded from the cell gateway. */
142
147
  retry_after_seconds?: number;
148
+ rate_limit?: RateLimitInfo;
143
149
  }
144
150
  /**
145
151
  * Removes anything shaped like a Shardflux API key or user session token from text, and each of the given secrets
@@ -147,6 +153,27 @@ export interface ErrorInfo {
147
153
  */
148
154
  export declare function redact(text: string, ...secrets: (string | null | undefined)[]): string;
149
155
  /** The `hint:` a CLI error carries itself, if any. */
156
+ /**
157
+ * 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
158
+ * again; the build's log says what happened.
159
+ */
160
+ export declare function saveRecovery(level: 'machine' | 'template', inUse: number, buildId?: string, failure?: SaveFailure): string;
161
+ /** What a failed save reports about its build (SDK 0.26.0+ on an API that carries them). */
162
+ export interface SaveFailure {
163
+ code?: string | undefined;
164
+ retryable?: boolean | undefined;
165
+ details?: Record<string, unknown> | undefined;
166
+ }
167
+ /**
168
+ * A save larger than the plan's workspace disk (0.21.1+): resource_limit with details.limit template_org_bytes_max;
169
+ * `limit` is the plan's workspace disk and `actual` the version's size, in bytes.
170
+ */
171
+ export declare function planDiskLimit(failure: SaveFailure | undefined): {
172
+ limit: number;
173
+ actual: number;
174
+ } | undefined;
175
+ /** Bytes as the plan names a disk: `2 GiB`, `2.5 GiB`. */
176
+ export declare const gib: (bytes: number) => string;
150
177
  export declare function ownHint(err: unknown): string | undefined;
151
178
  export declare function describeError(err: unknown): ErrorInfo;
152
179
  /** Maps an error to the documented exit code. */
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { budgetHint, CheckoutTimeoutError, DurabilityLostError, ExecStartError, FilesystemSaveFailedError, FilesystemSaveTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -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,8 +183,38 @@ 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
- return err instanceof HintedError ? err.hint : undefined;
215
+ if (err instanceof ShardfluxApiError && err.reason === 'codex_chatgpt_login_required')
216
+ return 'Sign in to Codex with ChatGPT using codex login, then copy your Codex login with shard setup again.';
217
+ return budgetHint(err) ?? (err instanceof HintedError ? err.hint : undefined);
180
218
  }
181
219
  function isNamed(err, name) {
182
220
  return typeof err === 'object' && err !== null && err.name === name;
@@ -202,6 +240,7 @@ export function describeError(err) {
202
240
  ...(err.operationId ? { operation_id: err.operationId } : {}),
203
241
  ...(err.details ? { details: err.details } : {}),
204
242
  source: err.source,
243
+ ...(err.rateLimit ? { rate_limit: err.rateLimit } : {}),
205
244
  ...(err.retryAfterSeconds !== undefined ? { retry_after_seconds: err.retryAfterSeconds } : {}),
206
245
  };
207
246
  }
@@ -260,10 +299,14 @@ export function describeError(err) {
260
299
  if (err instanceof TemplateBuildTimeoutError)
261
300
  return { code: 'timeout', message: `${err.message}; follow it with: shard templates builds get ${err.build.id}`, last_state: err.build.state };
262
301
  // 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 } };
302
+ // 0.20.2+: details.build_id, and a failure's recovery as `fix` (the hint line).
303
+ // 0.21.1+: the build's failure (error_code and its details, e.g. limit/value/actual) and whether the same save can
304
+ // succeed (retryable false: larger than the plan's workspace disk, or another refusal that repeats until the VM changes).
305
+ if (err instanceof FilesystemSaveFailedError) {
306
+ 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 } : {}) } };
307
+ }
265
308
  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 } };
309
+ 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
310
  if (err instanceof TemplateUploadError)
268
311
  return { code: 'upload_failed', message: err.message, ...(err.status ? { status: err.status } : {}), details: { sha256: err.sha256, ...(err.code ? { storage_code: err.code } : {}) } };
269
312
  if (err instanceof AuthConfigError)
@@ -271,7 +314,7 @@ export function describeError(err) {
271
314
  if (err instanceof MfaPendingError)
272
315
  return { code: 'mfa_required', message: err.message };
273
316
  if (err instanceof NotFoundError)
274
- return { code: 'not_found', message: err.message };
317
+ return { code: 'not_found', message: err.message, ...(err.details ? { details: err.details } : {}) };
275
318
  if (err instanceof MachineSetupError)
276
319
  return { code: 'machine_setup_failed', message: err.message, retryable: true };
277
320
  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,18 +1,46 @@
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";
7
9
  import { syncAgentConfig } from "./agent-config-sync.js";
8
10
  import { progress } from "./progress.js";
11
+ import { getConversation, localConversations } from "./conversations.js";
9
12
  function remote() {
10
13
  const project = projectIdentity('./');
11
14
  return { project, repoRemote: project.remote ?? normalizeRemote(`file://${project.folder}`) };
12
15
  }
13
- /** One line per status: what is in use and what is saving or failed, its platform base and setup cache. */
16
+ /**
17
+ * The VM this computer saves a project's template from (0.23.0+): the newest conversation this checkout started whose
18
+ * VM is there now, else the project's workspace (`<project>/main`, setups from before conversations). Each
19
+ * conversation's VM is read from the API: a restore by any client moves it to a new VM. In a VM, `shard template save`
20
+ * saves that VM itself.
21
+ */
22
+ async function templateSource(ctx, project) {
23
+ const gone = (error) => { if (error instanceof ShardfluxApiError && error.status === 404)
24
+ return undefined; throw error; };
25
+ const started = Object.values(localConversations(ctx)).filter(r => r.folder === project.folder && r.api_url === ctx.apiUrl)
26
+ .sort((a, b) => Date.parse(b.conversation.created_at) - Date.parse(a.conversation.created_at));
27
+ for (const r of started) {
28
+ const c = await getConversation(ctx, r.conversation.id).catch(gone);
29
+ if (!c || c.ended_at || !c.workspace_id)
30
+ continue;
31
+ const ws = await ctx.cloud().workspaces.get(c.workspace_id, { signal: ctx.signal }).catch(gone);
32
+ if (ws && !ws.data.deleted_at)
33
+ return ws;
34
+ }
35
+ return await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal }) ?? undefined;
36
+ }
37
+ /**
38
+ * One line per status: what is in use and what is saving or failed, its platform base and setup cache. A project
39
+ * template unused for 30 days (rebase_deferred, 0.20.1+) follows the machine when the next conversation starts.
40
+ */
14
41
  export function filesystemLine(state) {
15
- const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${state.rebase_pending ? ', rebase pending' : ''}` : '';
42
+ const rebase = state.rebase_deferred ? ', follows your machine on next use' : state.rebase_pending ? ', rebase pending' : '';
43
+ const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${rebase}` : '';
16
44
  return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
17
45
  }
18
46
  function usageLine(state) {
@@ -29,21 +57,8 @@ function output(ctx, state) {
29
57
  ctx.io.stdout.write(ctx.json ? JSON.stringify(state) + '\n' : `${filesystemLine(state)}\n`);
30
58
  return 0;
31
59
  }
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
60
  /** 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) {
61
+ export async function pendingQueue(ctx, state) {
47
62
  if (!state.pending)
48
63
  return undefined;
49
64
  try {
@@ -54,53 +69,74 @@ async function pendingQueue(ctx, state) {
54
69
  return undefined;
55
70
  }
56
71
  }
72
+ /** Within one phase of a waited save, a changed counter line is printed at most this often (0.20.2+). */
73
+ export const SAVE_PROGRESS_LINE_MS = 30_000;
57
74
  /**
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).
75
+ * Save output: the preflight line (what the save copies and about how long it takes, 0.20.2+), then one line naming
76
+ * 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
77
+ * with --wait one stderr line per phase with its counters, and the saved version. --json: one document at the end
78
+ * (`phases` with --wait, `preflight` when known), the progress as {"event":"save_progress"} lines on stderr.
60
79
  */
61
- async function saved(ctx, values, save, level) {
80
+ async function saved(ctx, values, save, preflight, level) {
62
81
  if (values.wait !== true && values.timeout !== undefined)
63
82
  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)
83
+ const pf = await preflight();
84
+ if (pf && !ctx.json)
85
+ progress(ctx, savePreflightText(pf));
86
+ else if (pf && values.wait === true)
87
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_preflight', ...pf })}\n`);
88
+ const phases = new SavePhases();
89
+ // The first line names the version; later lines say what changed: a new phase or place in the queue at once, newer
90
+ // counters within a phase at most every SAVE_PROGRESS_LINE_MS.
91
+ let announced = false;
92
+ let last = { line: '', milestone: '', at: 0 };
93
+ const onProgress = (p, final = false) => {
94
+ phases.update(p);
95
+ const line = saveProgressText(p);
96
+ const milestone = JSON.stringify([p.state, p.progress?.phase ?? null, p.queue?.position ?? null, p.queue?.slots ?? null]);
97
+ const now = Date.now();
98
+ if (announced && (line === last.line || (!final && milestone === last.milestone && now - last.at < SAVE_PROGRESS_LINE_MS)))
69
99
  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
- })();
100
+ if (ctx.json)
101
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_progress', ...p })}\n`);
102
+ else
103
+ progress(ctx, announced ? line : `${level}: saving version ${p.version} · ${line}`);
104
+ announced = true;
105
+ last = { line, milestone, at: now };
84
106
  };
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);
107
+ const wait = values.wait === true ? { timeoutMs: waitTimeout(values, 600_000), onProgress, onChange: (state) => {
108
+ // An API that does not show the build: the version alone.
109
+ if (!announced && state.state === 'building' && state.pending) {
110
+ // The filesystem says saving; the unavailable build view cannot tell us queued versus running.
111
+ if (ctx.json)
112
+ ctx.io.stderr.write(`${JSON.stringify({ event: 'save_started', level, version: state.pending.version, build_id: state.pending.build_id })}\n`);
113
+ else
114
+ progress(ctx, `${level}: saving version ${state.pending.version}`);
115
+ announced = true;
89
116
  }
90
117
  } } : undefined;
118
+ const document = (state, extra = {}) => ({ ...state, ...extra, ...(wait ? { phases: phases.list() } : {}), ...(pf ? { preflight: pf } : {}) });
91
119
  let state;
92
120
  try {
93
121
  state = await save({ signal: ctx.signal, ...(wait ? { wait } : {}) });
94
122
  }
95
- finally {
96
- stop.abort();
97
- await watcher;
123
+ catch (err) {
124
+ if ((err instanceof FilesystemSaveTimeoutError || err instanceof FilesystemSaveFailedError) && err.progress)
125
+ onProgress(err.progress, true);
126
+ // --json: stdout still gets its one document, with how far the save got (still building at a timeout).
127
+ if (ctx.json && (err instanceof FilesystemSaveTimeoutError || err instanceof FilesystemSaveFailedError)) {
128
+ if (err instanceof FilesystemSaveFailedError)
129
+ phases.end();
130
+ ctx.io.stdout.write(`${JSON.stringify(document(err.state))}\n`);
131
+ }
132
+ throw err;
98
133
  }
134
+ phases.end();
99
135
  const queue = wait ? undefined : await pendingQueue(ctx, state);
100
136
  if (ctx.json)
101
- return output(ctx, queue ? { ...state, queue } : state);
137
+ return output(ctx, document(state, queue ? { queue } : {}));
102
138
  const moved = movedLine(state, level);
103
- const queued = queue ? `; ${queueLine(queue).replace(/^Q/, 'q')}` : '';
139
+ const queued = queue ? `; ${saveProgressText({ version: state.pending?.version ?? 0, build_id: state.pending?.build_id ?? '', state: 'queued', queue })}` : '';
104
140
  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
141
  return 0;
106
142
  }
@@ -111,6 +147,12 @@ export function movedLine(state, level) {
111
147
  return '';
112
148
  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
149
  }
150
+ /** `shard template delete` (0.20.1+): what went, in one line. A binding-only delete has no template. */
151
+ export function deletedLine(deleted) {
152
+ const versions = deleted.template ? ` (${deleted.template.versions} saved version${deleted.template.versions === 1 ? '' : 's'})` : '';
153
+ const builds = deleted.canceled_builds > 0 ? `, stopped ${deleted.canceled_builds} build${deleted.canceled_builds === 1 ? '' : 's'}` : '';
154
+ return `template: deleted${versions}${builds}; new conversations start from your machine until setup saves a new one`;
155
+ }
114
156
  const rollbackTo = (value) => {
115
157
  if (value === undefined)
116
158
  return undefined;
@@ -119,7 +161,17 @@ const rollbackTo = (value) => {
119
161
  throw new UsageError('--to must be a saved version number');
120
162
  return n;
121
163
  };
122
- /** Creates or resumes the machine VM of the saved machine version; the first one gets this computer's agent config. */
164
+ /**
165
+ * The VM `shard machine save` saves: the machine VM of the version in use, else the one the last save came from. A
166
+ * deleted one is not there (0.20.2+: the machine row offers Save again only while it is).
167
+ */
168
+ export async function machineSaveSource(ctx, state) {
169
+ const cloud = ctx.cloud();
170
+ const ws = await cloud.workspaces.findByKey(`machine/${state.organization_id}/v${state.version}`, { includeDeleted: false, signal: ctx.signal }) ??
171
+ (state.workspace_id ? await cloud.workspaces.get(state.workspace_id).catch((e) => { if (e instanceof ShardfluxApiError && e.status === 404)
172
+ return null; throw e; }) : null);
173
+ return ws && !ws.data.deleted_at ? ws : null;
174
+ }
123
175
  /** The account's machine VM, created or resumed; `sync: false` skips the first-open config sync (an account sign-in). */
124
176
  export async function openMachine(ctx, state, opts = {}) {
125
177
  const cloud = ctx.cloud();
@@ -161,12 +213,10 @@ export const FILESYSTEM_HANDLERS = {
161
213
  },
162
214
  async 'machine save'(ctx, values) {
163
215
  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);
216
+ const ws = await machineSaveSource(ctx, await cloud.machine.get({ signal: ctx.signal }));
167
217
  if (!ws)
168
218
  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');
219
+ return saved(ctx, values, options => cloud.machine.save({ workspaceId: ws.id }, options), () => cloud.machine.preflight({ workspaceId: ws.id }, { signal: ctx.signal }), 'machine');
170
220
  },
171
221
  async 'machine rollback'(ctx, values) {
172
222
  return output(ctx, await ctx.cloud().machine.rollback({ to: rollbackTo(values.to) }, { signal: ctx.signal }));
@@ -174,7 +224,7 @@ export const FILESYSTEM_HANDLERS = {
174
224
  async template(ctx) {
175
225
  const { project, repoRemote } = remote();
176
226
  const state = await ctx.cloud().templates.getProject(repoRemote, { signal: ctx.signal });
177
- const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
227
+ const ws = await templateSource(ctx, project);
178
228
  const progress = ws ? await readSetupProgress(ctx, ws.data) : undefined;
179
229
  const attachments = ws ? await ctx.cloud().volumes.listAttached(ws.id) : undefined;
180
230
  const mounted = attachments?.data.some((a) => a.mount_path === '/mnt/shardflux-cache' && a.mounted && a.volume.name === 'shardflux-setup-cache' && a.volume.org_shared);
@@ -182,12 +232,38 @@ export const FILESYSTEM_HANDLERS = {
182
232
  },
183
233
  async 'template save'(ctx, values) {
184
234
  const { project, repoRemote } = remote();
185
- const ws = await ctx.cloud().workspaces.findByKey(`${project.slug}/main`, { signal: ctx.signal });
235
+ const ws = await templateSource(ctx, project);
186
236
  if (!ws)
187
- 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');
237
+ throw new UsageError('Start a conversation in this project with shard ./ before saving its template', 'or run shard template save in a conversation\'s VM');
238
+ 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
239
  },
190
240
  async 'template rollback'(ctx, values) {
191
241
  return output(ctx, await ctx.cloud().templates.rollbackProject({ repoRemote: remote().repoRemote, to: rollbackTo(values.to) }, { signal: ctx.signal }));
192
242
  },
243
+ /** `shard template delete` (0.20.1+): asks first in a terminal; elsewhere it needs --yes. */
244
+ async 'template delete'(ctx, values) {
245
+ const { repoRemote } = remote();
246
+ if (values.yes !== true) {
247
+ if (!ctx.input.interactive)
248
+ 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');
249
+ 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() ?? '';
250
+ if (!/^y(es)?$/i.test(answer)) {
251
+ ctx.io.stderr.write('Kept the project template.\n');
252
+ return 0;
253
+ }
254
+ }
255
+ let deleted;
256
+ try {
257
+ // One key per run: a retry after a dropped connection replays the same delete.
258
+ deleted = await ctx.cloud().templates.deleteProject({ repoRemote }, { idempotencyKey: randomUUID(), signal: ctx.signal });
259
+ }
260
+ catch (err) {
261
+ if (err instanceof ShardfluxApiError && err.status === 404 && err.details?.reason === 'filesystem_not_found') {
262
+ 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 });
263
+ }
264
+ throw err;
265
+ }
266
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(deleted) + '\n' : `${deletedLine(deleted)}\n`);
267
+ return 0;
268
+ },
193
269
  };
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