@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 +24 -0
- package/README.md +32 -7
- package/dist/account-args.js +3 -3
- package/dist/account-commands.js +15 -2
- package/dist/acp-journal.d.ts +2 -2
- package/dist/acp-journal.js +14 -4
- package/dist/acp.js +30 -8
- package/dist/args.d.ts +1 -1
- package/dist/args.js +18 -8
- package/dist/conversation-commands.js +16 -2
- package/dist/conversations.js +6 -1
- package/dist/default-volume-commands.d.ts +3 -0
- package/dist/default-volume-commands.js +35 -0
- package/dist/desktop-account.d.ts +16 -0
- package/dist/desktop-account.js +29 -0
- package/dist/device-bridge.js +18 -2
- package/dist/filesystem-commands.js +2 -1
- package/dist/local-sessions.d.ts +7 -1
- package/dist/local-sessions.js +38 -0
- package/dist/menubar.js +2 -0
- package/dist/move-interrupt.d.ts +18 -0
- package/dist/move-interrupt.js +193 -0
- package/dist/move.js +16 -6
- package/dist/worktree-twin-engine.js +16 -6
- package/package.json +7 -5
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
package/dist/account-args.js
CHANGED
|
@@ -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'
|
|
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',
|
|
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'],
|
package/dist/account-commands.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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) }));
|
package/dist/acp-journal.d.ts
CHANGED
|
@@ -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;
|
package/dist/acp-journal.js
CHANGED
|
@@ -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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
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
|
|
462
|
-
if (
|
|
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
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.
|
|
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
|
-
|
|
1369
|
-
|
|
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
|
|
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,
|
|
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; },
|
package/dist/conversations.js
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/device-bridge.js
CHANGED
|
@@ -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 () => {
|
|
327
|
-
|
|
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
|
-
|
|
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}` : '';
|
package/dist/local-sessions.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/local-sessions.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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, { ...
|
|
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, { ...
|
|
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,
|
|
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.
|
|
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.
|
|
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)
|
|
358
|
-
|
|
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.
|
|
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.
|
|
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-
|
|
62
|
-
"@shardflux/desktop-linux-
|
|
63
|
-
"@shardflux/menubar-darwin": "^0.8.
|
|
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",
|