@shardflux/cli 0.23.0 → 0.24.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0
4
+
5
+ - Finish all active device port-forward cleanup and close the local listener and cell before returning the original tunnel-close error. Repeated stop and abort keep the same error and promise.
6
+
7
+ - SSH and port tunnel reattachment stops on permanent HTTP upgrade refusals through the SDK, retaining the actual HTTP status instead of repeatedly refreshing a valid token.
8
+
9
+ - Recheck the guest ACP supervisor before reusing an idle editor connection. After its owner changes, initialize and load the retained native session; a stable owner keeps its session and cursor.
10
+
11
+ - Bind ACP input to the loaded supervisor using its Unix peer PID/start identity before provider stdin. A replacement between the owner check and request delivery requires loading the retained session again; no uncertain turn is replayed.
12
+
13
+ - `shard volumes attach/detach --machine <volume>` and `--template <volume> [--path <repo>]` manage default shared mounts. Machine/template status show their defaults; JSON includes live and skipped results.
14
+
15
+ - Finished Codex Cloud conversations land in your checkout like every other conversation: each turn that ends with its work committed lands in the branch the thread started from, and the thread goes on. A thread waiting on an approval or an answer lands once that turn finishes.
16
+
17
+ - `shard move` interrupts a running local Claude Code or Codex reply and continues it automatically in the cloud, redoing the interrupted step. An agent calling `move_to_cloud` inside its own turn still waits for that turn to end. The local turn and its running commands stop before the project snapshot is taken.
18
+
19
+ ## 0.23.2 (2026-10-14)
20
+
21
+ - Preserve API-key credential burst refusals (`tool_token_budget`, scope `api_key`) and their Retry-After without treating them as pre-handler admission or replaying arbitrary POSTs. Plan rollout preserves unmarked Free legacy hourly/burst behavior and uses effective workspace capacity for eligible shared hourly budgets; no client-side quota or extra retry layer.
22
+
23
+ ## 0.23.1
24
+
25
+ - Pin the Ubuntu desktop packages to 0.1.11. Read the desktop account snapshot with the selected project key so organization, usage, spend and connector metadata match the macOS Settings app, including API-key-only profiles. Optional missing metadata stays unavailable. Conversation lists support --all with bounded cursor handling; the desktop reads all pages of conversations and workspace states.
26
+
3
27
  ## 0.23.0 (2026-10-14)
4
28
 
5
29
  - Codex Cloud enable and setup report when a ChatGPT sign-in is required, with `codex login` and `shard setup` as the next steps. Setup offers the sign-in action.
package/README.md CHANGED
@@ -4,9 +4,11 @@ Historical template file lists: reads queue checksum-verified reconstruction whe
4
4
 
5
5
  Plan-derived API budgets (0.23.0+, SDK 0.27.0): `--json` error output includes `rate_limit` and `retry_after_seconds`; the text hint names the shared scope and delay. API request pacing follows the SDK admission timeout and preserves the request body. Reuse workspace credentials and reduce status polling when the returned budget asks for a delay.
6
6
 
7
+ Credential key pacing (0.23.2+): when the service enables plan budgets for an eligible finite non-Free grant, workspace credentials share organization and workspace hourly allowances, plus a separate API-key burst. A `tool_token_budget` refusal can name `api_key`; honor `Retry-After` and reuse valid credentials. Unmarked Free grants retain the legacy principal hourly/burst policy. It does not promise that lifecycle work has not started, so it never authorizes replay of an arbitrary POST.
8
+
7
9
  Exec survives a dropped connection (0.23.0+): a lost start response is retried with the same session ID and reattaches to the command that already started, and a cut-off JSON answer is retried within the same safe retry boundary.
8
10
 
9
- Suspension controls arrive in 0.22.0 (next coordinated release). `shard operations progress <operation-id>` reads the actual phase, host deadline, cancellation window and safe next action. `shard operations cancel <operation-id>` requests cancellation; `--wait --timeout 5m` waits for confirmed `suspend_canceled` recovery of the original workspace. Asynchronous acceptance is labeled separately. The owning host refuses cancellation outside validated preparation; after capture, wait for the outcome and resume if it succeeded. These commands never reset, replace or launch a provider turn. `--json` preserves the reported values; unavailable progress is explicit.
11
+ Suspension controls are available from 0.22.0. `shard operations progress <operation-id>` reads the actual phase, host deadline, cancellation window and safe next action. `shard operations cancel <operation-id>` requests cancellation; `--wait --timeout 5m` waits for confirmed `suspend_canceled` recovery of the original workspace. Asynchronous acceptance is labeled separately. The owning host refuses cancellation outside validated preparation; after capture, wait for the outcome and resume if it succeeded. These commands never reset, replace or launch a provider turn. `--json` preserves the reported values; unavailable progress is explicit.
10
12
 
11
13
  The command line for [Shardflux](https://shardflux.dev): serverless VMs for AI agents. Open a
12
14
  persistent workspace by key, run commands in it, move files in and out, and suspend, resume, fork
@@ -180,7 +182,7 @@ backup of later edits in other worktrees. External object alternates, nested rep
180
182
  resolution before preserve-all can upload; a registration whose folder is gone has nothing to copy and is listed as
181
183
  `stale_registrations`.
182
184
 
183
- A running turn finishes here first. Uncommitted edits come along, and your working tree, index and branch stay as
185
+ With CLI 0.24.0+, a move during a reply stops the local turn and continues the same reply in the cloud automatically, redoing the interrupted step. An agent calling `move_to_cloud` inside its own turn waits for that turn to finish. Uncommitted edits come along, and your working tree, index and branch stay as
184
186
  they are. The cloud work comes back to your checkout by itself, and typing in the local Claude Code copy shows one line
185
187
  that it continues in the cloud. Sessions from Claude Desktop's Code tab and the Codex app move the same way; in the
186
188
  macOS menu bar, a project's recent sessions have **Move to cloud** in their ⋯ menu, and with the Shardflux MCP server
@@ -188,6 +190,10 @@ you can ask the agent itself to continue in the cloud. A session is listed under
188
190
  one that began in another folder such as Claude Desktop's scratch folder, and `--path <repository>` moves it from
189
191
  anywhere (0.22.0+).
190
192
 
193
+ With menu bar 0.8.5+, a failed move stays at its session’s row with its reason on hover and **Retry move to cloud**
194
+ in **⋯**. Retry or Refresh clears the previous failure. CLI 0.23.0+ records a matching stable Node.js path on macOS,
195
+ and menu bar 0.8.5+ recovers Node.js after an upgrade removes the recorded binary.
196
+
191
197
  Start a thread in T3 Code from the command line (0.17.0+):
192
198
 
193
199
  ```sh
@@ -245,7 +251,8 @@ Finished conversations land in your checkout by themselves (0.16.0+, with the me
245
251
  conversation's turn ends with its work committed, the branch it started from moves to its commits and the changed
246
252
  files are written in place, next to your own uncommitted edits, which stay as they are (where both touched the same
247
253
  lines, your text is kept). `git status` keeps showing only your edits. A conversation started from a checkout with
248
- uncommitted edits starts from them too. Every rewritten file is backed up first:
254
+ uncommitted edits starts from them too. A Codex Cloud thread lands after each turn it finishes (0.24.0+); one waiting
255
+ on an approval or an answer lands once that turn finishes. Every rewritten file is backed up first:
249
256
 
250
257
  ```sh
251
258
  shard conversations landing # auto (default), pr or off for this project
@@ -639,7 +646,7 @@ Projects that depend on `@shardflux/sdk` or the Python `shardflux` package keep
639
646
  `shard` tells you when a newer version exists, on stderr after the command's output:
640
647
 
641
648
  ```text
642
- @shardflux/cli 0.13.5 is outdated: 0.14.0 is available. Update: shard update
649
+ @shardflux/cli 0.23.0 is outdated: 0.23.1 is available. Update: shard update
643
650
  ```
644
651
 
645
652
  or that the API no longer supports it (`... is no longer supported by the Shardflux API (minimum
@@ -1597,15 +1604,17 @@ shard agents new --app claude --path ./ --prompt "What is wrong on this page?" -
1597
1604
 
1598
1605
  `--image <file|->` (0.15.0+, repeatable) attaches up to five PNG, JPEG, GIF or WebP images of up to 10 MiB each to that first message. They are written into the conversation VM under `/home/user/.shardflux/attachments/` and named in the message: Claude Code attaches them as images and Codex opens them with its image viewer. `--image -` reads one image, or PNG images back to back, from standard input; use a file for `--prompt-file` when images come from standard input.
1599
1606
 
1600
- Use `shard agents open <workspace> --in auto --json` to resolve where a conversation opens: a saved target first; then, for Claude (0.15.1+), the conversation itself in Claude Desktop when it is installed (`{"target":"claude-desktop","url":"claude://code/session_…","app_bundle_id":…}`), else Claude Code in the terminal (`{"target":"terminal","command":["shard","agents","open","<workspace>"]}`; without `--json` it attaches in this terminal). Codex opens its Cloud thread in the Codex app. claude.ai opens with `--in web`. The caller opens the `url` or app, or runs the command in a terminal. Explicit `--in terminal` retains terminal attachment. The menu uses this resolver for new and existing conversations, without provider URLs in Swift.
1607
+ With CLI 0.23.0+, a full conversation key or id opens that same Claude or Codex session from any folder, including after **Move to cloud**. A computer key opens its bound conversation.
1608
+
1609
+ Use `shard agents open <workspace> --in auto --json` to resolve where a conversation opens: a saved target first; then, for Claude (0.15.1+), the conversation itself in Claude Desktop when it is installed (`{"target":"claude-desktop","url":"claude://code/session_…","app_bundle_id":…}`), else Claude Code in the terminal (`{"target":"terminal","command":["shard","agents","open","<workspace>"]}`; without `--json` it attaches in this terminal). Codex Cloud opens its current thread in your browser (CLI 0.23.0+). claude.ai opens with `--in web`. The caller opens the `url` or app, or runs the command in a terminal. Explicit `--in terminal` retains terminal attachment. The menu uses this resolver for new and existing conversations, without provider URLs in Swift.
1601
1610
 
1602
- ## Codex Cloud (0.15.0+, unreleased)
1611
+ ## Codex Cloud (0.15.0+)
1603
1612
 
1604
1613
  New Codex conversations use `shard agents new --app codex --path <directory>` or `shard ./ codex`. Each gets a fresh conversation VM and a genuine Codex Cloud thread. `shard agents open <conversation-id>` opens the existing thread without waking its VM. These paths create no SSH device entries and need no pairing.
1605
1614
 
1606
1615
  `shard setup` uses your existing Codex ChatGPT login to register one executor for the account. Its small connector workspace stays resident and is billed as ordinary workspace memory/CPU usage, including while conversations wait. Use `--no-codex` to skip setup, `shard agents codex status` to inspect it, or `shard agents codex disable` to remove the executor and release its residency. Conversation VMs wake for tools and follow the normal adaptive idle policy afterwards.
1607
1616
 
1608
- Explicit `agents new --in terminal`, `--in t3`, and `--like` launches keep their existing VM routes. Local Git is prepared and validated before Cloud enrollment. This branch's Cloud feature is awaiting release. Existing local-mode conversations keep their original route. Cloud uses OpenAI's model/configuration capabilities; local app-server plugins and settings are not automatically transferred.
1617
+ Explicit `agents new --in terminal`, `--in t3`, and `--like` launches keep their existing VM routes. Local Git is prepared and validated before Cloud enrollment. Existing local-mode conversations keep their original route. Cloud uses OpenAI's model/configuration capabilities; local app-server plugins and settings are not automatically transferred.
1609
1618
 
1610
1619
  Save progress (0.22.0+): Machine and template saves show source size and an estimated duration, then queued/build state and phase updates immediately. With `--json`, progress events go to stderr and the final stdout document includes `preflight` and `phases`. Failure and timeout output retain the latest counters and `details.last_progress`, with the build id and recovery action. If an older API cannot expose the build, JSON stderr emits `save_started` with the version/build id and leaves the phase unknown. Missing final host reports preserve the last observed progress.
1611
1620
 
@@ -1623,3 +1632,19 @@ its result while descendant drain can remain pending. TS/Python agent tools and
1623
1632
  Older clients read the same session response fields.
1624
1633
 
1625
1634
  Cloud history (0.23.0+): `shard agents open <conversation-id> --original` opens the original chat; `--history <thread-id>` opens an earlier linked chat. `--json` includes `previous_threads` with each history URL. Sign in with the Codex account that owns the selected chat.
1635
+
1636
+ ## Complete conversation lists (0.23.1+)
1637
+
1638
+ `shard conversations list --all` reads every page, keeping the selected project, state and parent filters. `--json` returns `data` and `nextCursor`. Linux desktop 0.1.11+ reads all conversation and computer pages and shows the selected project key’s organization, usage, spend and connector status in Settings.
1639
+
1640
+ ## Default shared volumes (0.24.0+)
1641
+
1642
+ ```sh
1643
+ shard volumes attach --machine datasets --mount /mnt/datasets
1644
+ shard volumes attach --template cache --mount /mnt/cache --read-only --path ./repo
1645
+ shard machine --json
1646
+ shard template --json
1647
+ shard volumes detach --machine datasets
1648
+ ```
1649
+
1650
+ Defaults mount in new and running conversations. Removal waits for current work to finish and preserves manual attachments. Add `--json` to read propagation results.
@@ -463,11 +463,11 @@ export const ACCOUNT_COMMANDS = [
463
463
  {
464
464
  path: ['volumes', 'attach'],
465
465
  summary: 'Attach a volume to a workspace at a path',
466
- positionals: [{ name: 'ws', required: true }, { name: 'volume', required: true }],
467
- options: { mount: { type: 'string', value: '<path>', description: 'Absolute guest path, e.g. /mnt/data (required).' }, 'read-only': { type: 'boolean', description: 'Mount read-only (default read-write).' }, ...VOLUME_WAIT },
466
+ positionals: [{ name: 'ws|volume' }, { name: 'volume' }],
467
+ options: { machine: { type: 'boolean', description: 'Apply to every machine conversation.' }, template: { type: 'boolean', description: 'Apply to this repository’s conversations.' }, path: { type: 'string', value: '<path>', description: 'Repository folder for --template (default cwd).' }, mount: { type: 'string', value: '<path>', description: 'Absolute guest path, e.g. /mnt/data (required).' }, 'read-only': { type: 'boolean', description: 'Mount read-only (default read-write).' }, ...VOLUME_WAIT },
468
468
  examples: ['shard volumes attach acme/demo datasets --mount /mnt/datasets --read-only'],
469
469
  },
470
- { path: ['volumes', 'detach'], summary: 'Detach a volume from a workspace', positionals: [{ name: 'ws', required: true }, { name: 'volume', required: true }], options: { ...VOLUME_WAIT } },
470
+ { path: ['volumes', 'detach'], summary: 'Detach a volume from a workspace', positionals: [{ name: 'ws|volume' }, { name: 'volume' }], options: { machine: { type: 'boolean', description: 'Remove a machine default.' }, template: { type: 'boolean', description: 'Remove a project template default.' }, path: { type: 'string', value: '<path>', description: 'Repository folder for --template.' }, ...VOLUME_WAIT } },
471
471
  // ---- templates with the session ------------------------------------------------------------------------
472
472
  {
473
473
  path: ['templates', 'publish'],
@@ -1,3 +1,4 @@
1
+ import { defaultVolumeCommand } from "./default-volume-commands.js";
1
2
  import { prepareOnboardingSample } from "./onboarding-sample.js";
2
3
  import { onboardingReport, onboardingText } from "./onboarding.js";
3
4
  /**
@@ -1950,7 +1951,13 @@ export const ACCOUNT_HANDLERS = {
1950
1951
  out(ctx, page.data.length ? attachmentTable(page.data) : `${v.name} is not attached.\n`, { data: page.data, next_cursor: page.nextCursor });
1951
1952
  return 0;
1952
1953
  },
1953
- async 'volumes attach'(ctx, values, [wsRef, volRef]) {
1954
+ async 'volumes attach'(ctx, values, args) {
1955
+ const done = await defaultVolumeCommand(ctx, values, args, 'attach');
1956
+ if (done !== undefined)
1957
+ return done;
1958
+ const [wsRef, volRef] = args;
1959
+ if (!wsRef || !volRef)
1960
+ throw new UsageError('Supply a workspace and volume, or --machine/--template with one volume');
1954
1961
  const mount = need(values, 'mount', '--mount /mnt/data');
1955
1962
  if (!mount.startsWith('/'))
1956
1963
  throw new UsageError('--mount must be an absolute path, e.g. /mnt/data');
@@ -1961,7 +1968,13 @@ export const ACCOUNT_HANDLERS = {
1961
1968
  out(ctx, `Attached ${v.name} to ${ws.key} at ${a.mount_path} (${a.mode}): ${a.state}${a.mounted ? ', mounted' : ', mounted on the next start'}.\n`, r);
1962
1969
  return 0;
1963
1970
  },
1964
- async 'volumes detach'(ctx, values, [wsRef, volRef]) {
1971
+ async 'volumes detach'(ctx, values, args) {
1972
+ const done = await defaultVolumeCommand(ctx, values, args, 'detach');
1973
+ if (done !== undefined)
1974
+ return done;
1975
+ const [wsRef, volRef] = args;
1976
+ if (!wsRef || !volRef)
1977
+ throw new UsageError('Supply a workspace and volume, or --machine/--template with one volume');
1965
1978
  const ws = await resolveWorkspace(ctx, wsRef);
1966
1979
  const v = await resolveVolume(ctx, volRef);
1967
1980
  const r = await waitedVolume(ctx, () => ctx.cloud().volumes.detach(ws.id, v.id, { wait: volumeWait(ctx, values) }));
@@ -5,12 +5,12 @@ export interface AcpCursor {
5
5
  from_seq: number;
6
6
  prompt_id?: RpcId;
7
7
  }
8
- export declare const ACP_INPUT_SCRIPT = "import json, os, socket, sys\npayload = sys.stdin.buffer.readline((2 << 20) + 1024)\nif not payload.endswith(b'\\n') or len(json.dumps(json.loads(payload)['message'], separators=(',', ':'), ensure_ascii=False).encode()) > 2 << 20:\n raise ValueError('ACP input exceeds 2 MiB')\nfolder = os.open(sys.argv[1], os.O_RDONLY)\ntry:\n with socket.socket(socket.AF_UNIX) as peer:\n peer.settimeout(10)\n address = os.path.join(sys.argv[1], 'control.sock')\n if len(address.encode()) >= 108: address = '/proc/self/fd/%d/control.sock' % folder\n peer.connect(address)\n peer.sendall(payload)\n response = peer.makefile('rb').readline(65536)\n result = json.loads(response)\n if result.get('accepted') is not True: raise ValueError(result.get('error', 'ACP input was not acknowledged'))\n sys.stdout.buffer.write(response)\nfinally:\n os.close(folder)\n";
8
+ export declare const ACP_INPUT_SCRIPT = "import json, os, socket, struct, sys\npayload = sys.stdin.buffer.readline((2 << 20) + 1024)\nif not payload.endswith(b'\\n') or len(json.dumps(json.loads(payload)['message'], separators=(',', ':'), ensure_ascii=False).encode()) > 2 << 20:\n raise ValueError('ACP input exceeds 2 MiB')\nfolder = os.open(sys.argv[1], os.O_RDONLY)\ntry:\n with socket.socket(socket.AF_UNIX) as peer:\n peer.settimeout(10)\n address = os.path.join(sys.argv[1], 'control.sock')\n if len(address.encode()) >= 108: address = '/proc/self/fd/%d/control.sock' % folder\n peer.connect(address)\n expected_pid, expected_start = sys.argv[2].split(':', 1)\n pid, uid, _ = struct.unpack('3i', peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')))\n with open('/proc/%d/stat' % pid) as process:\n start = process.read().rsplit(')', 1)[1].split()[19]\n if uid != os.getuid() or str(pid) != expected_pid or start != expected_start:\n raise ValueError('ACP supervisor changed before input; load this session before continuing')\n peer.sendall(payload)\n response = peer.makefile('rb').readline(65536)\n result = json.loads(response)\n if result.get('accepted') is not True: raise ValueError(result.get('error', 'ACP input was not acknowledged'))\n sys.stdout.buffer.write(response)\nfinally:\n os.close(folder)\n";
9
9
  /** Replay is finite; a live follower exists only while a turn/RPC needs output. Native supervision is CVB's. */
10
10
  export declare class AcpJournal {
11
11
  #private;
12
12
  readonly rpc: AcpRpc;
13
- constructor(cell: CellClient, conversation: string, _harness: 'claude' | 'codex', cwd: string, cursor: AcpCursor, save: () => void, signal: AbortSignal);
13
+ constructor(cell: CellClient, conversation: string, _harness: 'claude' | 'codex', cwd: string, cursor: AcpCursor, save: () => void, signal: AbortSignal, owner: string);
14
14
  start(): Promise<void>;
15
15
  pause(): void;
16
16
  resume(): void;
@@ -14,7 +14,7 @@ function retryableRead(error) {
14
14
  // start, a stdin frame, an output stream and a cancel (0.23.0+).
15
15
  const ONE_SHOT_INPUT = 192 << 10;
16
16
  // InputACP's private, same-uid socket receipt protocol. Stdin avoids Linux's 128 KiB per-argument limit.
17
- export const ACP_INPUT_SCRIPT = `import json, os, socket, sys
17
+ export const ACP_INPUT_SCRIPT = `import json, os, socket, struct, sys
18
18
  payload = sys.stdin.buffer.readline((2 << 20) + 1024)
19
19
  if not payload.endswith(b'\\n') or len(json.dumps(json.loads(payload)['message'], separators=(',', ':'), ensure_ascii=False).encode()) > 2 << 20:
20
20
  raise ValueError('ACP input exceeds 2 MiB')
@@ -25,6 +25,12 @@ try:
25
25
  address = os.path.join(sys.argv[1], 'control.sock')
26
26
  if len(address.encode()) >= 108: address = '/proc/self/fd/%d/control.sock' % folder
27
27
  peer.connect(address)
28
+ expected_pid, expected_start = sys.argv[2].split(':', 1)
29
+ pid, uid, _ = struct.unpack('3i', peer.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i')))
30
+ with open('/proc/%d/stat' % pid) as process:
31
+ start = process.read().rsplit(')', 1)[1].split()[19]
32
+ if uid != os.getuid() or str(pid) != expected_pid or start != expected_start:
33
+ raise ValueError('ACP supervisor changed before input; load this session before continuing')
28
34
  peer.sendall(payload)
29
35
  response = peer.makefile('rb').readline(65536)
30
36
  result = json.loads(response)
@@ -40,6 +46,7 @@ export class AcpJournal {
40
46
  #conversation;
41
47
  #cwd;
42
48
  #cursor;
49
+ #owner;
43
50
  #save;
44
51
  #controller = new AbortController();
45
52
  #signal;
@@ -50,14 +57,17 @@ export class AcpJournal {
50
57
  #follower;
51
58
  #writing = Promise.resolve();
52
59
  #paused = 0;
53
- constructor(cell, conversation, _harness, cwd, cursor, save, signal) {
60
+ constructor(cell, conversation, _harness, cwd, cursor, save, signal, owner) {
54
61
  if (!/^[a-zA-Z0-9_-]{1,96}$/.test(conversation))
55
62
  throw new Error('Invalid ACP conversation identity');
63
+ if (!/^[1-9][0-9]*:[0-9]+$/.test(owner))
64
+ throw new Error('Invalid ACP supervisor identity');
56
65
  this.#cell = cell;
57
66
  this.#conversation = conversation;
58
67
  this.#cwd = cwd;
59
68
  this.#cursor = cursor;
60
69
  this.#save = save;
70
+ this.#owner = owner;
61
71
  this.#signal = AbortSignal.any([signal, this.#controller.signal]);
62
72
  this.rpc = new AcpRpc(message => {
63
73
  const encoded = JSON.stringify(message);
@@ -83,7 +93,7 @@ export class AcpJournal {
83
93
  const dir = `/home/user/.local/share/shardflux/acp/${this.#conversation}`;
84
94
  const payload = Buffer.from(`{"mode":"input","input_id":${JSON.stringify(inputId)},"message":${message}}\n`);
85
95
  if (payload.length <= ONE_SHOT_INPUT) {
86
- const r = await this.#cell.exec.run(['python3', '-c', ACP_INPUT_SCRIPT, dir], { cwd: this.#cwd, stdin: payload, timeoutMs: 15_000, maxOutputBytes: 65536, signal: this.#signal });
96
+ const r = await this.#cell.exec.run(['python3', '-c', ACP_INPUT_SCRIPT, dir, this.#owner], { cwd: this.#cwd, stdin: payload, timeoutMs: 15_000, maxOutputBytes: 65536, signal: this.#signal });
87
97
  let receipt;
88
98
  try {
89
99
  receipt = JSON.parse(r.stdout);
@@ -95,7 +105,7 @@ export class AcpJournal {
95
105
  throw new Error('ACP delivery is unverified; load this session before continuing.');
96
106
  return;
97
107
  }
98
- const session = await this.#cell.exec.start({ argv: ['python3', '-c', ACP_INPUT_SCRIPT, dir], cwd: this.#cwd, stdin_open: true, timeout_ms: 15_000 }, this.#signal);
108
+ const session = await this.#cell.exec.start({ argv: ['python3', '-c', ACP_INPUT_SCRIPT, dir, this.#owner], cwd: this.#cwd, stdin_open: true, timeout_ms: 15_000 }, this.#signal);
99
109
  let offset = 0, stdout = '';
100
110
  try {
101
111
  while (offset < payload.length) {
package/dist/acp.js CHANGED
@@ -45,6 +45,12 @@ export function acpModelOptions(catalog) {
45
45
  });
46
46
  }
47
47
  const SIGN_IN = 'Sign in to Shardflux from your editor (or run shard acp --login).';
48
+ function supervisorOwner(workspaceId, conversationId, app, text) {
49
+ const status = JSON.parse(text);
50
+ if (status.conversation_id !== conversationId || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start)
51
+ throw new Error('ACP supervisor identity does not match this conversation.');
52
+ return status.ended ? null : `${workspaceId}:${status.pid}:${status.start}`;
53
+ }
48
54
  export function acpError(error) {
49
55
  if (error instanceof RpcError) {
50
56
  if (error.code === -32000 && /authentication required|oauth.*(?:revoked|expired)|not (?:signed|logged) in/i.test(error.message))
@@ -368,11 +374,28 @@ export class ShardfluxAcp {
368
374
  await session.connecting;
369
375
  return false;
370
376
  }
371
- if (session.transport && !session.transport.rpc.closed)
372
- return false;
373
- session.connecting = this.#start(session).finally(() => { session.connecting = undefined; });
377
+ let started = false;
378
+ session.connecting = (async () => {
379
+ if (session.transport && !session.transport.rpc.closed && session.ws && session.conversation) {
380
+ // An idle journal has no read follower. Its open local RPC cannot
381
+ // prove the supervisor still owns the guest after a cold recovery.
382
+ // Check only on use, before sending any input to that owner.
383
+ let owner = null;
384
+ try {
385
+ owner = supervisorOwner(session.ws.id, session.conversation.id, session.record.app ?? parseAcpModel(session.record.model).app, await session.ws.cell().files.readText(`/home/user/.local/share/shardflux/acp/${session.conversation.id}/status.json`, { length: 8192 }));
386
+ }
387
+ catch (error) {
388
+ if (error.status !== 404)
389
+ throw error;
390
+ }
391
+ if (owner !== null && owner === session.record.owner)
392
+ return;
393
+ }
394
+ started = true;
395
+ await this.#start(session);
396
+ })().finally(() => { session.connecting = undefined; });
374
397
  await session.connecting;
375
- return true;
398
+ return started;
376
399
  }
377
400
  async #start(session) {
378
401
  const ctx = this.#ctx, { record } = session, app = record.app ?? parseAcpModel(record.model).app;
@@ -458,10 +481,9 @@ export class ShardfluxAcp {
458
481
  throw new Error('The guest did not claim this conversation harness.');
459
482
  session.conversation = c;
460
483
  rememberConversation(ctx, c, record.folder);
461
- const status = JSON.parse(statusText);
462
- if (status.conversation_id !== c.id || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start || status.ended)
484
+ const owner = supervisorOwner(session.ws.id, c.id, app, statusText);
485
+ if (owner === null)
463
486
  throw new Error('ACP supervisor identity does not match this conversation.');
464
- const owner = `${session.ws.id}:${status.pid}:${status.start}`;
465
487
  const initialize = !record.nativeId || record.owner !== owner;
466
488
  // A cold restore can replace the journal owner without changing the VM id.
467
489
  // Its sequence starts at zero; an old cursor would drop initialize/load replies.
@@ -470,7 +492,7 @@ export class ShardfluxAcp {
470
492
  delete record.prompt_id;
471
493
  }
472
494
  record.workspaceId = session.ws.id;
473
- const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal);
495
+ const transport = new AcpJournal(cell, c.id, app, c.worktree_path, record, () => this.#save(record), ctx.signal, owner.slice(session.ws.id.length + 1));
474
496
  transport.rpc.handler = async (method, params, request) => {
475
497
  if (method.startsWith('fs/') || method.startsWith('terminal/'))
476
498
  throw new RpcError(-32601, 'Files and commands run in your cloud workspace.');
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.23.0";
1
+ export declare const CLI_VERSION = "0.24.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -13,7 +13,7 @@ import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
15
  import { DEFAULT_CHOICE, MODEL_CHOICES } from "./agent-models.js";
16
- export const CLI_VERSION = '0.23.0';
16
+ export const CLI_VERSION = '0.24.0';
17
17
  export const GLOBAL_OPTIONS = {
18
18
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
19
19
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -146,10 +146,9 @@ const LAYER_HELP = {
146
146
  template: 'The project template is this repository’s layer: toolchains, dependencies, services and project secrets. Setup after "shard ./" saves it; "shard template save" saves the current agent VM’s project changes, and "shard template delete" starts the project over from your machine. It is saved from an agent’s VM; "shard templates" builds templates for your own app’s workspaces instead.',
147
147
  };
148
148
  const KEY_COMMANDS = [
149
- { path: ['conversations', 'list'], summary: 'List project conversations', positionals: [], options: {
149
+ { path: ['conversations', 'list'], summary: 'List project conversations', positionals: [], options: { all: { type: 'boolean', description: 'Fetch every page.' },
150
150
  project: { type: 'string', value: '<id>', description: 'Project ID for a user session.' }, state: { type: 'string', value: '<state>', description: 'Conversation state.' },
151
- parent: { type: 'string', value: '<id>', description: 'Children of this conversation.' }, limit: { type: 'string', value: '<count>', description: 'Page size.' }, cursor: { type: 'string', value: '<cursor>', description: 'Next page cursor.' }
152
- } },
151
+ parent: { type: 'string', value: '<id>', description: 'Children of this conversation.' }, limit: { type: 'string', value: '<count>', description: 'Page size.' }, cursor: { type: 'string', value: '<cursor>', description: 'Next page cursor.' } } },
153
152
  { path: ['conversations', 'resume'], summary: 'Resume a saved conversation in a fresh VM', positionals: [{ name: 'id', required: true }], options: {} },
154
153
  { path: ['conversations', 'reader'], summary: 'Open a read-only project space reader', positionals: [{ name: 'project_id', required: true }], options: {} },
155
154
  { path: ['conversations', 'get'], summary: 'Show a conversation and its current VM', positionals: [{ name: 'id', required: true }], options: {} },
@@ -1364,10 +1363,10 @@ const CLI_PROXY_COMMANDS = [
1364
1363
  { path: ['cli-proxy', 'forward'], hidden: true, summary: 'Forward 127.0.0.1:8317 to your hosted CLIProxyAPI (started at login)', positionals: [], options: {} },
1365
1364
  ];
1366
1365
  /** Every command: the account plane first (sign in, set up), then the project-key commands. */
1367
- const MENUBAR_COMMANDS = ['start', 'stop', 'status'].map(action => ({
1368
- path: ['menubar', action], summary: `${action === 'start' ? 'Install and start' : action === 'stop' ? 'Stop' : 'Show'} the macOS menu bar app`,
1369
- positionals: [], options: { login: { type: 'string', value: '<on|off>', description: 'Enable or disable launch at login.' } },
1370
- }));
1366
+ const MENUBAR_COMMANDS = [{ path: ['menubar', 'account'], hidden: true, summary: 'Read this profile’s desktop account snapshot', positionals: [], options: {} }, ...['start', 'stop', 'status'].map((action) => ({
1367
+ path: ['menubar', action], summary: `${action === 'start' ? 'Install and start' : action === 'stop' ? 'Stop' : 'Show'} the macOS menu bar app`,
1368
+ positionals: [], options: { login: { type: 'string', value: '<on|off>', description: 'Enable or disable launch at login.' } },
1369
+ }))];
1371
1370
  const SETTINGS_COMMANDS = [{
1372
1371
  path: ['settings'], summary: 'Show or change this computer’s settings for cloud conversations (0.20.1+)', positionals: [], options: {
1373
1372
  'title-prefix': { type: 'string', value: '<text|none|default>', description: 'The marker your cloud conversations’ Claude session titles start with in claude.ai and the Claude app ("☁ Fix login bug · todo-app"): up to 16 characters on one line; none for no marker, default for ☁. New conversations use it.' },
@@ -1693,6 +1692,17 @@ export function parseCommandLine(argv, options = {}) {
1693
1692
  if (positionals[1] === undefined && flagged[0] !== undefined && positionals.length === 1)
1694
1693
  positionals.push(flagged[0]);
1695
1694
  }
1695
+ if (command.path[0] === 'volumes' && ['attach', 'detach'].includes(command.path[1] ?? '')) {
1696
+ if (values.machine === true && values.template === true)
1697
+ throw new UsageError('Choose --machine or --template');
1698
+ if (values.path !== undefined && values.template !== true)
1699
+ throw new UsageError('--path requires --template');
1700
+ const defaults = values.machine === true || values.template === true;
1701
+ if (positionals.length < (defaults ? 1 : 2))
1702
+ throw new UsageError(`missing <volume> for "shard ${command.path.join(' ')}"`);
1703
+ if (defaults && positionals.length !== 1)
1704
+ throw new UsageError('Supply one volume with --machine or --template');
1705
+ }
1696
1706
  const specs = command.positionals;
1697
1707
  const variadic = specs.at(-1)?.variadic === true;
1698
1708
  const required = specs.filter((s) => s.required && !s.variadic).length;
@@ -19,9 +19,23 @@ export const CONVERSATION_HANDLERS = {
19
19
  async 'conversations reader'(ctx, _values, [id]) { const r = await conversationClient(ctx).conversations.spaceReader(id, { signal: ctx.signal }); out(ctx, `${r.workspace.workspace_key}\n`, r); return 0; },
20
20
  async 'conversations list'(ctx, values) {
21
21
  const state = str(values.state);
22
- const page = await conversationClient(ctx).conversations.list({ projectId: str(values.project), state, parentId: str(values.parent), cursor: str(values.cursor), limit: values.limit ? Number(values.limit) : undefined });
22
+ const data = [], seen = new Set();
23
+ let cursor = str(values.cursor), next;
24
+ if (cursor)
25
+ seen.add(cursor);
26
+ do {
27
+ const page = await conversationClient(ctx).conversations.list({ projectId: str(values.project), state, parentId: str(values.parent), cursor, limit: values.limit ? Number(values.limit) : values.all ? 200 : undefined });
28
+ data.push(...page.data);
29
+ next = page.nextCursor;
30
+ cursor = next ?? undefined;
31
+ if (values.all && cursor) {
32
+ if (seen.has(cursor) || seen.size >= 100)
33
+ throw new UsageError('The conversation list repeated its cursor. Try again.');
34
+ seen.add(cursor);
35
+ }
36
+ } while (values.all && cursor);
23
37
  // A conversation that needs you adds a fifth column: why (0.18.0+).
24
- out(ctx, page.data.map(c => { const q = questionLine(c); return `${c.id}\t${displayTitle(c)}\t${c.state}\t${c.branch}${q ? `\t${q}` : ''}`; }).join('\n') + '\n', page);
38
+ out(ctx, data.map(c => { const q = questionLine(c); return `${c.id}\t${displayTitle(c)}\t${c.state}\t${c.branch}${q ? `\t${q}` : ''}`; }).join('\n') + '\n', { data, nextCursor: next });
25
39
  return 0;
26
40
  },
27
41
  async 'conversations get'(ctx, _values, [id]) { const c = await conversationClient(ctx).conversations.get(id); const q = questionLine(c, 200); out(ctx, `${displayTitle(c)}\n${c.state}\n${c.branch}\n${q ? `${q}\n` : ''}`, c); return 0; },
@@ -87,12 +87,17 @@ export async function getConversation(ctx, id) {
87
87
  return validateConversation(c);
88
88
  }
89
89
  export async function listConversations(ctx, projectId) {
90
- const all = [];
90
+ const all = [], seen = new Set();
91
91
  let cursor;
92
92
  do {
93
93
  const page = await conversationClient(ctx).conversations.list({ ...(projectId ? { projectId } : {}), limit: 200, ...(cursor ? { cursor } : {}) });
94
94
  all.push(...page.data.map(validateConversation));
95
95
  cursor = page.nextCursor ?? undefined;
96
+ if (cursor) {
97
+ if (seen.has(cursor) || seen.size >= 100)
98
+ throw new UsageError('The conversation list repeated its cursor. Try again.');
99
+ seen.add(cursor);
100
+ }
96
101
  } while (cursor);
97
102
  return all;
98
103
  }
@@ -0,0 +1,3 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { Values } from './args.js';
3
+ export declare function defaultVolumeCommand(ctx: Ctx, values: Values, args: string[], action: 'attach' | 'detach'): Promise<number | undefined>;
@@ -0,0 +1,35 @@
1
+ import { NotFoundError, UsageError } from "./errors.js";
2
+ import { projectIdentity, normalizeRemote } from "./project-identity.js";
3
+ export async function defaultVolumeCommand(ctx, values, args, action) {
4
+ if (values.machine !== true && values.template !== true) {
5
+ if (values.path !== undefined)
6
+ throw new UsageError('--path requires --template');
7
+ return undefined;
8
+ }
9
+ if (values.machine === true && values.template === true)
10
+ throw new UsageError('Choose --machine or --template');
11
+ if (args.length !== 1)
12
+ throw new UsageError(`Use shard volumes ${action} --machine <volume> or --template <volume>`);
13
+ if (values.machine === true && values.path !== undefined)
14
+ throw new UsageError('--path requires --template');
15
+ const cloud = ctx.cloud();
16
+ const project = values.template === true ? projectIdentity(typeof values.path === 'string' ? values.path : '.') : undefined;
17
+ const api = project ? cloud.templates.projectVolumes(project.remote ?? normalizeRemote(`file://${project.folder}`)) : cloud.machine.volumes;
18
+ const options = { signal: ctx.signal };
19
+ let result;
20
+ if (action === 'attach') {
21
+ if (typeof values.mount !== 'string')
22
+ throw new UsageError('--mount /mnt/data is required');
23
+ const ref = args[0];
24
+ result = await api.attach({ ...(/^[a-f0-9-]{36}$/i.test(ref) ? { volumeId: ref } : { name: ref }), mountPath: values.mount, mode: values['read-only'] === true ? 'ro' : 'rw' }, options);
25
+ }
26
+ else {
27
+ const list = await api.list(options), v = list.volumes.find(v => v.volume_id === args[0] || v.name === args[0]);
28
+ if (!v)
29
+ throw new NotFoundError(`Default volume ${args[0]} was not found`);
30
+ result = await api.detach(v.volume_id, options);
31
+ }
32
+ ctx.io.stdout.write(ctx.json ? JSON.stringify(result) + '\n' : `${values.template === true ? 'template' : 'machine'}: ${result.volumes.map(v => `${v.name} at ${v.mount_path} (${v.mode})`).join(', ') || 'no default volumes'}\n` +
33
+ result.live.map(v => `${v.workspace_id}: ${v.status}${v.reason ? ` (${v.reason})` : ''}\n`).join(''));
34
+ return 0;
35
+ }
@@ -0,0 +1,16 @@
1
+ import type { Ctx } from './commands.js';
2
+ /** The desktop Settings snapshot uses the selected project key's organization, as AccountClient on macOS does.
3
+ * The optional user identity comes from the session; saved credentials never imply connector liveness. */
4
+ export declare function desktopAccount(ctx: Ctx): Promise<{
5
+ identity: {
6
+ id: string;
7
+ email: string;
8
+ email_verified: boolean;
9
+ display_name: string | null;
10
+ mfa_enabled: boolean;
11
+ } | null;
12
+ organization: Record<string, unknown>;
13
+ usage: Record<string, unknown> | null;
14
+ spend: Record<string, unknown> | null;
15
+ credentials: import("@shardflux/sdk").AgentCredentialMetadata[] | null;
16
+ }>;
@@ -0,0 +1,29 @@
1
+ import { UsageError } from "./errors.js";
2
+ /** The desktop Settings snapshot uses the selected project key's organization, as AccountClient on macOS does.
3
+ * The optional user identity comes from the session; saved credentials never imply connector liveness. */
4
+ export async function desktopAccount(ctx) {
5
+ const token = ctx.apiKey().token;
6
+ const request = async (path) => {
7
+ const response = await (ctx.accountOptions().fetch ?? fetch)(new URL(`v1/${path}`, `${ctx.apiUrl.replace(/\/+$/, '')}/`), {
8
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, redirect: 'error',
9
+ signal: AbortSignal.any([ctx.signal, AbortSignal.timeout(20_000)]),
10
+ });
11
+ if (!response.ok)
12
+ throw new UsageError(`Couldn’t refresh your account (${response.status}). Try again.`);
13
+ return await response.json();
14
+ };
15
+ const me = await ctx.cloud().me();
16
+ const org = me.api_key?.organization_id;
17
+ if (!org)
18
+ throw new UsageError('The project key did not identify its organization. Sign in to Shardflux again.');
19
+ const [organization, usage, spend, credentials, identity] = await Promise.all([
20
+ request(`organizations/${encodeURIComponent(org)}`),
21
+ request(`organizations/${encodeURIComponent(org)}/usage/summary`).catch(() => null),
22
+ request(`organizations/${encodeURIComponent(org)}/spend`).catch(() => null),
23
+ ctx.cloud().agentCredentials.list().catch(() => null),
24
+ ctx.sessionSource() ? ctx.account().me().then(value => value.user).catch(() => null) : Promise.resolve(null),
25
+ ]);
26
+ if (organization.id !== org || typeof organization.name !== 'string' || !organization.name)
27
+ throw new UsageError('The organization response is unreadable. Refresh your account and try again.');
28
+ return { identity, organization, usage, spend, credentials };
29
+ }
@@ -323,8 +323,24 @@ export async function devicePort(ws, cell, kind, ports, signal) {
323
323
  await new Promise((resolveListen, reject) => { server.once('error', reject); server.listen(ports.local_port, '127.0.0.1', resolveListen); });
324
324
  let stopping;
325
325
  const abort = () => { void stop().catch(() => undefined); };
326
- const stop = () => stopping ??= (async () => { signal.removeEventListener('abort', abort); for (const socket of peers)
327
- socket.destroy(); await Promise.all([...handles].map(h => h.close())); await new Promise(resolveClose => server.close(() => resolveClose())); cell.close(); })();
326
+ const stop = () => stopping ??= (async () => {
327
+ signal.removeEventListener('abort', abort);
328
+ for (const socket of peers)
329
+ socket.destroy();
330
+ let failure;
331
+ await Promise.all([...handles].map(async (handle) => {
332
+ try {
333
+ await handle.close();
334
+ }
335
+ catch (error) {
336
+ failure ??= { error };
337
+ }
338
+ }));
339
+ await new Promise(resolveClose => server.close(() => resolveClose()));
340
+ cell.close();
341
+ if (failure)
342
+ throw failure.error;
343
+ })();
328
344
  signal.addEventListener('abort', abort, { once: true });
329
345
  if (signal.aborted)
330
346
  abort();
@@ -41,7 +41,8 @@ async function templateSource(ctx, project) {
41
41
  export function filesystemLine(state) {
42
42
  const rebase = state.rebase_deferred ? ', follows your machine on next use' : state.rebase_pending ? ', rebase pending' : '';
43
43
  const base = state.platform_base ? `; platform ${state.platform_base.slug}@${state.platform_base.version}${rebase}` : '';
44
- return usageLine(state) + base + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
44
+ const volumes = state.volumes?.length ? `; volumes: ${state.volumes.map(v => `${v.name} at ${v.mount_path} (${v.mode})`).join(', ')}` : '';
45
+ return usageLine(state) + base + volumes + (state.cache_scope ? `; cache: ${state.cache_scope}` : '');
45
46
  }
46
47
  function usageLine(state) {
47
48
  const current = state.version > 0 ? `version ${state.version}` : '';
@@ -20,7 +20,7 @@ export interface LocalSession {
20
20
  export interface SessionState {
21
21
  /** A process has the session open right now. */
22
22
  running: boolean;
23
- /** working: a turn is in progress (never cut off); idle: between turns, or no process is running it. */
23
+ /** working: a turn is in progress; idle: between turns, or no process is running it. */
24
24
  turn: 'working' | 'idle';
25
25
  pid?: number;
26
26
  }
@@ -46,6 +46,12 @@ export declare function workingDirectory(recent: Rec[], fallback: string): strin
46
46
  /** Whether the transcript's last turn is still running, from its records (see the module comment). */
47
47
  export declare function claudeTurnFromRecords(records: Rec[]): 'working' | 'idle';
48
48
  export declare function codexTurnFromRecords(records: Rec[]): 'working' | 'idle';
49
+ /** The actual writer, never a guessed PID from the transcript. Codex TUI's writer may be its shared daemon. */
50
+ export declare function sessionProcessPid(s: Pick<LocalSession, 'harness' | 'session_id' | 'file'>, env: Env): number | undefined;
51
+ /** The selected thread's last started turn, from its native rollout (Codex 0.160.0). */
52
+ export declare function sessionTurnId(s: Pick<LocalSession, 'file'>): string | undefined;
53
+ /** Re-read the registry immediately before signalling: a PID reused after a session exits belongs to someone else. */
54
+ export declare function sameClaudeProcess(s: Pick<LocalSession, 'session_id'>, env: Env, pid: number): boolean;
49
55
  /**
50
56
  * Whether a session worked in `folder` (or below it): any working directory its records name (0.20.2+; before, only
51
57
  * the first). A session started in a scratch folder that went on in a repository is listed under the repository too.
@@ -308,6 +308,44 @@ function codexRunning(env, id) {
308
308
  const r = spawnSync('perl', ['-e', 'use Fcntl qw(:flock); open(my $f, "<", $ARGV[0]) or exit 2; exit(flock($f, LOCK_EX|LOCK_NB) ? 0 : 1)', lock], { stdio: 'ignore', timeout: 5000 });
309
309
  return r.status === 1;
310
310
  }
311
+ /** The actual writer, never a guessed PID from the transcript. Codex TUI's writer may be its shared daemon. */
312
+ export function sessionProcessPid(s, env) {
313
+ if (s.harness === 'claude')
314
+ return claudeProcesses(env)?.find(p => p.sessionId === s.session_id)?.pid;
315
+ if (!codexRunning(env, s.session_id))
316
+ return undefined;
317
+ const lock = join(codexHome(env), 'thread-writer-locks', `${s.session_id}.lock`);
318
+ if (process.platform === 'linux') {
319
+ const st = statSync(lock, { bigint: true });
320
+ const major = ((st.dev >> 8n) & 0xfffn) | ((st.dev >> 32n) & 0xfffff000n);
321
+ const minor = (st.dev & 0xffn) | ((st.dev >> 12n) & 0xffffff00n);
322
+ try {
323
+ for (const line of readFileSync('/proc/locks', 'utf8').split('\n')) {
324
+ const m = /^\d+: FLOCK\s+ADVISORY\s+WRITE\s+(\d+)\s+([0-9a-f]+):([0-9a-f]+):(\d+)\s/.exec(line);
325
+ if (m && BigInt(`0x${m[2]}`) === major && BigInt(`0x${m[3]}`) === minor && BigInt(m[4]) === st.ino)
326
+ return Number(m[1]);
327
+ }
328
+ }
329
+ catch { /* Fall through to lsof on systems without a readable proc lock table. */ }
330
+ }
331
+ const r = spawnSync('lsof', ['-t', '--', lock], { encoding: 'utf8', timeout: 2000 });
332
+ const pids = [...new Set((r.stdout ?? '').trim().split(/\s+/).map(Number).filter(p => Number.isSafeInteger(p) && p > 1))];
333
+ return pids.length === 1 ? pids[0] : undefined;
334
+ }
335
+ /** The selected thread's last started turn, from its native rollout (Codex 0.160.0). */
336
+ export function sessionTurnId(s) {
337
+ const r = tail(s.file, statSync(s.file).size).reverse().find(r => r.type === 'event_msg' && obj(r.payload).type === 'task_started');
338
+ const id = obj(r?.payload).turn_id;
339
+ return typeof id === 'string' ? id : undefined;
340
+ }
341
+ /** Re-read the registry immediately before signalling: a PID reused after a session exits belongs to someone else. */
342
+ export function sameClaudeProcess(s, env, pid) {
343
+ const p = claudeProcesses(env)?.find(p => p.sessionId === s.session_id && p.pid === pid);
344
+ if (!p)
345
+ return false;
346
+ const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 2000, env: { ...process.env, TZ: 'UTC' } });
347
+ return !!p.procStart && r.status === 0 && r.stdout.trim().replace(/\s+/g, ' ') === p.procStart.trim().replace(/\s+/g, ' ');
348
+ }
311
349
  // ---------------------------------------------------------------------------------------------------------------
312
350
  // Listing, finding, state
313
351
  const within = (folder) => (cwd) => cwd === folder || cwd.startsWith(folder.endsWith('/') ? folder : `${folder}/`);
package/dist/menubar.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { desktopAccount } from "./desktop-account.js";
1
2
  import { execFile, spawn } from 'node:child_process';
2
3
  import { chmodSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
3
4
  import { createRequire } from 'node:module';
@@ -492,3 +493,4 @@ export const MENUBAR_HANDLERS = Object.fromEntries(['start', 'stop', 'status'].m
492
493
  };
493
494
  return [`menubar ${action}`, handler];
494
495
  }));
496
+ MENUBAR_HANDLERS['menubar account'] = async (ctx) => { ctx.io.stdout.write(json(await desktopAccount(ctx))); return 0; };
@@ -0,0 +1,18 @@
1
+ import type { Ctx } from './commands.js';
2
+ import type { LocalSession } from './local-sessions.js';
3
+ type Env = Record<string, string | undefined>;
4
+ /** Detached MCP children retain their parent chain; this also works for already-installed MCP servers. */
5
+ export declare function processAncestor(ancestor: number, child?: number): boolean;
6
+ export declare function selfMove(s: LocalSession, env: Env): boolean;
7
+ /**
8
+ * Whether a move stops the session's turn itself. A self-move, and a session whose live process this computer cannot
9
+ * name (a Claude Code without the session registry, a Codex thread whose lock more than one process holds), let the
10
+ * turn end first instead.
11
+ */
12
+ export declare function stopsLocalTurn(s: LocalSession, env: Env): boolean;
13
+ /** Shared Codex daemons need thread-scoped interrupt AND background-terminal cleanup: interrupt alone leaves Bash alive. */
14
+ export declare function interruptCodexThread(s: LocalSession, env: Env, signal: AbortSignal): Promise<void>;
15
+ /** Stop before the project snapshot. The native harness owns its interrupt records; the local transcript is read-only here. */
16
+ export declare function interruptLocalTurn(ctx: Pick<Ctx, 'io' | 'json' | 'signal'>, s: LocalSession, env: Env): Promise<boolean>;
17
+ export declare const MOVE_CONTINUATION = "Continue the same reply after the move to the cloud. The local turn was interrupted for this move. Redo the interrupted step from its beginning, including re-running the interrupted tool call; never skip to the next step. Everything completed before the interruption is in this project and session history. Finish the original request without waiting for another prompt.";
18
+ export {};
@@ -0,0 +1,193 @@
1
+ /** Native interruption measured with Claude 2.1.295/2.1.300 and Codex 0.160.0; see ground-truth.md. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, realpathSync } from 'node:fs';
4
+ import { basename, join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import WebSocket from 'ws';
7
+ import { UsageError } from "./errors.js";
8
+ import { CLI_VERSION } from "./args.js";
9
+ import { codexHome, sameClaudeProcess, sessionProcessPid, sessionState, sessionTurnId } from "./local-sessions.js";
10
+ function processes() {
11
+ const r = spawnSync('ps', ['-axo', 'pid=,ppid=,stat=,lstart=,comm='], { encoding: 'utf8', timeout: 2000, env: { ...process.env, TZ: 'UTC' } });
12
+ if (r.status !== 0)
13
+ throw new UsageError('Couldn’t check the local commands. Retry the move.');
14
+ return r.stdout.split('\n').flatMap(line => {
15
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line);
16
+ // Zombies and processes already exiting cannot write project files.
17
+ return m && !/[ZE]/.test(m[3]) ? [{ pid: Number(m[1]), parent: Number(m[2]), identity: m[4].replace(/\s+/g, ' ') }] : [];
18
+ });
19
+ }
20
+ function descendants(pid) {
21
+ const rows = processes(), ids = new Set([pid]);
22
+ for (;;) {
23
+ const before = ids.size;
24
+ for (const p of rows)
25
+ if (ids.has(p.parent))
26
+ ids.add(p.pid);
27
+ if (ids.size === before)
28
+ break;
29
+ }
30
+ return rows.filter(p => p.pid !== pid && ids.has(p.pid));
31
+ }
32
+ /** A standalone harness's commands cannot outlive the snapshot, even if its interruption leaves an orphan shell. */
33
+ async function stopCommands(owned) {
34
+ const remaining = () => processes().filter(p => owned.some(o => o.pid === p.pid && o.identity === p.identity));
35
+ const send = (p, sig) => { try {
36
+ process.kill(p.pid, sig);
37
+ }
38
+ catch (e) {
39
+ if (e.code !== 'ESRCH')
40
+ throw e;
41
+ } };
42
+ for (const p of remaining())
43
+ send(p, 'SIGTERM');
44
+ const deadline = Date.now() + 1000;
45
+ while (remaining().length && Date.now() < deadline)
46
+ await delay(50);
47
+ for (const p of remaining())
48
+ send(p, 'SIGKILL');
49
+ if (remaining().length) {
50
+ await delay(100);
51
+ if (remaining().length)
52
+ throw new UsageError('A local command has not stopped. Stop it locally and retry the move.');
53
+ }
54
+ }
55
+ /** Detached MCP children retain their parent chain; this also works for already-installed MCP servers. */
56
+ export function processAncestor(ancestor, child = process.pid) {
57
+ const r = spawnSync('ps', ['-axo', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
58
+ if (r.status !== 0)
59
+ throw new UsageError('Couldn’t check this session’s process. Retry the move.');
60
+ const parents = new Map(r.stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number)).map(([pid, parent]) => [pid, parent]));
61
+ const seen = new Set();
62
+ for (let p = child; p > 1 && !seen.has(p); p = parents.get(p) ?? 0) {
63
+ if (p === ancestor)
64
+ return true;
65
+ seen.add(p);
66
+ }
67
+ return false;
68
+ }
69
+ export function selfMove(s, env) {
70
+ const pid = sessionProcessPid(s, env);
71
+ return pid !== undefined && processAncestor(pid);
72
+ }
73
+ /**
74
+ * Whether a move stops the session's turn itself. A self-move, and a session whose live process this computer cannot
75
+ * name (a Claude Code without the session registry, a Codex thread whose lock more than one process holds), let the
76
+ * turn end first instead.
77
+ */
78
+ export function stopsLocalTurn(s, env) {
79
+ const pid = sessionProcessPid(s, env);
80
+ return pid !== undefined && !processAncestor(pid);
81
+ }
82
+ /** Shared Codex daemons need thread-scoped interrupt AND background-terminal cleanup: interrupt alone leaves Bash alive. */
83
+ export async function interruptCodexThread(s, env, signal) {
84
+ const turnId = sessionTurnId(s);
85
+ if (!turnId)
86
+ throw new UsageError('Couldn’t identify this Codex turn. Stop it locally and retry the move.');
87
+ const socket = realpathSync(join(codexHome(env), 'app-server-control', 'app-server-control.sock'));
88
+ const ws = new WebSocket(`ws+unix://${socket}:/`);
89
+ const pending = new Map();
90
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
91
+ let id = 0;
92
+ const fail = (error) => { for (const p of pending.values())
93
+ p.reject(error); pending.clear(); };
94
+ ws.on('error', fail);
95
+ ws.on('close', () => fail(new Error('Codex’s session control closed.')));
96
+ ws.on('message', data => {
97
+ let r;
98
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
99
+ try {
100
+ r = JSON.parse(bytes.toString('utf8'));
101
+ }
102
+ catch {
103
+ return;
104
+ }
105
+ const p = r.id === undefined ? undefined : pending.get(r.id);
106
+ if (!p)
107
+ return;
108
+ pending.delete(r.id);
109
+ if (r.error)
110
+ p.reject(new Error(`Codex could not stop the turn: ${JSON.stringify(r.error)}`));
111
+ else
112
+ p.resolve(r.result);
113
+ });
114
+ const abort = () => { fail(new Error('Codex did not confirm that the local turn stopped.')); ws.terminate(); };
115
+ bounded.addEventListener('abort', abort, { once: true });
116
+ const call = (method, params) => new Promise((resolve, reject) => {
117
+ bounded.throwIfAborted();
118
+ const next = ++id;
119
+ pending.set(next, { resolve, reject });
120
+ ws.send(JSON.stringify({ id: next, method, params }));
121
+ });
122
+ try {
123
+ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); bounded.addEventListener('abort', () => reject(bounded.reason), { once: true }); });
124
+ await call('initialize', { clientInfo: { name: 'shard_move', version: CLI_VERSION }, capabilities: { experimentalApi: true } });
125
+ ws.send(JSON.stringify({ method: 'initialized' }));
126
+ await call('turn/interrupt', { threadId: s.session_id, turnId });
127
+ await call('thread/backgroundTerminals/clean', { threadId: s.session_id });
128
+ }
129
+ finally {
130
+ bounded.removeEventListener('abort', abort);
131
+ ws.terminate();
132
+ }
133
+ }
134
+ /** Stop before the project snapshot. The native harness owns its interrupt records; the local transcript is read-only here. */
135
+ export async function interruptLocalTurn(ctx, s, env) {
136
+ if (sessionState(s, env).turn !== 'working')
137
+ return false;
138
+ const pid = sessionProcessPid(s, env);
139
+ if (!pid)
140
+ throw new UsageError('Couldn’t identify this session’s live process. Stop its local turn and retry the move.');
141
+ if (processAncestor(pid))
142
+ throw new UsageError('A session cannot interrupt its own turn to move.');
143
+ if (!ctx.json)
144
+ ctx.io.stderr.write('Stopping the local turn and continuing it in the cloud…\n');
145
+ const args = s.harness === 'codex' ? spawnSync('ps', ['-p', String(pid), '-o', 'args='], { encoding: 'utf8', timeout: 2000 }).stdout ?? '' : '';
146
+ const daemon = s.harness === 'codex' && args.includes('app-server') && args.includes('--managed-daemon');
147
+ if (daemon) {
148
+ // Only use the daemon that actually owns this thread’s lock; never connect to another CODEX_HOME.
149
+ if (!existsSync(join(codexHome(env), 'app-server-control', 'app-server-control.sock')))
150
+ throw new UsageError('Codex’s session control is unavailable. Stop it locally and retry the move.');
151
+ if (sessionProcessPid(s, env) !== pid)
152
+ throw new UsageError('This session’s process changed. Retry the move.');
153
+ await interruptCodexThread(s, env, ctx.signal);
154
+ }
155
+ else {
156
+ const owner = processes().find(p => p.pid === pid);
157
+ let owned = descendants(pid);
158
+ if (s.harness === 'claude') {
159
+ if (!sameClaudeProcess(s, env, pid))
160
+ throw new UsageError('This session’s process changed. Retry the move.');
161
+ }
162
+ else {
163
+ const comm = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], { encoding: 'utf8', timeout: 2000 }).stdout?.trim() ?? '';
164
+ if (sessionProcessPid(s, env) !== pid || !/^codex(?:\.exe)?$/.test(basename(comm)))
165
+ throw new UsageError('This session’s process changed. Retry the move.');
166
+ }
167
+ process.kill(pid, 'SIGINT');
168
+ await delay(100, undefined, { signal: ctx.signal });
169
+ const same = () => owner && processes().some(p => p.pid === pid && p.identity === owner.identity);
170
+ if (same()) {
171
+ // Interactive Claude's OS signal handler can leave the turn running. Terminate that verified standalone
172
+ // harness, after giving Desktop/exec their native clean-interrupt path; never signal a shared daemon here.
173
+ owned = [...owned, ...descendants(pid)];
174
+ process.kill(pid, 'SIGTERM');
175
+ await delay(100, undefined, { signal: ctx.signal });
176
+ if (same())
177
+ process.kill(pid, 'SIGKILL');
178
+ }
179
+ await stopCommands(owned);
180
+ }
181
+ const deadline = Date.now() + 5000;
182
+ while (sessionState(s, env).turn === 'working') {
183
+ // macOS can retain an exiting native process and its busy registry entry while kill(pid, 0) still succeeds.
184
+ // Its userspace and the owned commands above are stopped; do not wait for corpse reaping to snapshot.
185
+ if (!daemon && !processes().some(p => p.pid === pid))
186
+ break;
187
+ if (Date.now() >= deadline)
188
+ throw new UsageError('The local turn has not stopped. Stop it locally and retry the move.');
189
+ await delay(50, undefined, { signal: ctx.signal });
190
+ }
191
+ return true;
192
+ }
193
+ export const MOVE_CONTINUATION = 'Continue the same reply after the move to the cloud. The local turn was interrupted for this move. Redo the interrupted step from its beginning, including re-running the interrupted tool call; never skip to the next step. Everything completed before the interruption is in this project and session history. Finish the original request without waiting for another prompt.';
package/dist/move.js CHANGED
@@ -2,14 +2,15 @@
2
2
  * `shard move` (0.19.0+): a Claude Code or Codex session on this computer continues in a cloud conversation as the same
3
3
  * session, with its full history.
4
4
  *
5
- * 1. Waits until the session's current turn has ended (local-sessions.ts); a turn is never cut off.
5
+ * 1. Stops a user-moved session's local turn; an agent moving itself waits until its own turn ends.
6
6
  * 2. Starts a conversation from the live twin's snapshot of the project (uncommitted edits included; the working
7
7
  * tree, index and HEAD are not touched) whose record starts with the session (harness_session_id).
8
8
  * 3. Puts the transcript, its paths rewritten to the VM's folder, at the harness's native path in the VM and as the
9
9
  * conversation's durable copy in the project space, before the repository is seeded.
10
10
  * 4. The VM's guest resumes it (`claude --resume <id>`, `codex resume <id>`); this command attaches or detaches.
11
11
  * 5. Records the move here; the live twin brings the cloud work back to this checkout, and typing in the local copy
12
- * shows one line saying it diverges (a Claude Code UserPromptSubmit hook; the local process is never stopped).
12
+ * shows one line saying it diverges (a Claude Code UserPromptSubmit hook).
13
+ * 6. An interrupted turn receives a continuation prompt in the VM to redo the interrupted step and finish.
13
14
  */
14
15
  import { createHash } from 'node:crypto';
15
16
  import { execFileSync } from 'node:child_process';
@@ -34,6 +35,8 @@ import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
34
35
  import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, recordCwd, rewriteSessionPaths, sessionState } from "./local-sessions.js";
35
36
  import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
36
37
  import { captureWorktreeMigration, inspectWorktreeMigration, migrationScope, preserveWorktreeMigration, referencedWorktrees, rewriteWorktreeTranscript } from "./move-worktrees.js";
38
+ import { interruptLocalTurn, MOVE_CONTINUATION, stopsLocalTurn } from "./move-interrupt.js";
39
+ import { seedPhase } from "./seed-phases.js";
37
40
  const VM_HOME = '/home/user';
38
41
  const SPACE = '/shardflux/project';
39
42
  /** A transcript above this moves with the repository's history bound. */
@@ -213,13 +216,19 @@ export async function moveSession(ctx, values) {
213
216
  // worktrees and local branches stay on this computer, untouched (as `this-worktree-only` says explicitly).
214
217
  // --preserve-all-worktrees copies them too.
215
218
  const preserveAll = values['preserve-all-worktrees'] === true;
219
+ let interrupted = false;
216
220
  if (preserveAll && values['this-worktree-only'] === true)
217
221
  throw new UsageError('Choose --preserve-all-worktrees or --this-worktree-only.');
218
222
  if (values.plan !== true) {
219
223
  // A local MCP caller is still in its turn: an unrelated folder is refused while it can still answer.
220
224
  if (!preserveAll)
221
225
  confirmedCwds(s, completeRecords(readTranscript(s.file)), folder);
222
- await waitForTurnEnd(ctx, s, env);
226
+ if (stopsLocalTurn(s, env))
227
+ interrupted = await interruptLocalTurn(ctx, s, env);
228
+ else
229
+ await waitForTurnEnd(ctx, s, env);
230
+ if (interrupted)
231
+ seedPhase('local_turn_stopped');
223
232
  }
224
233
  const bytes = completeRecords(readTranscript(s.file));
225
234
  if (bytes.length > TRANSCRIPT_LIMIT)
@@ -273,6 +282,7 @@ export async function moveSession(ctx, values) {
273
282
  throw e;
274
283
  }
275
284
  const { conversation: c, workspace: ws } = created;
285
+ const terminalValues = interrupted ? { ...values, initialPrompt: MOVE_CONTINUATION } : values;
276
286
  const moved = { api_url: ctx.apiUrl, harness: s.harness, session_id: s.session_id, folder, file: s.file, bytes: bytes.length, sha256,
277
287
  conversation_id: c.id, key: c.key, title: s.title, moved_at: new Date().toISOString() };
278
288
  rememberMoved(ctx.store.dir, moved);
@@ -292,14 +302,14 @@ export async function moveSession(ctx, values) {
292
302
  const attach = !ctx.json && values.detach !== true && ctx.io.stdin.isTTY === true;
293
303
  if (!attach) {
294
304
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
295
- await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
305
+ await conversationTerminal(quiet, ws, c, place, { ...terminalValues, detach: true });
296
306
  out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}), ...(preserved ? { preserved } : {}), ...(elsewhere.length ? { other_worktrees: elsewhere } : {}) });
297
307
  return 0;
298
308
  }
299
309
  const where = openInChoices(ctx, project.slug)[s.harness];
300
310
  if (s.harness === 'claude' && (where === 'claude-desktop' || where === 'web')) {
301
311
  const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
302
- await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
312
+ await conversationTerminal(quiet, ws, c, place, { ...terminalValues, detach: true });
303
313
  if (where === 'claude-desktop')
304
314
  return openClaudeDesktopSession(ctx, ws, c);
305
315
  const url = await remoteControlUrl(ctx, ws, c);
@@ -309,7 +319,7 @@ export async function moveSession(ctx, values) {
309
319
  return 0;
310
320
  }
311
321
  ctx.io.stderr.write(`Moved to the cloud as ${c.key}: the same session, with its history.\n`);
312
- return conversationTerminal(ctx, ws, c, place, values);
322
+ return conversationTerminal(ctx, ws, c, place, terminalValues);
313
323
  }
314
324
  /** `shard move --list`: this project's sessions on this computer, and where each moved one continues. */
315
325
  export function listMovable(ctx, values, env, now = Date.now()) {
@@ -20,17 +20,26 @@ import { join } from 'node:path';
20
20
  /** Its VM is gone: the conversation ended (archived after the retention window, or done). */
21
21
  export const ended = (c) => !!c.ended_at || c.state === 'archived';
22
22
  const revision = (c) => JSON.stringify([c.changes, c.summary]);
23
+ /**
24
+ * A Codex Cloud thread (0.24.0+) lives on between turns, and Cloud's own thread state is its state: an idle thread (its
25
+ * turn finished) and one waiting on an approval, an answer or an error are both needs_you, and only the latter has a
26
+ * `question`. A landing is never recorded on it (the API ends a Cloud conversation that records one); the ledger keeps it.
27
+ */
28
+ const cloud = (c) => c.route === 'codex-cloud';
29
+ const asking = (c) => c.state === 'needs_you' && !(cloud(c) && !c.question);
23
30
  /** A turn ended with committed work only; the agent is not asking anything. */
24
31
  export function finished(c, agents) {
25
32
  if (ended(c))
26
33
  return 'state';
34
+ if (cloud(c))
35
+ return c.state === 'needs_you' && !c.question ? 'state' : null;
27
36
  const agent = agents.find(a => a.workspace_id === c.workspace_id && c.harness_session_id && a.id === `${c.workspace_id}:${c.harness_session_id}`);
28
37
  if (agent)
29
38
  return agent.state === 'working' ? null : agent;
30
39
  return c.state === 'unresolved' || c.state === 'done' ? 'state' : null;
31
40
  }
32
41
  export function eligible(c, ledger, now) {
33
- if (c.route === 'codex-cloud' || c.join_mode === 'reviewer' || c.state === 'needs_you')
42
+ if (c.join_mode === 'reviewer' || asking(c))
34
43
  return false;
35
44
  if (!c.changes || c.changes.commits <= 0 || c.changes.uncommitted || c.landed?.how === 'discarded')
36
45
  return false;
@@ -40,7 +49,7 @@ export function eligible(c, ledger, now) {
40
49
  return false;
41
50
  if ((st.retry_at ?? 0) > now)
42
51
  return false;
43
- if (st.landed_revision === revision(c) && (ended(c) || c.landed?.how === 'merged' || c.landed?.how === 'pr'))
52
+ if (st.landed_revision === revision(c) && (ended(c) || cloud(c) || c.landed?.how === 'merged' || c.landed?.how === 'pr'))
44
53
  return false;
45
54
  return st.conflict_revision !== revision(c);
46
55
  }
@@ -118,7 +127,7 @@ export class TwinEngine {
118
127
  const listedAt = d.now();
119
128
  const all = (await d.conversations(project)).filter(c => mine.has(c.id));
120
129
  for (const c of all)
121
- if (!ended(c) && c.route !== 'codex-cloud' && c.join_mode !== 'reviewer') {
130
+ if (!ended(c) && c.join_mode !== 'reviewer') {
122
131
  watched.add(c.id);
123
132
  this.#watch(c, listedAt, signal);
124
133
  }
@@ -236,7 +245,7 @@ export class TwinEngine {
236
245
  }
237
246
  // Landed at this revision already; only the API record is missing (a later report reset it on an older guest).
238
247
  if (st.landed_revision === rev) {
239
- if (ended(c))
248
+ if (ended(c) || cloud(c))
240
249
  return;
241
250
  if ((this.#posted.get(c.id) ?? 0) + 30_000 < d.now()) {
242
251
  this.#posted.set(c.id, d.now());
@@ -354,8 +363,9 @@ export class TwinEngine {
354
363
  if (o.state !== 'landed')
355
364
  vm.close();
356
365
  const post = async (action, ref) => {
357
- // An ended conversation cannot record a landing any more (the API refuses); the ledger keeps it.
358
- if (ended(c))
366
+ // An ended conversation cannot record a landing any more (the API refuses), and a Codex Cloud thread goes on after
367
+ // one; the ledger keeps both.
368
+ if (ended(c) || cloud(c))
359
369
  return;
360
370
  this.#posted.set(c.id, d.now());
361
371
  await d.land(c, action, ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
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 serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -46,21 +46,23 @@
46
46
  "classic-level": "3.0.0",
47
47
  "smol-toml": "1.9.0",
48
48
  "snappyjs": "0.7.0",
49
+ "ws": "8.22.0",
49
50
  "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.27.1"
51
+ "@shardflux/sdk": "^0.27.2"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@eslint/js": "10.0.1",
54
55
  "@types/node": "24.13.6",
56
+ "@types/ws": "8.18.2",
55
57
  "eslint": "10.11.0",
56
58
  "playwright": "1.62.1",
57
59
  "typescript": "5.9.3",
58
60
  "typescript-eslint": "8.70.1"
59
61
  },
60
62
  "optionalDependencies": {
61
- "@shardflux/desktop-linux-x64": "0.1.10",
62
- "@shardflux/desktop-linux-arm64": "0.1.10",
63
- "@shardflux/menubar-darwin": "^0.8.5"
63
+ "@shardflux/desktop-linux-arm64": "0.1.12",
64
+ "@shardflux/desktop-linux-x64": "0.1.12",
65
+ "@shardflux/menubar-darwin": "^0.8.6"
64
66
  },
65
67
  "scripts": {
66
68
  "build": "node scripts/build.mjs",