@shardflux/cli 0.2.0 → 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ Every command and option the README shows is available from the version named here. Below 1.0, a minor release may
4
+ break compatibility; breaking changes are marked **Breaking**.
5
+
6
+ ## 0.3.0 (unreleased)
7
+
8
+ Needs `@shardflux/sdk` 0.6.0 or later.
9
+
10
+ ### Where the time went: `--timing`
11
+
12
+ - `--timing` on `workspaces open`, `workspaces suspend|resume|fork|delete|close|reset` (with `--wait`, the whole wait),
13
+ `operations wait` and `templates draft open|test|state|discard` prints the SDK's lifecycle timing on stderr after
14
+ the call: the client phases (the request, each operation state seen while waiting with the server's reason, such as
15
+ `capacity_pending (no_ready_host)`), the operation's own server timing (queued, ran, total; start or resume path),
16
+ the time spent outside the server, and retries. It is printed when the call fails, times out or is interrupted
17
+ too, before the error, and the exit code is unchanged.
18
+ - `workspaces exec`, `files read|write|ls` and `workspaces changes` take `--timing` too: it times the wake, when the
19
+ command had to wake a suspended workspace.
20
+ - With `--json`, the timing is a `timing` field of the JSON on stdout instead (snake_case; `null` when the command
21
+ made no lifecycle call), and of the error document on stderr (`{"error": {...}, "timing": {...}}`). Existing fields
22
+ are unchanged.
23
+
24
+ ### Waits are the SDK's
25
+
26
+ - `workspaces open` waits with the SDK's waited open: the API holds the request until the workspace is ready
27
+ (`Prefer: wait`, up to 20 s at a time) instead of the CLI polling and then reading the workspace. The ready
28
+ workspace comes with its first tool token, so the open is listed as an agent session under the CLI's label (as a
29
+ reopen of a running workspace already was). Output, `--timeout` and exit codes are unchanged.
30
+ - Lifecycle commands with `--wait` use the SDK's `wait`. Output and exit codes are unchanged, except that
31
+ `workspaces fork --wait` prints the new workspace as it is after the fork, not as it was requested.
32
+ - Ctrl-C ends a held open at once (exit 130); the open continues server side, and opening the key again keeps
33
+ waiting.
34
+
35
+ ## 0.2.0 (2026-09-28)
36
+
37
+ Secrets (`secrets ...`, `workspaces open --secret`, `workspaces secrets`), session workspaces (`--lifetime session`,
38
+ `workspaces close`), layered workspaces (`workspaces reset|save-as-template|changes`), templates
39
+ (`templates list|files|diff|draft ...`), and waking suspended workspaces on use (`--no-wake`, `--wake-timeout`).
40
+
41
+ ## 0.1.0 (2026-09-26)
42
+
43
+ First release: `login`, `whoami`, `usage`, `workspaces open|list|get|exec|suspend|resume|fork|delete|sessions`,
44
+ `files read|write|ls`, `operations list|get|wait`, `--json`, and documented exit codes.
package/README.md CHANGED
@@ -56,14 +56,14 @@ every run.
56
56
  | `login`, `whoami` | Show the principal behind the key. |
57
57
  | `version` | Print the CLI and SDK versions. |
58
58
  | `usage [--org <id>]` | Usage summary for the current period: plan, meters and allowances. |
59
- | `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
59
+ | `workspaces open <key> --template <slug> [--secret NAME]... [--lifetime persistent\|session] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m] [--timing]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. |
60
60
  | `workspaces list [--state S] [--prefix P] [--lifetime persistent\|session\|any] [--purpose standard\|template_draft\|template_test\|any] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. By default only persistent standard workspaces are listed. |
61
61
  | `workspaces get <id\|key>` | One workspace (deleted ones included). |
62
62
  | `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. |
63
63
  | `files read <id\|key> <path> [--out F]` | Raw bytes to stdout, or to a local file. |
64
64
  | `files write <id\|key> <path> [--from F\|-] [--append] [--no-parents]` | Atomic write. The default source is stdin. |
65
65
  | `files ls <id\|key> <path> [--limit N]` | Directory listing. |
66
- | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
66
+ | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T] [--timing]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
67
67
  | `workspaces fork <id\|key> <new-key> [caps] [--wait]` | Fork into a new key. |
68
68
  | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once. The key of a persistent workspace is never reused. |
69
69
  | `workspaces close <id\|key> [--wait]` | End a session workspace now: it is deleted, and the key then opens a new workspace. A persistent workspace is refused (`not_session`). |
@@ -77,7 +77,7 @@ every run.
77
77
  | `secrets update <id\|NAME> [--description T] [--tool T]... [--allow-workspace ID]... [--any-workspace] [--allow-project ID]... [--all-projects] [--clear-projects]` | Change the description or usage permissions. |
78
78
  | `secrets rotate <id\|NAME> [--from-file F]` | Store a new value (standard input or file); older values are erased. |
79
79
  | `secrets versions <id\|NAME>`, `secrets delete <id\|NAME> --yes`, `secrets access-log <id\|NAME>` | Versions, deletion (also removes the name from every workspace binding), and the access log. |
80
- | `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T]` | Operations. |
80
+ | `operations list <id\|key> [--state S] [--kind K]`, `operations get <op>`, `operations wait <op> [--timeout T] [--timing]` | Operations. |
81
81
  | `templates list [--owner platform\|organization] [--include-archived] [--all]` | Templates the project can open, with their open version, layouts, file list state and draft. |
82
82
  | `templates files <slug> <version> [path] [--stat] [--owner O] [--all] [--limit N]` | One directory of a version's file tree, or one entry with `--stat`. |
83
83
  | `templates diff <slug> --from <version\|base> --to <version> [--prefix P] [--change K] [--all]` | Diff two versions: added, removed, changed, type_changed, metadata. The totals come first. |
@@ -109,6 +109,51 @@ never runs twice, because the workspace refused it before doing anything.
109
109
  - `--no-wake` (or `SHARDFLUX_NO_WAKE=1`) turns this off. A workspace that is not running is then
110
110
  refused at once (exit 1, `workspace_not_running`).
111
111
 
112
+ ## Timing
113
+
114
+ `--timing` (0.3.0+) says where the time of an open or a wait went. After the call, `shard` prints the lifecycle
115
+ timing that [`@shardflux/sdk`](https://www.npmjs.com/package/@shardflux/sdk) measured (its `formatTiming()`) on
116
+ stderr. It is printed when the call fails, times out or is interrupted too, before the error, and the exit code does
117
+ not change. A slow open reads, for example:
118
+
119
+ ```text
120
+ $ shard ws open acme/demo --template python-node-browser --timing
121
+ Workspace acme/demo (01a0e5a8-3edd-74ba-b489-d62b8925e342) is ready.
122
+ ...
123
+ open 34.18 s, succeeded (workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342, operation 01a0e5a8-3ef0-7ecb-975e-dff2d5ca6e33)
124
+ client: request 20.01 s (held) → capacity_pending 13.52 s (no_ready_host) → running 0.59 s → view 42 ms ∥ token 61 ms
125
+ server: queued 33.40 s, ran 0.62 s, total 34.02 s; start warm, boot to ready 79 ms
126
+ outside the server: 0.16 s
127
+ ```
128
+
129
+ Here the time went to waiting for a host with capacity; the network and the VM start were fast.
130
+
131
+ - **client**: phases on your clock. `request` is the API call; `(held)` means the API held it until the workspace
132
+ was ready, up to 20 s at a time. Then come the operation states seen while waiting, with the server's reason, and
133
+ reading the workspace.
134
+ - **server**: the operation's own timing. `queued` runs until the operation began running, including any wait for
135
+ capacity; `ran` is the cell's work. Then the start or resume path the cell reported.
136
+ - **outside the server**: your total minus the operation's, so network, polling and the final reads. A large value
137
+ with a small server total points at the connection, not at the workspace.
138
+ - **retries**: transient failures that were retried.
139
+
140
+ Which call is timed:
141
+
142
+ - `workspaces open` and `templates draft open|test`: the open, including the wait unless `--no-wait`.
143
+ - `workspaces suspend|resume|fork|delete|close|reset` and `templates draft state|discard`: with `--wait`, the request
144
+ and the whole wait; without it, the request.
145
+ - `operations wait`: the wait.
146
+ - `workspaces exec`, `files read|write|ls` and `workspaces changes`: the wake, when the command had to wake a
147
+ suspended workspace. Without a wake nothing is printed.
148
+
149
+ With `--json`, stdout carries the timing as a `timing` field of the command's JSON instead: `action`, `outcome`,
150
+ `workspace_id`, `operation_id`, `total_ms`, `phases` (`phase`, `reason`, `operation_id`, `start_ms`, `duration_ms`),
151
+ `retries`, `server` (`queued_ms`, `run_ms`, `total_ms`, `start_path`, `resume_path`, `boot_to_ready_ms`, ...) and
152
+ `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
153
+ carries it: `{"error": {...}, "timing": {...}}`.
154
+
155
+ `shard` does not stream progress while it waits. For live events, use the SDK's `onProgress`.
156
+
112
157
  ## Secrets
113
158
 
114
159
  ```sh
@@ -146,6 +191,8 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
146
191
  `{deleted, id, name, scope}`; `workspaces secrets` prints `{workspace_id, names, secrets}`.
147
192
 
148
193
  Errors go to stderr as `{"error": {code, message, status, request_id, ...}}`.
194
+ - **`--timing`:** the timing goes to stderr after the output. With `--json` it is a `timing` field instead
195
+ (see "Timing").
149
196
 
150
197
  ## Exit codes
151
198
 
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.2.0";
1
+ export declare const CLI_VERSION = "0.3.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { parseArgs } from 'node:util';
12
12
  import { UsageError } from "./errors.js";
13
- export const CLI_VERSION = '0.2.0';
13
+ export const CLI_VERSION = '0.3.0';
14
14
  export const GLOBAL_OPTIONS = {
15
15
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
16
16
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -25,9 +25,21 @@ const CAPS = {
25
25
  'memory-mib': { type: 'string', value: '<n>', description: 'Memory cap in MiB.' },
26
26
  'disk-gib': { type: 'string', value: '<n>', description: 'Disk cap in GiB.' },
27
27
  };
28
+ /** `--timing` (0.3.0+): the SDK's lifecycle timing of the command's open, wait or wake (README "Timing"). */
29
+ const TIMING = {
30
+ timing: {
31
+ type: 'boolean',
32
+ description: 'Print where the time went on stderr after the call, also when it fails: client phases, the operation’s server timing, retries (with --json: a "timing" field instead).',
33
+ },
34
+ };
35
+ /** `--timing` of the commands that can wake a suspended workspace: the wake's timing, when there was one. */
36
+ const WAKE_TIMING = {
37
+ timing: { type: 'boolean', description: 'Print where the time went when the command had to wake the workspace (stderr; with --json a "timing" field, null without a wake).' },
38
+ };
28
39
  const WAIT = {
29
40
  wait: { type: 'boolean', description: 'Wait until the operation finishes.' },
30
41
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (e.g. 90s, 5m; default 5m). The operation continues server side.' },
42
+ ...TIMING,
31
43
  };
32
44
  const REF = { name: 'id|key', required: true };
33
45
  const SLUG = { name: 'slug', required: true };
@@ -100,12 +112,14 @@ export const COMMANDS = [
100
112
  ...CAPS,
101
113
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
102
114
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m). The start continues server side.' },
115
+ ...TIMING,
103
116
  },
104
117
  examples: [
105
118
  'shard workspaces open acme/demo --template python-node-browser',
106
119
  'shard ws open job-42 --template python-node-browser --lifetime session',
107
120
  'shard ws open acme/demo --template python-node-browser --secret OPENAI_API_KEY --secret DATABASE_URL',
108
121
  'shard ws open acme/demo --template python-node-browser --no-wait --json',
122
+ 'shard ws open acme/demo --template python-node-browser --timing',
109
123
  ],
110
124
  },
111
125
  {
@@ -133,6 +147,7 @@ export const COMMANDS = [
133
147
  env: { type: 'string', multiple: true, value: '<K=V>', description: 'Environment variable (repeatable).' },
134
148
  timeout: { type: 'string', value: '<duration>', description: 'Kill the command after this long (exit code 124).' },
135
149
  stdin: { type: 'string', value: '<file|->', description: 'Send this file (or - for standard input) to the command’s stdin (max 1 MiB).' },
150
+ ...WAKE_TIMING,
136
151
  },
137
152
  examples: ['shard ws exec acme/demo -- python3 -V', 'shard ws exec acme/demo --cwd /home/user/app --env CI=1 -- bash -lc "npm test"'],
138
153
  },
@@ -188,6 +203,7 @@ export const COMMANDS = [
188
203
  summary: { type: 'boolean', description: 'Also print totals over everything under --path.' },
189
204
  ...PAGE,
190
205
  limit: { type: 'string', value: '<n>', description: 'Page size, 1-1000 (default 1000).' },
206
+ ...WAKE_TIMING,
191
207
  },
192
208
  },
193
209
  { path: ['workspaces', 'sessions'], summary: 'List the attributed agent sessions (tool-token principals and labels) of a workspace', positionals: [REF], options: {} },
@@ -206,7 +222,7 @@ export const COMMANDS = [
206
222
  path: ['files', 'read'],
207
223
  summary: 'Print (or save) a file from a workspace',
208
224
  positionals: [REF, { name: 'path', required: true }],
209
- options: { out: { type: 'string', value: '<file>', description: 'Write to this local file instead of stdout.' } },
225
+ options: { out: { type: 'string', value: '<file>', description: 'Write to this local file instead of stdout.' }, ...WAKE_TIMING },
210
226
  },
211
227
  {
212
228
  path: ['files', 'write'],
@@ -216,13 +232,14 @@ export const COMMANDS = [
216
232
  from: { type: 'string', value: '<file|->', description: 'Local file to upload, or - for standard input (default -).' },
217
233
  append: { type: 'boolean', description: 'Append instead of replacing.' },
218
234
  parents: { type: 'boolean', description: 'Create missing parent directories (default; --no-parents to refuse).' },
235
+ ...WAKE_TIMING,
219
236
  },
220
237
  },
221
238
  {
222
239
  path: ['files', 'ls'],
223
240
  summary: 'List a directory in a workspace',
224
241
  positionals: [REF, { name: 'path', required: true }],
225
- options: { limit: { type: 'string', value: '<n>', description: 'Maximum entries (default 1000).' } },
242
+ options: { limit: { type: 'string', value: '<n>', description: 'Maximum entries (default 1000).' }, ...WAKE_TIMING },
226
243
  },
227
244
  {
228
245
  path: ['operations', 'list'],
@@ -347,6 +364,7 @@ export const COMMANDS = [
347
364
  ...CAPS,
348
365
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
349
366
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
367
+ ...TIMING,
350
368
  },
351
369
  examples: ['shard templates draft open acme-dev --base python-node-browser@5'],
352
370
  },
@@ -374,6 +392,7 @@ export const COMMANDS = [
374
392
  ...CAPS,
375
393
  wait: { type: 'boolean', description: 'Wait until ready (default; --no-wait returns at once).' },
376
394
  timeout: { type: 'string', value: '<duration>', description: 'Give up waiting after this long (default 5m).' },
395
+ ...TIMING,
377
396
  },
378
397
  },
379
398
  {
@@ -393,7 +412,7 @@ export const COMMANDS = [
393
412
  path: ['operations', 'wait'],
394
413
  summary: 'Wait for an operation to finish (exit 0 succeeded, 6 failed/canceled, 5 timeout)',
395
414
  positionals: [{ name: 'operation-id', required: true }],
396
- options: { timeout: { type: 'string', value: '<duration>', description: 'Give up after this long (default 5m).' } },
415
+ options: { timeout: { type: 'string', value: '<duration>', description: 'Give up after this long (default 5m).' }, ...TIMING },
397
416
  },
398
417
  ];
399
418
  const GROUPS = {
@@ -1,5 +1,6 @@
1
1
  import type { RunResult, Shardflux, Workspace } from '@shardflux/sdk';
2
2
  import type { Values } from './args.js';
3
+ import type { TimingRecorder } from './timing.js';
3
4
  export interface Writer {
4
5
  write(chunk: string | Uint8Array): boolean;
5
6
  }
@@ -21,6 +22,10 @@ export interface Ctx {
21
22
  /** Bound on lifecycle waits per cell call, wakes included (--wake-timeout / SHARDFLUX_WAKE_TIMEOUT_MS). */
22
23
  wakeTimeoutMs: number;
23
24
  signal: AbortSignal;
25
+ /** --timing: print the SDK's timing of the command's open, wait or wake (stderr; a `timing` field with --json). */
26
+ timing: boolean;
27
+ /** Timings and the last observed operation of the traced SDK calls this command made (the client's onProgress). */
28
+ traces: TimingRecorder;
24
29
  cloud(): Shardflux;
25
30
  }
26
31
  export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promise<number>;
package/dist/commands.js CHANGED
@@ -9,6 +9,7 @@ import { readFile, writeFile } from 'node:fs/promises';
9
9
  import { SDK_VERSION, ShardfluxApiError } from '@shardflux/sdk';
10
10
  import { CLI_VERSION, SECRET_NAME, UUID, parseDuration, parseEnvPairs, parsePositiveInt } from "./args.js";
11
11
  import { InterruptedError, NotFoundError, UsageError } from "./errors.js";
12
+ import { timingJson } from "./timing.js";
12
13
  import { bindingsText, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, fileTable, json, kv, operationDetail, operationTable, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateEntryText, templateFileTable, templateTable, usageText, workspaceDetail, workspaceTable, } from "./format.js";
13
14
  const DEFAULT_WAIT_MS = 300_000;
14
15
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
@@ -30,8 +31,14 @@ function secretNames(names, option) {
30
31
  throw new UsageError(`${option} names a secret twice`);
31
32
  return [...names];
32
33
  }
34
+ /** With --json --timing the JSON gains a `timing` field (null when the command made no traced lifecycle call). */
35
+ function withTiming(ctx, machine) {
36
+ if (!ctx.timing || typeof machine !== 'object' || machine === null || Array.isArray(machine))
37
+ return machine;
38
+ return { ...machine, timing: timingJson(ctx.traces.last) };
39
+ }
33
40
  function out(ctx, human, machine) {
34
- ctx.io.stdout.write(ctx.json ? json(machine) : human);
41
+ ctx.io.stdout.write(ctx.json ? json(withTiming(ctx, machine)) : human);
35
42
  }
36
43
  function caps(values) {
37
44
  const c = {};
@@ -158,6 +165,21 @@ async function waitOperation(ctx, operationId, timeoutMs) {
158
165
  throw err;
159
166
  }
160
167
  }
168
+ /**
169
+ * Runs an SDK call that waits itself (a waited open or lifecycle call). Ctrl-C reports the operation it was waiting
170
+ * for, as the SDK observed it; a held open interrupted before the server answered has none yet (`unnamed`).
171
+ */
172
+ async function waited(ctx, unnamed, call) {
173
+ try {
174
+ return await call();
175
+ }
176
+ catch (err) {
177
+ if (!ctx.signal.aborted)
178
+ throw err;
179
+ const id = ctx.traces.operationId;
180
+ throw id ? new InterruptedError(`Interrupted; operation ${id} continues server side.`, id) : new InterruptedError(`Interrupted; ${unnamed}`);
181
+ }
182
+ }
161
183
  async function getOperation(ctx, operationId) {
162
184
  if (!UUID.test(operationId))
163
185
  throw new UsageError(`<operation-id> must be a UUID (got "${operationId}")`);
@@ -259,20 +281,18 @@ export const HANDLERS = {
259
281
  const lifetime = oneOf(values, 'lifetime', LIFETIMES);
260
282
  const timeoutMs = waitTimeout(values);
261
283
  const cloud = ctx.cloud();
262
- let ws = await cloud.workspaces.open({
284
+ // The SDK's waited open: the server holds the request until the workspace is ready (Prefer: wait), then the SDK
285
+ // polls for what is left of --timeout; one trace (ws.lastTiming) covers all of it.
286
+ const ws = await waited(ctx, 'the open continues server side (open the key again to keep waiting).', () => cloud.workspaces.open({
263
287
  key: key,
264
288
  template,
265
289
  ...(c ? { caps: c } : {}),
266
290
  ...(bind.length ? { secrets: bind } : {}),
267
291
  ...(lifetime ? { lifetime } : {}),
268
292
  agentLabel: ctx.agentLabel,
269
- wait: false,
270
- });
293
+ wait: values.wait === false ? false : { timeoutMs, signal: ctx.signal },
294
+ }));
271
295
  const op = ws.activeOperation;
272
- if (values.wait !== false && !ws.ready && op) {
273
- await waitOperation(ctx, op.id, timeoutMs);
274
- ws = await cloud.workspaces.get(ws.id);
275
- }
276
296
  const view = ws.data;
277
297
  const human = ws.ready
278
298
  ? `Workspace ${ws.key} (${ws.id}) is ready.\n${workspaceDetail(view)}`
@@ -351,7 +371,7 @@ export const HANDLERS = {
351
371
  }
352
372
  const code = execExitCode(r);
353
373
  if (ctx.json) {
354
- ctx.io.stdout.write(json({ session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects }));
374
+ ctx.io.stdout.write(json(withTiming(ctx, { session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects })));
355
375
  }
356
376
  return code;
357
377
  },
@@ -364,7 +384,7 @@ export const HANDLERS = {
364
384
  out(ctx, `wrote ${bytes.length} bytes to ${dest}\n`, { path, bytes: bytes.length, out: dest });
365
385
  }
366
386
  else if (ctx.json) {
367
- ctx.io.stdout.write(json({ path, bytes: bytes.length, content_base64: Buffer.from(bytes).toString('base64') }));
387
+ ctx.io.stdout.write(json(withTiming(ctx, { path, bytes: bytes.length, content_base64: Buffer.from(bytes).toString('base64') })));
368
388
  }
369
389
  else {
370
390
  ctx.io.stdout.write(bytes);
@@ -386,23 +406,23 @@ export const HANDLERS = {
386
406
  return 0;
387
407
  },
388
408
  async 'workspaces suspend'(ctx, values, [ref]) {
389
- return lifecycle(ctx, values, ref, 'Suspending', (ws) => ctx.cloud().workspaces.suspend(ws.id));
409
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o));
390
410
  },
391
411
  async 'workspaces resume'(ctx, values, [ref]) {
392
- return lifecycle(ctx, values, ref, 'Resuming', (ws) => ctx.cloud().workspaces.resume(ws.id));
412
+ return lifecycle(ctx, values, ref, 'Resuming', (ws, o) => ctx.cloud().workspaces.resume(ws.id, o));
393
413
  },
394
414
  async 'workspaces delete'(ctx, values, [ref]) {
395
415
  if (values.yes !== true)
396
416
  throw new UsageError('refusing to delete without --yes (deletion revokes tool access immediately and cannot be undone)');
397
- return lifecycle(ctx, values, ref, 'Deleting', (ws) => ctx.cloud().workspaces.delete(ws.id));
417
+ return lifecycle(ctx, values, ref, 'Deleting', (ws, o) => ctx.cloud().workspaces.delete(ws.id, o));
398
418
  },
399
419
  async 'workspaces close'(ctx, values, [ref]) {
400
- return lifecycle(ctx, values, ref, 'Closing', (ws) => ctx.cloud().workspaces.close(ws.id));
420
+ return lifecycle(ctx, values, ref, 'Closing', (ws, o) => ctx.cloud().workspaces.close(ws.id, o));
401
421
  },
402
422
  async 'workspaces reset'(ctx, values, [ref]) {
403
423
  if (values.yes !== true)
404
424
  throw new UsageError('refusing to reset without --yes (reset wipes every change in the workspace and restarts it on its template)');
405
- return lifecycle(ctx, values, ref, 'Resetting', (ws) => ctx.cloud().workspaces.reset(ws.id));
425
+ return lifecycle(ctx, values, ref, 'Resetting', (ws, o) => ctx.cloud().workspaces.reset(ws.id, o));
406
426
  },
407
427
  async 'workspaces save-as-template'(ctx, values, [ref]) {
408
428
  const template = str(values.template);
@@ -656,10 +676,8 @@ export const HANDLERS = {
656
676
  const c = caps(values);
657
677
  const timeoutMs = waitTimeout(values);
658
678
  const source = await resolveWorkspace(ctx, ref);
659
- const res = await ctx.cloud().workspaces.fork(source.id, { key: newKey, ...(c ? { caps: c } : {}) });
660
- let op = res.operation;
661
- if (values.wait === true)
662
- op = await waitOperation(ctx, op.id, timeoutMs);
679
+ const res = await waited(ctx, 'the fork continues server side.', () => ctx.cloud().workspaces.fork(source.id, { key: newKey, ...(c ? { caps: c } : {}) }, lifecycleOptions(ctx, values, timeoutMs)));
680
+ const op = res.operation;
663
681
  out(ctx, lifecycleText(`Forking ${source.key} into`, res.workspace, op), { operation: op, workspace: res.workspace.data });
664
682
  return 0;
665
683
  },
@@ -912,12 +930,14 @@ async function resolveSecret(ctx, values, ref) {
912
930
  } while (cursor);
913
931
  throw new NotFoundError(project !== null ? `No secret named "${ref}" in this project (organization-wide secrets: add --org <organization-id>).` : `No organization secret named "${ref}".`);
914
932
  }
933
+ /** `--wait`: the SDK's own wait (the call resolves once the operation finished; one trace covers request and wait). */
934
+ function lifecycleOptions(ctx, values, timeoutMs) {
935
+ return values.wait === true ? { wait: { timeoutMs, signal: ctx.signal } } : {};
936
+ }
915
937
  async function lifecycle(ctx, values, ref, verb, start) {
916
938
  const timeoutMs = waitTimeout(values);
917
939
  const ws = await resolveWorkspace(ctx, ref);
918
- let op = await start(ws);
919
- if (values.wait === true)
920
- op = await waitOperation(ctx, op.id, timeoutMs);
940
+ const op = await waited(ctx, 'the operation continues server side.', () => start(ws, lifecycleOptions(ctx, values, timeoutMs)));
921
941
  out(ctx, lifecycleText(verb, ws, op), op);
922
942
  return 0;
923
943
  }
package/dist/http.d.ts CHANGED
@@ -9,5 +9,10 @@
9
9
  * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
10
  * costs one TCP/TLS handshake, which is negligible for these call rates.
11
11
  * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
+ *
13
+ * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
14
+ * held by the server for up to 20 s). The SDK passes the caller's signal to its
15
+ * held polls itself, but not to the held open (`workspaces.open()` with `wait`
16
+ * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
12
17
  */
13
- export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch): typeof fetch;
18
+ export declare function makeFetch(env: Record<string, string | undefined>, base?: typeof fetch, signal?: AbortSignal): typeof fetch;
package/dist/http.js CHANGED
@@ -9,13 +9,22 @@
9
9
  * 6.28) and `Connection: close` do not stall. A fresh connection per request
10
10
  * costs one TCP/TLS handshake, which is negligible for these call rates.
11
11
  * Set SHARDFLUX_HTTP_KEEPALIVE=1 to use the runtime's keep-alive pooling.
12
+ *
13
+ * `signal` (the CLI's Ctrl-C) is attached to held requests (`Prefer: wait=N`,
14
+ * held by the server for up to 20 s). The SDK passes the caller's signal to its
15
+ * held polls itself, but not to the held open (`workspaces.open()` with `wait`
16
+ * sends its POST without one), so Ctrl-C would otherwise wait the hold out.
12
17
  */
13
- export function makeFetch(env, base = fetch) {
14
- if (env.SHARDFLUX_HTTP_KEEPALIVE === '1')
15
- return base;
18
+ export function makeFetch(env, base = fetch, signal) {
19
+ const keepAlive = env.SHARDFLUX_HTTP_KEEPALIVE === '1';
16
20
  return (input, init) => {
17
21
  const headers = new Headers(init?.headers);
18
- headers.set('connection', 'close');
19
- return base(input, { ...init, headers });
22
+ const held = signal !== undefined && /\bwait\s*=/i.test(headers.get('prefer') ?? '');
23
+ if (keepAlive && !held)
24
+ return base(input, init);
25
+ if (!keepAlive)
26
+ headers.set('connection', 'close');
27
+ const s = held && signal ? (init?.signal ? AbortSignal.any([init.signal, signal]) : signal) : init?.signal;
28
+ return base(input, { ...init, headers, ...(s ? { signal: s } : {}) });
20
29
  };
21
30
  }
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export type { Ctx, Handler, Io, Writer } from './commands.js';
10
10
  export { EXIT, AuthConfigError, InterruptedError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from './errors.js';
11
11
  export type { ErrorInfo } from './errors.js';
12
12
  export { errorText, json, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
13
+ export { TimingRecorder, timingJson, timingText } from './timing.js';
package/dist/index.js CHANGED
@@ -7,3 +7,4 @@ export { COMMANDS, GLOBAL_OPTIONS, CLI_VERSION, commandHelp, findCommand, normal
7
7
  export { HANDLERS, execExitCode, resolveWorkspace } from "./commands.js";
8
8
  export { EXIT, AuthConfigError, InterruptedError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
9
9
  export { errorText, json, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
10
+ export { TimingRecorder, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ import { HANDLERS } from "./commands.js";
10
10
  import { AuthConfigError, EXIT, InterruptedError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
11
11
  import { errorText, json } from "./format.js";
12
12
  import { makeFetch } from "./http.js";
13
+ import { TimingRecorder, timingJson, timingText } from "./timing.js";
13
14
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
14
15
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
15
16
  const isLabel = (s) => s.length >= 1 && s.length <= 100 && ![...s].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f);
@@ -44,6 +45,9 @@ export async function run(argv, io, opts = {}) {
44
45
  const signal = opts.signal ?? new AbortController().signal;
45
46
  const rawKey = io.env.SHARDFLUX_API_KEY;
46
47
  const wantsJson = argv.slice(0, argv.includes('--') ? argv.indexOf('--') : argv.length).includes('--json');
48
+ // Every traced SDK call of the command (open, waited lifecycle calls, waits, wakes) reports here; --timing prints it.
49
+ const traces = new TimingRecorder();
50
+ let showTiming = false;
47
51
  const report = (err) => {
48
52
  const code = exitCodeFor(err, signal.aborted);
49
53
  const info = describeError(err);
@@ -52,7 +56,11 @@ export async function run(argv, io, opts = {}) {
52
56
  info.message = 'Interrupted.';
53
57
  }
54
58
  info.message = redact(info.message, rawKey);
55
- io.stderr.write(redact(wantsJson ? json({ error: info }) : errorText(info, hintFor(info)), rawKey));
59
+ // --timing on a failure: the failed call's timing (err.timing, else the last recorded), before the error; with
60
+ // --json a `timing` field next to `error`.
61
+ const timing = showTiming ? traces.forError(err) : null;
62
+ const text = wantsJson ? json(showTiming ? { error: info, timing: timingJson(timing) } : { error: info }) : `${timing ? timingText(timing) : ''}${errorText(info, hintFor(info))}`;
63
+ io.stderr.write(redact(text, rawKey));
56
64
  return code;
57
65
  };
58
66
  let parsed;
@@ -78,6 +86,7 @@ export async function run(argv, io, opts = {}) {
78
86
  throw new UsageError('--agent-label must be 1-100 printable characters');
79
87
  const wake = resolveWake(parsed.global.wake, io.env.SHARDFLUX_NO_WAKE);
80
88
  const wakeTimeoutMs = resolveWakeTimeout(parsed.global.wakeTimeout, io.env.SHARDFLUX_WAKE_TIMEOUT_MS);
89
+ showTiming = parsed.values.timing === true;
81
90
  let cloud;
82
91
  ctx = {
83
92
  io,
@@ -87,6 +96,8 @@ export async function run(argv, io, opts = {}) {
87
96
  wake,
88
97
  wakeTimeoutMs,
89
98
  signal,
99
+ timing: showTiming,
100
+ traces,
90
101
  cloud() {
91
102
  if (cloud)
92
103
  return cloud;
@@ -95,7 +106,14 @@ export async function run(argv, io, opts = {}) {
95
106
  throw new AuthConfigError('SHARDFLUX_API_KEY is not set.');
96
107
  if (!KEY_SHAPE.test(key))
97
108
  throw new AuthConfigError('SHARDFLUX_API_KEY is not a Shardflux project key (expected sfk_<key id>_<secret>).');
98
- cloud = new Shardflux({ apiKey: key, baseUrl: apiUrl, userAgent: `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}`, fetch: makeFetch(io.env), sleep: abortableSleep(signal) });
109
+ cloud = new Shardflux({
110
+ apiKey: key,
111
+ baseUrl: apiUrl,
112
+ userAgent: `shard-cli/${CLI_VERSION} shardflux-sdk-ts/${SDK_VERSION}`,
113
+ fetch: makeFetch(io.env, fetch, signal),
114
+ sleep: abortableSleep(signal),
115
+ onProgress: traces.listener,
116
+ });
99
117
  return cloud;
100
118
  },
101
119
  };
@@ -120,5 +138,8 @@ export async function run(argv, io, opts = {}) {
120
138
  return report(new InterruptedError('Interrupted.'));
121
139
  if ('err' in outcome)
122
140
  return report(outcome.err);
141
+ // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
142
+ if (showTiming && !ctx.json && traces.last)
143
+ io.stderr.write(timingText(traces.last));
123
144
  return outcome.code;
124
145
  }
@@ -0,0 +1,17 @@
1
+ import type { LifecycleTiming, ProgressListener } from '@shardflux/sdk';
2
+ export declare class TimingRecorder {
3
+ /** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
4
+ last: LifecycleTiming | null;
5
+ /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
6
+ operationId: string | null;
7
+ readonly listener: ProgressListener;
8
+ /**
9
+ * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
10
+ * carries the token fetch's timing, which is not a lifecycle call: then only a wake (if any) counts.
11
+ */
12
+ forError(err: unknown): LifecycleTiming | null;
13
+ }
14
+ /** The human form (stderr): formatTiming() of the SDK, one block. */
15
+ export declare function timingText(t: LifecycleTiming): string;
16
+ /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
17
+ export declare function timingJson(t: LifecycleTiming | null): Record<string, unknown> | null;
package/dist/timing.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `--timing`: where the time of a command's lifecycle call went, as @shardflux/sdk measures it (0.6.0+). The CLI
3
+ * does no timing of its own: the SDK traces open(), waited lifecycle calls, waitForOperation() and wakes, and emits
4
+ * each trace's LifecycleTiming in a `done` progress event (also on `err.timing` when the call fails). The recorder
5
+ * listens on the client (every traced call of the command), so a timing exists for failures too, including ones that
6
+ * carry no `timing` (network errors, Ctrl-C).
7
+ */
8
+ import { formatTiming } from '@shardflux/sdk';
9
+ export class TimingRecorder {
10
+ /** The timing of the last traced call that ended (token fetches are not lifecycle calls and are skipped). */
11
+ last = null;
12
+ /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
13
+ operationId = null;
14
+ listener = (e) => {
15
+ if (e.action === 'token' || e.action === 'tool')
16
+ return;
17
+ if (e.operationId)
18
+ this.operationId = e.operationId;
19
+ if (e.type === 'done')
20
+ this.last = e.timing;
21
+ };
22
+ /**
23
+ * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
24
+ * carries the token fetch's timing, which is not a lifecycle call: then only a wake (if any) counts.
25
+ */
26
+ forError(err) {
27
+ const own = typeof err === 'object' && err !== null ? err.timing : undefined;
28
+ return own && own.action !== 'token' ? own : this.last;
29
+ }
30
+ }
31
+ /** The human form (stderr): formatTiming() of the SDK, one block. */
32
+ export function timingText(t) {
33
+ return `${formatTiming(t)}\n`;
34
+ }
35
+ /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
36
+ export function timingJson(t) {
37
+ if (!t)
38
+ return null;
39
+ const s = t.server;
40
+ return {
41
+ action: t.action,
42
+ outcome: t.outcome,
43
+ workspace_id: t.workspaceId,
44
+ operation_id: t.operationId,
45
+ total_ms: t.totalMs,
46
+ phases: t.phases.map((p) => ({ phase: p.phase, reason: p.reason, operation_id: p.operationId, start_ms: p.startMs, duration_ms: p.durationMs })),
47
+ retries: t.retries.map((r) => ({ at_ms: r.atMs, request: r.request, attempt: r.attempt, cause: r.cause, delay_ms: r.delayMs })),
48
+ server: s
49
+ ? {
50
+ operation_id: s.operationId,
51
+ kind: s.kind,
52
+ state: s.state,
53
+ queued_ms: s.queuedMs,
54
+ run_ms: s.runMs,
55
+ total_ms: s.totalMs,
56
+ start_path: s.startPath,
57
+ warm_fallback: s.warmFallback,
58
+ resume_path: s.resumePath,
59
+ boot_to_ready_ms: s.bootToReadyMs,
60
+ host_timings_ms: s.hostTimingsMs,
61
+ }
62
+ : null,
63
+ outside_server_ms: t.outsideServerMs,
64
+ };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -32,13 +32,14 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE"
36
37
  ],
37
38
  "publishConfig": {
38
39
  "access": "public"
39
40
  },
40
41
  "dependencies": {
41
- "@shardflux/sdk": "^0.6.0"
42
+ "@shardflux/sdk": "^0.6.1"
42
43
  },
43
44
  "devDependencies": {
44
45
  "@eslint/js": "10.0.1",