@shardflux/cli 0.5.3 → 0.6.1

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 CHANGED
@@ -3,7 +3,39 @@
3
3
  Every command and option the README shows is available from the version named here. Below 1.0, a minor release may
4
4
  break compatibility; breaking changes are marked **Breaking**.
5
5
 
6
- ## 0.5.3 (not yet published)
6
+ ## 0.6.1
7
+
8
+ Needs `@shardflux/sdk` 0.11.0 or later.
9
+
10
+ ### `--when-idle 0`: suspend as soon as the workspace is idle
11
+
12
+ `shard ws suspend <id|key> --when-idle` accepts `0` (also `0s`) up to `1h`; before it refused anything under `30s`.
13
+ The API now takes `after_seconds` from 0: the workspace is suspended as soon as it is idle (a few seconds of activity
14
+ flush grace after its last work), and a running command, an attached stream or a keepalive still postpones it until
15
+ it ends. The human output says `is suspended as soon as it is idle`, and `ws get` shows `as soon as idle`. Out of
16
+ range is still exit 2 before any request: `--when-idle must be between 0s and 1h`.
17
+
18
+ ## 0.6.0
19
+
20
+ Needs `@shardflux/sdk` 0.11.0 or later.
21
+
22
+ ### A resume that restarted processes says so (cold boot, contracts §12)
23
+
24
+ When the platform's VM runtime changed after a suspend and no host can restore the memory snapshot, the cell resumes
25
+ the workspace by booting its saved disk (a cold boot), automatically, also when a command wakes it. Files are as of the
26
+ suspend; every process was restarted.
27
+
28
+ - After a command whose resume or wake did that (`ws resume --wait`, `operations wait` of that resume, or `ws exec`,
29
+ `files ...` and `ws changes` waking a suspended workspace), `shard` prints one line on stderr after the output, with
30
+ or without `--timing`: `note: workspace <id> resumed from its saved disk; running processes were restarted
31
+ (runtime_changed)`. Also with `--json` (stdout unchanged); not after a `--json` failure, whose stderr is only the
32
+ error document. On a text failure it comes before the error line. The exit code does not change.
33
+ - `--json --timing`: `timing.server` adds `memory_restored` (`false` for a cold boot, `true` for a memory resume,
34
+ `null` when the API did not say) and `cold_boot_reason`. The human `--timing` block says `resume from cold_boot:
35
+ processes restarted (runtime_changed)` (the SDK's `formatTiming()`).
36
+ - `coldBootNotice()` is exported next to `TimingRecorder`, which records the call as `coldBoot`.
37
+
38
+ ## 0.5.3
7
39
 
8
40
  Fix: `shard --version`, the User-Agent and the update check reports 0.5.3. 0.5.2 was published reporting 0.5.1, its previous version; the build now
9
41
  fails when `CLI_VERSION` differs from package.json.
package/README.md CHANGED
@@ -385,7 +385,7 @@ shows the status; `shard version --check` asks now (`--json`: `{"cli", "sdk", "u
385
385
  | `files search <id\|key> <path> <pattern> [--regex] [-i\|--ignore-case] [--include G]... [--exclude G]... [--max N]` | Search file contents under a directory (or in one file; gitignore-style globs): `path:line:column: text` per match (0.5.0+). |
386
386
  | `files patch <id\|key> <path> (--old T --new T)... [--replace-all] \| --content-file F\|- [--expected-revision R] [--if-revision N]` | Replace exact text (each `--old` must occur once unless `--replace-all`), or the whole content, atomically; `R` is a revision from `files stat --revision` or `absent` (0.5.0+). `--if-revision` (0.5.0+): file-first workspaces only. |
387
387
  | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T] [--timing]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
388
- | `workspaces suspend <id\|key> --when-idle D` / `--cancel-when-idle` | (0.5.1+) Suspend once the workspace has been idle for `D` (30s to 1h), or cancel that. See "Suspend when idle". |
388
+ | `workspaces suspend <id\|key> --when-idle D` / `--cancel-when-idle` | (0.5.1+) Suspend once the workspace has been idle for `D` (0 to 1h; `0` = as soon as it is idle, 0.6.1+), or cancel that. See "Suspend when idle". |
389
389
  | `workspaces fork <id\|key> <new-key> [caps] [--wait]` | Fork into a new key. |
390
390
  | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once. The key of a persistent workspace is never reused. |
391
391
  | `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`). |
@@ -576,17 +576,34 @@ without waking it (the state at suspension); `files search` then notes it on std
576
576
  `shard operations wait <id>` keeps waiting.
577
577
  - A resume or open that fails exits 6.
578
578
 
579
+ **A resume can restart processes (0.6.0+).** A resume normally restores memory and running processes. When the
580
+ platform's VM runtime changed after the suspend and no host can restore that memory snapshot, the resume boots the
581
+ workspace's saved disk instead (a cold boot), automatically, also when a command wakes the workspace. The command
582
+ still runs, and the files are as of the suspend, but every process was restarted, as after a reboot: start dev
583
+ servers and background jobs again. `shard` then prints one line on stderr after the output (also with `--json`,
584
+ except on a failure, whose stderr is only the error document), whether or not you pass `--timing`:
585
+
586
+ ```text
587
+ note: workspace 01a0e5a8-3edd-74ba-b489-d62b8925e342 resumed from its saved disk; running processes were restarted (runtime_changed)
588
+ ```
589
+
590
+ `shard ws resume --wait --json` prints the operation with its `result` as the API sent it (`resume_path`
591
+ `cold_boot`, `memory_restored` false, `cold_boot_reason`), and `--timing` shows `resume from cold_boot: processes
592
+ restarted (runtime_changed)`. The same applies to `operations wait` of such a resume.
593
+
579
594
  ## Suspend when idle (0.5.1+)
580
595
 
581
596
  A running workspace is billed while it is awake. When an agent's turn ends, ask for a suspend once the workspace has
582
597
  been idle for a short time, instead of waiting for its idle policy:
583
598
 
584
599
  ```sh
585
- shard ws suspend acme/demo --when-idle 2m # 30s to 1h; whole seconds
600
+ shard ws suspend acme/demo --when-idle 2m # 0 to 1h; whole seconds
586
601
  shard ws get acme/demo # "suspend when idle after 120 s idle, not before ..."
587
602
  shard ws suspend acme/demo --cancel-when-idle
588
603
  ```
589
604
 
605
+ - `--when-idle 0` (0.6.1+) suspends the workspace as soon as it is idle (a few seconds after its last work).
606
+
590
607
  - The idle time counts from the later of the workspace's last work and the request.
591
608
  - A command still running, an attached stream or a keepalive postpones the suspend until the duration after it ends.
592
609
  - The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
@@ -686,8 +703,9 @@ Which call is timed:
686
703
 
687
704
  With `--json`, stdout carries the timing as a `timing` field of the command's JSON instead: `action`, `outcome`,
688
705
  `workspace_id`, `operation_id`, `total_ms`, `phases` (`phase`, `reason`, `operation_id`, `start_ms`, `duration_ms`),
689
- `retries`, `server` (`queued_ms`, `run_ms`, `total_ms`, `start_path`, `resume_path`, `boot_to_ready_ms`, ...) and
690
- `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
706
+ `retries`, `server` (`queued_ms`, `run_ms`, `total_ms`, `start_path`, `resume_path`, `memory_restored` and
707
+ `cold_boot_reason` (0.6.0+; `memory_restored` is `false` when a resume booted the saved disk, `null` when the API did
708
+ not say), `boot_to_ready_ms`, ...) and `outside_server_ms`. It is `null` when the command made no lifecycle call. On a failure the error document on stderr
691
709
  carries it: `{"error": {...}, "timing": {...}}`.
692
710
 
693
711
  `shard` does not stream progress while it waits. For live events, use the SDK's `onProgress`.
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.5.3";
1
+ export declare const CLI_VERSION = "0.6.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.5.3';
15
+ export const CLI_VERSION = '0.6.1';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -274,7 +274,7 @@ const KEY_COMMANDS = [
274
274
  description: 'Without options the suspend is requested now. --when-idle (0.5.1+) defers it instead: the workspace is suspended once it has been idle for that long, counted from the later of its last work and the request, so call it when an agent turn ends. A command still running, an attached exec or terminal stream, or a keepalive postpones it; the next tool call (the next turn) or a resume cancels it. Repeating replaces the pending request. --cancel-when-idle cancels it.',
275
275
  positionals: [REF],
276
276
  options: {
277
- 'when-idle': { type: 'string', value: '<duration>', description: 'Suspend once the workspace has been idle this long (30s to 1h, e.g. 30s, 2m; 0.5.1+). Cannot be combined with --wait.' },
277
+ 'when-idle': { type: 'string', value: '<duration>', description: 'Suspend once the workspace has been idle this long (0.5.1+; 0 to 1h, e.g. 0, 30s, 2m; 0 = as soon as it is idle, 0.6.1+). Cannot be combined with --wait.' },
278
278
  'cancel-when-idle': { type: 'boolean', description: 'Cancel a pending --when-idle request (0.5.1+; idempotent).' },
279
279
  ...WAIT,
280
280
  },
@@ -70,7 +70,7 @@ export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promi
70
70
  export declare const str: (v: Values[string]) => string | undefined;
71
71
  export declare const strs: (v: Values[string]) => string[];
72
72
  export declare function out(ctx: Ctx, human: string, machine: unknown): void;
73
- /** `--when-idle <duration>`: whole seconds from 30 to 3600 (the API's after_seconds bounds, contracts §20.6). */
73
+ /** `--when-idle <duration>`: whole seconds from 0 (as soon as it is idle; 0.6.1) to 3600 (the API's after_seconds bounds, contracts §20.6). */
74
74
  export declare function whenIdleSeconds(text: string): number;
75
75
  export declare function waitTimeout(values: Values): number;
76
76
  /**
package/dist/commands.js CHANGED
@@ -66,14 +66,16 @@ function caps(values) {
66
66
  c.disk_gib = parsePositiveInt(disk, 'disk-gib', 1_000_000);
67
67
  return Object.keys(c).length ? c : undefined;
68
68
  }
69
- /** `--when-idle <duration>`: whole seconds from 30 to 3600 (the API's after_seconds bounds, contracts §20.6). */
69
+ /** `--when-idle <duration>`: whole seconds from 0 (as soon as it is idle; 0.6.1) to 3600 (the API's after_seconds bounds, contracts §20.6). */
70
70
  export function whenIdleSeconds(text) {
71
+ if (/^0+(?:\.0+)?(?:ms|s|m|h)?$/.test(text.trim()))
72
+ return 0;
71
73
  const ms = parseDuration(text, 'when-idle');
72
74
  if (ms % 1000 !== 0)
73
75
  throw new UsageError('--when-idle must be a whole number of seconds (e.g. 30s, 90s, 2m)');
74
76
  const s = ms / 1000;
75
- if (s < 30 || s > 3600)
76
- throw new UsageError(`--when-idle must be between 30s and 1h (got "${text}")`);
77
+ if (s > 3600)
78
+ throw new UsageError(`--when-idle must be between 0s and 1h (got "${text}")`);
77
79
  return s;
78
80
  }
79
81
  export function waitTimeout(values) {
package/dist/format.js CHANGED
@@ -109,13 +109,13 @@ export function startupTail(s) {
109
109
  }
110
110
  /** A pending suspend-when-idle request in one line (0.5.1; contracts §20.6). */
111
111
  export function suspendRequestText(r) {
112
- return `after ${r.after_seconds} s idle, not before ${time(r.not_before)} (requested ${time(r.requested_at)})`;
112
+ return `${r.after_seconds === 0 ? 'as soon as idle' : `after ${r.after_seconds} s idle`}, not before ${time(r.not_before)} (requested ${time(r.requested_at)})`;
113
113
  }
114
114
  /** `shard ws suspend --when-idle`: the recorded request, or the suspend already in progress. */
115
115
  export function suspendWhenIdleText(w, r) {
116
116
  if (r.suspend_request) {
117
117
  const s = r.suspend_request;
118
- return (`Suspend when idle: ${w.key} (${w.id}) is suspended once it has been idle for ${s.after_seconds} s, not before ${time(s.not_before)}.\n` +
118
+ return (`Suspend when idle: ${w.key} (${w.id}) is suspended ${s.after_seconds === 0 ? 'as soon as it is idle' : `once it has been idle for ${s.after_seconds} s`}, not before ${time(s.not_before)}.\n` +
119
119
  'A running command, an attached stream or a keepalive postpones it; the next tool call or a resume cancels it.\n' +
120
120
  `cancel with: shard workspaces suspend ${w.key} --cancel-when-idle\n`);
121
121
  }
package/dist/index.d.ts CHANGED
@@ -22,4 +22,4 @@ export { openUrl, openerFor } from './browser.js';
22
22
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from './errors.js';
23
23
  export type { ErrorInfo } from './errors.js';
24
24
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
- export { TimingRecorder, timingJson, timingText } from './timing.js';
25
+ export { TimingRecorder, coldBootNotice, timingJson, timingText } from './timing.js';
package/dist/index.js CHANGED
@@ -14,4 +14,4 @@ export { CHECK_INTERVAL_MS, CLI_DISTRIBUTION, NOTICE_WAIT_MS, UPDATE_CHECK_FILE,
14
14
  export { openUrl, openerFor } from "./browser.js";
15
15
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
16
16
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
- export { TimingRecorder, timingJson, timingText } from "./timing.js";
17
+ export { TimingRecorder, coldBootNotice, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -19,7 +19,7 @@ import { errorText, feedbackCommand, json, time } from "./format.js";
19
19
  import { HANDLERS } from "./handlers.js";
20
20
  import { makeFetch } from "./http.js";
21
21
  import { Input } from "./input.js";
22
- import { TimingRecorder, timingJson, timingText } from "./timing.js";
22
+ import { TimingRecorder, coldBootNotice, timingJson, timingText } from "./timing.js";
23
23
  import { CLI_DISTRIBUTION, NOTICE_WAIT_MS, UpdateCheck, noticeOf, updateCheckDisabled, updateView } from "./update-check.js";
24
24
  const KEY_SHAPE = /^sfk_[a-z2-7]{16}_[A-Za-z0-9]+$/;
25
25
  // Printable (no C0 controls or DEL), as the API's AgentLabel pattern.
@@ -329,7 +329,7 @@ export async function run(argv, io, opts = {}) {
329
329
  const feedback = nudges(info, command) ? feedbackCommand(info) : undefined;
330
330
  const text = wantsJson
331
331
  ? json({ error: info, ...(showTiming ? { timing: timingJson(timing) } : {}), ...(feedback ? { feedback } : {}) })
332
- : `${timing ? timingText(timing) : ''}${errorText(info, hint, feedback)}`;
332
+ : `${timing ? timingText(timing) : ''}${traces.coldBoot ? coldBootNotice(traces.coldBoot) : ''}${errorText(info, hint, feedback)}`;
333
333
  io.stderr.write(redact(text, ...secrets));
334
334
  return code;
335
335
  };
@@ -562,6 +562,9 @@ export async function run(argv, io, opts = {}) {
562
562
  // With --json the handler put the timing into its output; otherwise it follows the output, on stderr.
563
563
  if (showTiming && !ctx.json && traces.last)
564
564
  io.stderr.write(timingText(traces.last));
565
+ // A resume that booted the saved disk (0.6.0+): said on stderr with or without --timing, also with --json.
566
+ if (traces.coldBoot)
567
+ io.stderr.write(coldBootNotice(traces.coldBoot));
565
568
  code = outcome.code;
566
569
  }
567
570
  // The update notice: after the output, never after Ctrl-C or a --json failure (stderr then holds only the error).
package/dist/timing.d.ts CHANGED
@@ -4,6 +4,12 @@ export declare class TimingRecorder {
4
4
  last: LifecycleTiming | null;
5
5
  /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
6
6
  operationId: string | null;
7
+ /**
8
+ * (0.6.0+) The last traced call whose resume booted the workspace's saved disk instead of restoring its memory
9
+ * (`resume_path` `cold_boot`, `memory_restored` false): a `ws resume --wait`, an `operations wait` of that resume, or
10
+ * the wake of a command that found the workspace suspended. `coldBootNotice()` says it.
11
+ */
12
+ coldBoot: LifecycleTiming | null;
7
13
  readonly listener: ProgressListener;
8
14
  /**
9
15
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -11,6 +17,11 @@ export declare class TimingRecorder {
11
17
  */
12
18
  forError(err: unknown): LifecycleTiming | null;
13
19
  }
20
+ /**
21
+ * The one-line stderr notice after a command whose resume booted the workspace's saved disk (0.6.0+), with or without
22
+ * `--timing`: the workspace runs and its files are as of the suspend, but running processes were restarted.
23
+ */
24
+ export declare function coldBootNotice(t: LifecycleTiming): string;
14
25
  /** The human form (stderr): formatTiming() of the SDK, one block. */
15
26
  export declare function timingText(t: LifecycleTiming): string;
16
27
  /** The `--json` form: the SDK's LifecycleTiming with the CLI's snake_case keys (the rest of its JSON is the API's). */
package/dist/timing.js CHANGED
@@ -11,13 +11,22 @@ export class TimingRecorder {
11
11
  last = null;
12
12
  /** The operation the command's traced calls last observed: named when Ctrl-C interrupts a wait. */
13
13
  operationId = null;
14
+ /**
15
+ * (0.6.0+) The last traced call whose resume booted the workspace's saved disk instead of restoring its memory
16
+ * (`resume_path` `cold_boot`, `memory_restored` false): a `ws resume --wait`, an `operations wait` of that resume, or
17
+ * the wake of a command that found the workspace suspended. `coldBootNotice()` says it.
18
+ */
19
+ coldBoot = null;
14
20
  listener = (e) => {
15
21
  if (e.action === 'token' || e.action === 'tool')
16
22
  return;
17
23
  if (e.operationId)
18
24
  this.operationId = e.operationId;
19
- if (e.type === 'done')
20
- this.last = e.timing;
25
+ if (e.type !== 'done')
26
+ return;
27
+ this.last = e.timing;
28
+ if (restartedProcesses(e.timing))
29
+ this.coldBoot = e.timing;
21
30
  };
22
31
  /**
23
32
  * The failed call's own timing (`err.timing`) when the SDK attached one, else the last recorded. A refused tool token
@@ -28,6 +37,18 @@ export class TimingRecorder {
28
37
  return own && own.action !== 'token' ? own : this.last;
29
38
  }
30
39
  }
40
+ /** A resume that booted the saved disk: files as of the suspend, every process restarted (never a null memory_restored). */
41
+ function restartedProcesses(t) {
42
+ return t.server?.resumePath === 'cold_boot' && t.server.memoryRestored === false;
43
+ }
44
+ /**
45
+ * The one-line stderr notice after a command whose resume booted the workspace's saved disk (0.6.0+), with or without
46
+ * `--timing`: the workspace runs and its files are as of the suspend, but running processes were restarted.
47
+ */
48
+ export function coldBootNotice(t) {
49
+ const reason = t.server?.coldBootReason;
50
+ return `note: ${t.workspaceId ? `workspace ${t.workspaceId} ` : ''}resumed from its saved disk; running processes were restarted${reason ? ` (${reason})` : ''}\n`;
51
+ }
31
52
  /** The human form (stderr): formatTiming() of the SDK, one block. */
32
53
  export function timingText(t) {
33
54
  return `${formatTiming(t)}\n`;
@@ -56,6 +77,8 @@ export function timingJson(t) {
56
77
  start_path: s.startPath,
57
78
  warm_fallback: s.warmFallback,
58
79
  resume_path: s.resumePath,
80
+ memory_restored: s.memoryRestored ?? null,
81
+ cold_boot_reason: s.coldBootReason ?? null,
59
82
  boot_to_ready_ms: s.bootToReadyMs,
60
83
  host_timings_ms: s.hostTimingsMs,
61
84
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "dependencies": {
46
46
  "yaml": "2.9.1",
47
- "@shardflux/sdk": "^0.10.2"
47
+ "@shardflux/sdk": "^0.11.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@eslint/js": "10.0.1",