@shardflux/cli 0.13.6 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +44 -4
  3. package/dist/acp-catalog.d.ts +7 -0
  4. package/dist/acp-catalog.js +40 -0
  5. package/dist/acp-journal.d.ts +18 -0
  6. package/dist/acp-journal.js +232 -0
  7. package/dist/acp-models.d.ts +36 -0
  8. package/dist/acp-models.js +161 -0
  9. package/dist/acp-rpc.d.ts +41 -0
  10. package/dist/acp-rpc.js +115 -0
  11. package/dist/acp.d.ts +29 -0
  12. package/dist/acp.js +464 -0
  13. package/dist/agent-commands.d.ts +10 -2
  14. package/dist/agent-commands.js +37 -18
  15. package/dist/agent-machine.d.ts +6 -0
  16. package/dist/agent-machine.js +93 -0
  17. package/dist/agent-repository.d.ts +2 -0
  18. package/dist/agent-repository.js +42 -31
  19. package/dist/agent-state.d.ts +3 -0
  20. package/dist/agent-state.js +33 -12
  21. package/dist/agent-target-commands.d.ts +4 -1
  22. package/dist/agent-target-commands.js +110 -40
  23. package/dist/agent-targets.d.ts +6 -4
  24. package/dist/agent-targets.js +72 -31
  25. package/dist/args.d.ts +1 -1
  26. package/dist/args.js +53 -14
  27. package/dist/command-center-actions.js +26 -3
  28. package/dist/command-center.js +20 -4
  29. package/dist/conversation-commands.d.ts +3 -0
  30. package/dist/conversation-commands.js +36 -0
  31. package/dist/conversation-local-resume.d.ts +8 -0
  32. package/dist/conversation-local-resume.js +56 -0
  33. package/dist/conversation-local.d.ts +7 -0
  34. package/dist/conversation-local.js +61 -0
  35. package/dist/conversation-router.d.ts +12 -0
  36. package/dist/conversation-router.js +92 -0
  37. package/dist/conversation-stash.d.ts +11 -0
  38. package/dist/conversation-stash.js +158 -0
  39. package/dist/conversation-terminal.d.ts +3 -0
  40. package/dist/conversation-terminal.js +74 -0
  41. package/dist/conversation-worktree.d.ts +4 -0
  42. package/dist/conversation-worktree.js +22 -0
  43. package/dist/conversations.d.ts +53 -0
  44. package/dist/conversations.js +119 -0
  45. package/dist/device-bridge.d.ts +80 -0
  46. package/dist/device-bridge.js +409 -0
  47. package/dist/device-helper.d.ts +3 -0
  48. package/dist/device-helper.js +104 -0
  49. package/dist/errors.js +5 -0
  50. package/dist/format.js +3 -2
  51. package/dist/git-remote-bin.js +3 -1
  52. package/dist/git-remote.d.ts +3 -1
  53. package/dist/git-remote.js +2 -1
  54. package/dist/handlers.js +3 -1
  55. package/dist/main.js +15 -3
  56. package/dist/project-sync.d.ts +5 -1
  57. package/dist/project-sync.js +7 -6
  58. package/dist/t3-provider.d.ts +6 -0
  59. package/dist/t3-provider.js +99 -0
  60. package/dist/t3-runtime.d.ts +6 -0
  61. package/dist/t3-runtime.js +63 -0
  62. package/dist/target-lock.d.ts +3 -0
  63. package/dist/target-lock.js +143 -0
  64. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.14.1
4
+
5
+ - `shard ssh-proxy` connects faster: host trust (host key pin and device key) runs alongside the tunnel start, with no guest byte reaching OpenSSH before the host key is pinned, and a reconnect checks its device key with one read.
6
+
7
+ ## 0.14.0
8
+
9
+ - Ask for device approval before preparing the VM-backed local worktree; report a failed preparation as the request's result and bound preparation to 30 seconds.
10
+ - Keep desktop programs running after a command returns with detached `setsid -f` guidance.
11
+ - Allocate one VM per new ACP conversation, with cached initial model choices and live updates from that same VM.
12
+
13
+ - Populate ACP model choices from the cloud adapters for each session, following the VM's native Claude Code and Codex executables. Refresh choices on attachment and model updates, and remove models the live adapter rejects.
14
+ - Register the bound Shardflux conversation MCP server for cloud ACP sessions, so plain chat can fork, join, move, land and request device actions.
15
+ - Request valid 64 MiB file manifests when mirroring native transcripts; the device helper and native history handoff no longer fail with invalid argument.
16
+ - Carry Git fetch/push through the platform binary transport so native protocol flush packets arrive before stdin EOF.
17
+ - Seed the shared project hub before writing Desktop connections so a new thread can initialize its separate VM.
18
+
19
+ - Parallel conversation launches preserve both sessions in local history.
20
+
21
+ - Help an agent can set up cloud agents from: `shard --help` has a Cloud agents section (conversations, machine, template, agents) and says how a VM is made (the coding template, then your machine, then the project template), that machine and project templates are saved from an agent's VM, that `shard templates` is for your own app's workspaces (a repository bound to one is refused with `template_not_agent_capable`), and that Claude Code and Codex in the VM have the `shardflux-computer` tools. `shard machine`, `shard template` and `shard templates init|build|draft open` help say the same.
22
+ - Register one desktop app connection per project, named Shardflux · <project>; preserve older SSH aliases and CLI 0.13.x target manifests.
23
+ - Resume saved conversations in a fresh VM under the same identity; open a read-only project-space reader for final branches and transcripts.
24
+ - Conversation follow-up: read-only reviewer joins, Try locally device requests, and project usage with workspace-scoped guest resize.
25
+
26
+ - **Breaking:** `shard ./` starts a conversation in its own VM and opens Claude Code or Codex directly. `--resume` selects a saved project conversation. Two-way sync is now opened with `shard conversations try <name>` in an isolated local worktree.
27
+ - Register one Shardflux provider in T3 Code silently, with Claude Code and Codex models. ACP sessions attach to guest supervision and replay missed updates after reconnect.
28
+ - Run device tools through the Mac helper with local approvals and project grants; mirror transcripts into the Shardflux stash and hand ended sessions to native history after fetching their branch.
29
+ - Volume lists and details show `kind`; project-space quota is labelled platform managed. Includes SDK 0.19.0.
30
+ - Add project conversations with durable VM bindings, messages, moves, landing records and device requests.
31
+
32
+ - Local connections to the target-update lock cannot keep its owner running after release.
33
+
34
+ - `ssh-proxy` closes its guest forwarder on EOF, signals and transport failure, reuses the cached helper, and waits for target updates with automatic recovery of dead PID owners.
35
+ - `ssh-proxy` finishes when the guest closes its output, including sshd's pre-authentication admission refusals, so OpenSSH receives EOF and reconnects without hanging.
36
+
37
+ - Fork externally started desktop/SDK agent workspaces as full-state copies without a managed PTY receipt; give copied T3 workspaces their own identity even when an older platform's PTY repair fails.
38
+ - The managed `~/.ssh/config` Include line is written unquoted when the path needs no quotes, so tools that list SSH hosts from it (T3 Code) find Shardflux hosts.
39
+ - Open cloud projects in T3 Code with `shard agents open t3 <key>`, prewarm its matching server in the background, name environments `Shardflux · <project>`, and give forked agents a separate T3 identity.
40
+ - `shard ssh-proxy` sends a prewake hint and uses a passive tunnel so open desktop SSH connections join the existing parking lifecycle.
41
+ - `shard ssh-proxy` connects to a workspace whose host has no passive attach as an ordinary SSH connection.
42
+ - `shard` exits quietly when the reader has closed its output (OpenSSH after a `ssh-proxy` session, `| head`), instead of printing a write error.
43
+ - An error with several causes (tunnel teardown, cleanup) names each cause, and its first cause decides the error code.
44
+
3
45
  ## 0.13.6
4
46
 
5
47
  - `shard` makes the macOS menu bar app's executable runnable when it installs or starts the app, so a menu bar copied
package/README.md CHANGED
@@ -52,7 +52,7 @@ Use `shard` for your projects, machine, templates and account.
52
52
 
53
53
  ## Quick start
54
54
 
55
- Start your coding agent on a local project (0.13.2+):
55
+ Start a conversation on a local project (0.14.0+):
56
56
 
57
57
  ```sh
58
58
  shard setup
@@ -61,10 +61,27 @@ shard ./
61
61
  ```
62
62
 
63
63
  Choose Claude Code or Codex. Claude asks for one cloud sign-in per Shardflux account; Codex uses your copied login.
64
- The agent is usable immediately while setup prepares the project in the background. `shard` opens the command
65
- center for every project; `shard ./` returns to this project. On macOS the menu bar starts automatically the first
64
+ Each `shard ./` starts its own conversation VM and opens your agent’s native terminal. Later turns stay in that
65
+ VM; `shard ./ --resume` selects a saved project conversation. Native terminal and ACP sessions receive the conversation tools for their own conversation, so the agent can fork, ask for a review, and report to its parent (0.14.0+). You can open conversations in separate terminals at the same time; each stays in the project's local history. `shard` opens the optional command center. On macOS the menu bar starts automatically the first
66
66
  time `shard` runs, with launch at login enabled; `shard menubar stop` turns it off. See [Cloud agents](https://docs.shardflux.dev/guides/cloud-agents).
67
67
 
68
+ T3 Code uses one **Shardflux** provider; choose a Claude Code or Codex model to start that agent in the cloud.
69
+ Model choices come from your cloud agent's session and use the same coding tools and settings as its terminal.
70
+ The provider is registered automatically when T3 Code is installed. Use a T3 build that supports ACP providers.
71
+
72
+ Git fetch and push use the coding guest’s platform transport when available, preserving Git’s binary packets (0.14.0+). Older guests retain their existing transport.
73
+
74
+ To try a conversation on your Mac:
75
+
76
+ ```sh
77
+ shard conversations try <conversation>
78
+ ```
79
+
80
+ Your branch opens in `.shard/worktrees/<name>`, syncs both ways, and its dev servers appear at localhost.
81
+ Ctrl-C stops local sync. Your main checkout stays available for your own work.
82
+ Device tools ask Allow once or Always for this project on your Mac before preparing the conversation’s local worktree. Live transcripts stay in
83
+ `~/.config/shardflux/conversations/`; ended sessions enter Claude Code’s or Codex’s native history. Transcript files up to 64 MiB are supported (0.14.0+).
84
+
68
85
  For application workspace commands, `shard signup` creates an account and saves a project key, or set
69
86
  `SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
70
87
 
@@ -1163,4 +1180,27 @@ Remembered credentials: stored encrypted with AWS KMS and filled straight into t
1163
1180
  [Machine and template](https://docs.shardflux.dev/guides/machine-and-template) ·
1164
1181
  [Credentials](https://docs.shardflux.dev/guides/credentials)
1165
1182
 
1166
- `shard templates` remains the platform template build and management group.
1183
+ A VM is the `coding` template, then your machine, then the project template. Machine and project templates are
1184
+ always saved from an agent's VM, so they keep Claude Code, Codex and the agent runtime. `shard templates` builds
1185
+ templates for your own app's workspaces (SDK/API), not for cloud agents: a repository bound to one of those is
1186
+ refused with `template_not_agent_capable` (0.14.0+).
1187
+
1188
+ Inside the VM, Claude Code and Codex have Shardflux computer use next to their own tools (0.14.0+, `coding` 13+): the
1189
+ `shardflux-computer` MCP server's `computer` and `computer_batch` drive the VM's desktop. The agent starts a program
1190
+ on it with `DISPLAY=:1`, detached so it outlives the command, for example
1191
+ `DISPLAY=:1 setsid -f chromium http://localhost:3000`, then takes a screenshot.
1192
+
1193
+ Volume lists and details (0.14.0+) show `kind`. Project spaces show their measured storage and a platform-managed quota.
1194
+
1195
+ ## Conversations
1196
+
1197
+ From 0.14.0, `shard conversations list|get|send|wait|land|move` manages project conversations. `shard c` is the short form. Conversations retain their branch and transcript as their VM binding changes.
1198
+
1199
+
1200
+ Desktop project connections (0.14.0+): `shard agents open codex-app <project>/<workspace>` and
1201
+ `shard agents open claude-desktop <project>/<workspace>` register **Shardflux · <project>** in the app.
1202
+ Open that project connection to start conversations and return to their saved threads. The project hub supplies the base commit to each new thread’s VM (0.14.0+).
1203
+
1204
+ Conversation JSON includes `join_mode` and the worktree used by reviewers and workers.
1205
+
1206
+ `shard conversations resume <id>` continues a saved conversation in a fresh VM under the same identity (0.14.0+). `shard ./ --resume` uses this same path. `shard conversations reader <project-id>` opens a small read-only workspace for saved branches and transcripts; the Mac transcript mirror opens it automatically when no project VM is live. Adaptive idle reclaims the reader.
@@ -0,0 +1,7 @@
1
+ import type { ObjectValue } from './acp-rpc.js';
2
+ export type AcpApp = 'claude' | 'codex';
3
+ export type AcpCatalog = Partial<Record<AcpApp, ObjectValue>>;
4
+ export declare const ACP_PROBE_CATALOG: AcpCatalog;
5
+ export declare function nativeModelOptions(session: ObjectValue): ObjectValue[];
6
+ export declare function nativeCurrentModel(session: ObjectValue): string;
7
+ export declare function withoutNativeModel(session: ObjectValue, id: string): ObjectValue;
@@ -0,0 +1,40 @@
1
+ import { objectValue } from "./acp-rpc.js";
2
+ // T3's installation probe works offline, without a VM. These are harness
3
+ // labels only. Real sessions always replace them with guest metadata.
4
+ export const ACP_PROBE_CATALOG = {
5
+ claude: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: 'Claude Code' }] } },
6
+ codex: { models: { currentModelId: 'default', availableModels: [{ modelId: 'default', name: 'Codex' }] } },
7
+ };
8
+ export function nativeModelOptions(session) {
9
+ const configs = Array.isArray(session.configOptions) ? session.configOptions.map(objectValue) : [];
10
+ const model = configs.find(c => c.category === 'model' || c.id === 'model');
11
+ const options = model && Array.isArray(model.options)
12
+ ? model.options.flatMap(raw => {
13
+ const option = objectValue(raw);
14
+ return Array.isArray(option.options) ? option.options.map(objectValue) : [option];
15
+ })
16
+ : (Array.isArray(objectValue(session.models).availableModels) ? objectValue(session.models).availableModels : [])
17
+ .map(raw => { const m = objectValue(raw); return { value: m.modelId, name: m.name, ...(m.description ? { description: m.description } : {}) }; });
18
+ const seen = new Set();
19
+ return options.map(option => {
20
+ if (typeof option.value !== 'string' || !option.value || typeof option.name !== 'string' || !option.name || seen.has(option.value))
21
+ throw new Error('Agent model metadata omitted a unique ID or name');
22
+ seen.add(option.value);
23
+ return option;
24
+ });
25
+ }
26
+ export function nativeCurrentModel(session) {
27
+ const configs = Array.isArray(session.configOptions) ? session.configOptions.map(objectValue) : [];
28
+ const current = configs.find(c => c.category === 'model' || c.id === 'model')?.currentValue ?? objectValue(session.models).currentModelId;
29
+ const options = nativeModelOptions(session);
30
+ if (typeof current === 'string' && options.some(o => o.value === current))
31
+ return current;
32
+ throw new Error('The guest adapter returned no current model among its available models.');
33
+ }
34
+ export function withoutNativeModel(session, id) {
35
+ const options = nativeModelOptions(session).filter(o => o.value !== id);
36
+ const configs = Array.isArray(session.configOptions) ? session.configOptions.map(objectValue) : [];
37
+ const current = configs.find(c => c.category === 'model' || c.id === 'model')?.currentValue ?? objectValue(session.models).currentModelId;
38
+ return { ...session, configOptions: [{ id: 'model', category: 'model', type: 'select', options,
39
+ ...(options.some(o => o.value === current) ? { currentValue: current } : {}) }, ...configs.filter(c => c.category !== 'model' && c.id !== 'model')] };
40
+ }
@@ -0,0 +1,18 @@
1
+ import type { CellClient } from '@shardflux/sdk';
2
+ import { AcpRpc } from './acp-rpc.js';
3
+ import type { RpcId } from './acp-rpc.js';
4
+ export interface AcpCursor {
5
+ from_seq: number;
6
+ prompt_id?: RpcId;
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";
9
+ /** Replay is finite; a live follower exists only while a turn/RPC needs output. Native supervision is CVB's. */
10
+ export declare class AcpJournal {
11
+ #private;
12
+ readonly rpc: AcpRpc;
13
+ constructor(cell: CellClient, conversation: string, _harness: 'claude' | 'codex', cwd: string, cursor: AcpCursor, save: () => void, signal: AbortSignal);
14
+ start(): Promise<void>;
15
+ pause(): void;
16
+ resume(): void;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,232 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { AcpRpc } from "./acp-rpc.js";
4
+ // InputACP's private, same-uid socket receipt protocol. Stdin avoids Linux's 128 KiB per-argument limit.
5
+ export const ACP_INPUT_SCRIPT = `import json, os, socket, sys
6
+ payload = sys.stdin.buffer.readline((2 << 20) + 1024)
7
+ if not payload.endswith(b'\\n') or len(json.dumps(json.loads(payload)['message'], separators=(',', ':'), ensure_ascii=False).encode()) > 2 << 20:
8
+ raise ValueError('ACP input exceeds 2 MiB')
9
+ folder = os.open(sys.argv[1], os.O_RDONLY)
10
+ try:
11
+ with socket.socket(socket.AF_UNIX) as peer:
12
+ peer.settimeout(10)
13
+ address = os.path.join(sys.argv[1], 'control.sock')
14
+ if len(address.encode()) >= 108: address = '/proc/self/fd/%d/control.sock' % folder
15
+ peer.connect(address)
16
+ peer.sendall(payload)
17
+ response = peer.makefile('rb').readline(65536)
18
+ result = json.loads(response)
19
+ if result.get('accepted') is not True: raise ValueError(result.get('error', 'ACP input was not acknowledged'))
20
+ sys.stdout.buffer.write(response)
21
+ finally:
22
+ os.close(folder)
23
+ `;
24
+ /** Replay is finite; a live follower exists only while a turn/RPC needs output. Native supervision is CVB's. */
25
+ export class AcpJournal {
26
+ rpc;
27
+ #cell;
28
+ #conversation;
29
+ #cwd;
30
+ #cursor;
31
+ #save;
32
+ #controller = new AbortController();
33
+ #signal;
34
+ #initial;
35
+ #ready = false;
36
+ #reading;
37
+ #reader;
38
+ #follower;
39
+ #writing = Promise.resolve();
40
+ #paused = 0;
41
+ constructor(cell, conversation, _harness, cwd, cursor, save, signal) {
42
+ if (!/^[a-zA-Z0-9_-]{1,96}$/.test(conversation))
43
+ throw new Error('Invalid ACP conversation identity');
44
+ this.#cell = cell;
45
+ this.#conversation = conversation;
46
+ this.#cwd = cwd;
47
+ this.#cursor = cursor;
48
+ this.#save = save;
49
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
50
+ this.rpc = new AcpRpc(message => {
51
+ const encoded = JSON.stringify(message);
52
+ if (Buffer.byteLength(encoded) > 2 << 20)
53
+ return Promise.reject(new Error('ACP input exceeds 2 MiB'));
54
+ const inputId = randomUUID();
55
+ if (message.method === 'session/prompt' && message.id != null) {
56
+ this.#cursor.prompt_id = message.id;
57
+ this.#save();
58
+ }
59
+ const run = this.#writing.then(async () => {
60
+ await this.#deliver(inputId, encoded);
61
+ this.#follow();
62
+ });
63
+ this.#writing = run.catch(error => { this.rpc.close(error instanceof Error ? error : new Error(String(error))); });
64
+ return run;
65
+ });
66
+ this.rpc.onIdle = () => { if (!this.#wanted())
67
+ this.#reader?.abort(); };
68
+ }
69
+ #wanted() { return !this.#signal.aborted && !this.rpc.closed && this.#paused === 0 && (this.rpc.busy || this.#cursor.prompt_id !== undefined); }
70
+ async #deliver(inputId, message) {
71
+ const dir = `/home/user/.local/share/shardflux/acp/${this.#conversation}`;
72
+ const payload = Buffer.from(`{"mode":"input","input_id":${JSON.stringify(inputId)},"message":${message}}\n`);
73
+ 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);
74
+ let offset = 0, stdout = '';
75
+ try {
76
+ while (offset < payload.length) {
77
+ const bytes = payload.subarray(offset, offset + 65536);
78
+ const ack = await this.#cell.exec.input(session.session_id, bytes, { offset, close: offset + bytes.length === payload.length, signal: this.#signal });
79
+ const accepted = ack.offset - offset;
80
+ if (!Number.isSafeInteger(accepted) || accepted <= 0 || accepted > bytes.length || (ack.closed && ack.offset !== payload.length))
81
+ throw new Error('Invalid ACP input acknowledgement');
82
+ offset = ack.offset;
83
+ }
84
+ let exit;
85
+ for await (const event of await this.#cell.exec.output(session.session_id, { follow: true, signal: this.#signal })) {
86
+ if (event.type === 'error')
87
+ throw new Error(event.error?.error.message ?? 'ACP input failed');
88
+ if (event.type === 'exit')
89
+ exit = event.session?.exit_code;
90
+ if (event.type === 'output' && event.stream === 'stdout' && event.data)
91
+ stdout += Buffer.from(event.data, 'base64').toString();
92
+ if (stdout.length > 65536)
93
+ throw new Error('Invalid ACP input receipt');
94
+ }
95
+ if (exit !== 0 || JSON.parse(stdout).accepted !== true)
96
+ throw new Error('ACP delivery is unverified; load this session before continuing.');
97
+ }
98
+ finally {
99
+ await this.#cell.exec.cancel(session.session_id, 0).catch(() => undefined);
100
+ }
101
+ // Never resend an uncertain mutation. Its stable input identity is fenced by CVB before provider stdin.
102
+ }
103
+ start() {
104
+ this.#initial ??= this.#replay().then(() => { this.#ready = true; this.#follow(); }).catch(error => { this.rpc.close(error instanceof Error ? error : new Error(String(error))); throw error; });
105
+ return this.#initial;
106
+ }
107
+ pause() { this.#paused++; this.#reader?.abort(); void this.#detach(); }
108
+ resume() { this.#paused = Math.max(0, this.#paused - 1); this.#follow(); }
109
+ async #entry(line) {
110
+ if (!line.trim())
111
+ return;
112
+ const entry = JSON.parse(line);
113
+ if (entry.error)
114
+ throw new Error(entry.error);
115
+ if (!Number.isSafeInteger(entry.seq) || entry.seq < 1)
116
+ throw new Error('Invalid ACP journal entry');
117
+ if (entry.seq <= this.#cursor.from_seq)
118
+ return;
119
+ if (entry.seq !== this.#cursor.from_seq + 1)
120
+ throw new Error('ACP journal has a sequence gap');
121
+ if (entry.message) {
122
+ if (entry.message.method === undefined && entry.message.id === this.#cursor.prompt_id) {
123
+ delete this.#cursor.prompt_id;
124
+ }
125
+ this.rpc.feed(Buffer.from(JSON.stringify(entry.message) + '\n'));
126
+ await this.rpc.drain();
127
+ }
128
+ this.#cursor.from_seq = entry.seq;
129
+ this.#save();
130
+ if (entry.ended)
131
+ this.rpc.close(new Error('Native ACP session ended'));
132
+ }
133
+ async #replay() {
134
+ const path = `/home/user/.local/share/shardflux/acp/${this.#conversation}/journal.ndjson`;
135
+ this.#signal.throwIfAborted();
136
+ const snapshot = await this.#cell.files.stat(path);
137
+ const decoder = new StringDecoder('utf8');
138
+ let tail = '';
139
+ for (let offset = 0; offset < snapshot.size;) {
140
+ this.#signal.throwIfAborted();
141
+ const bytes = await this.#cell.files.read(path, { offset, length: Math.min(1 << 20, snapshot.size - offset) });
142
+ if (!bytes.length)
143
+ throw new Error('ACP journal changed during replay');
144
+ offset += bytes.length;
145
+ tail += decoder.write(bytes);
146
+ let newline;
147
+ while ((newline = tail.indexOf('\n')) >= 0) {
148
+ const line = tail.slice(0, newline);
149
+ tail = tail.slice(newline + 1);
150
+ await this.#entry(line);
151
+ }
152
+ if (Buffer.byteLength(tail) > 4 << 20)
153
+ throw new Error('ACP journal frame exceeds 4 MiB');
154
+ }
155
+ // An append can straddle the snapshot cut. The follower replays that whole frame from the saved sequence.
156
+ decoder.end();
157
+ }
158
+ #follow() {
159
+ if (!this.#ready || this.#reading || !this.#wanted())
160
+ return;
161
+ const reader = new AbortController();
162
+ this.#reader = reader;
163
+ this.#reading = this.#read(reader).finally(() => { this.#reading = undefined; if (this.#reader === reader)
164
+ this.#reader = undefined; this.#follow(); });
165
+ }
166
+ async #read(reader) {
167
+ const signal = AbortSignal.any([this.#signal, reader.signal]);
168
+ try {
169
+ let failures = 0;
170
+ while (this.#wanted() && !signal.aborted) {
171
+ const decoder = new StringDecoder('utf8');
172
+ let tail = '', offset = 0;
173
+ try {
174
+ const session = await this.#cell.exec.start({ argv: ['sf-agent', 'acp', 'attach', '--conversation', this.#conversation, '--from-seq', String(this.#cursor.from_seq), '--follow'], cwd: this.#cwd, timeout_ms: 0 }, signal);
175
+ this.#follower = session.session_id;
176
+ for await (const event of await this.#cell.exec.output(session.session_id, { follow: true, signal })) {
177
+ if (event.type === 'error')
178
+ throw new Error(event.error?.error.message ?? 'ACP journal failed');
179
+ if (event.type === 'exit')
180
+ throw new Error('ACP journal detached');
181
+ if (event.type !== 'output' || event.stream !== 'stdout' || !event.data)
182
+ continue;
183
+ const bytes = Buffer.from(event.data, 'base64'), at = event.offset ?? offset;
184
+ if (at > offset)
185
+ throw new Error('ACP follower output has a byte gap');
186
+ const fresh = bytes.subarray(Math.max(0, offset - at));
187
+ offset = Math.max(offset, at + bytes.length);
188
+ tail += decoder.write(fresh);
189
+ for (let newline; (newline = tail.indexOf('\n')) >= 0;) {
190
+ const line = tail.slice(0, newline);
191
+ tail = tail.slice(newline + 1);
192
+ await this.#entry(line);
193
+ failures = 0;
194
+ }
195
+ if (Buffer.byteLength(tail) > 4 << 20)
196
+ throw new Error('ACP journal frame exceeds 4 MiB');
197
+ if (!this.#wanted())
198
+ return;
199
+ }
200
+ if (!signal.aborted)
201
+ throw new Error('ACP journal connection ended');
202
+ }
203
+ catch (error) {
204
+ if (signal.aborted)
205
+ break;
206
+ if (++failures > 3)
207
+ throw error;
208
+ }
209
+ finally {
210
+ await this.#detach();
211
+ }
212
+ }
213
+ }
214
+ catch (error) {
215
+ this.rpc.close(error instanceof Error ? error : new Error(String(error)));
216
+ }
217
+ }
218
+ async #detach() {
219
+ const follower = this.#follower;
220
+ this.#follower = undefined;
221
+ if (follower)
222
+ await this.#cell.exec.cancel(follower, 0).catch(() => undefined);
223
+ }
224
+ async close() {
225
+ this.#controller.abort();
226
+ this.rpc.close();
227
+ await this.#writing;
228
+ await this.#initial?.catch(() => undefined);
229
+ await this.#reading;
230
+ await this.#detach();
231
+ }
232
+ }
@@ -0,0 +1,36 @@
1
+ import type { Workspace, WorkspaceView } from '@shardflux/sdk';
2
+ import type { Ctx } from './commands.js';
3
+ import type { Conversation } from './conversations.js';
4
+ import type { AcpCatalog } from './acp-catalog.js';
5
+ /** No prompts, login, logout or credential writes. Every child process group is
6
+ * terminated, including initialization failures and interrupted clients. */
7
+ export declare const ACP_MODELS_SCRIPT: string;
8
+ export interface AcpScope {
9
+ apiUrl: string;
10
+ organizationId: string;
11
+ template: string;
12
+ }
13
+ export declare function acpScope(apiUrl: string, ws: Pick<WorkspaceView, 'organization_id' | 'template'>): AcpScope;
14
+ /** Model payloads are account/harness/template scoped. The credential fingerprint
15
+ * only locates the last verified binding; it is never stored or logged in clear. */
16
+ export declare class AcpModelCache {
17
+ #private;
18
+ constructor(dir: string);
19
+ lookup(ctx: Ctx, folder: string): {
20
+ scope: AcpScope;
21
+ catalog: AcpCatalog;
22
+ } | undefined;
23
+ remember(ctx: Ctx, folder: string, scope: AcpScope): void;
24
+ read(scope: AcpScope): AcpCatalog;
25
+ write(scope: AcpScope, catalog: AcpCatalog): void;
26
+ }
27
+ export declare function discoverAcpCatalog(ctx: Ctx, ws: Workspace, root: string): Promise<AcpCatalog>;
28
+ export interface AcpPreparation {
29
+ allocated(conversation: Conversation, workspace: WorkspaceView): void;
30
+ ready(workspace: Workspace): void;
31
+ prepared(): void;
32
+ }
33
+ export interface AcpLifecycle {
34
+ prepare(ctx: Ctx, folder: string, name: string, callbacks: AcpPreparation): Promise<AcpCatalog>;
35
+ }
36
+ export declare const ACP_LIFECYCLE: AcpLifecycle;
@@ -0,0 +1,161 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { projectIdentity } from "./project-identity.js";
3
+ import { savedMachine } from "./filesystem-state.js";
4
+ import { syncAgentConfig } from "./agent-config-sync.js";
5
+ import { seedAgentRepository } from "./agent-repository.js";
6
+ import { createConversationRecord, conversationWorkspace } from "./conversations.js";
7
+ import { AgentStateStore } from "./agent-state.js";
8
+ import { objectValue } from "./acp-rpc.js";
9
+ import { nativeCurrentModel, nativeModelOptions } from "./acp-catalog.js";
10
+ /** No prompts, login, logout or credential writes. Every child process group is
11
+ * terminated, including initialization failures and interrupted clients. */
12
+ export const ACP_MODELS_SCRIPT = String.raw `
13
+ const {spawn}=require('node:child_process');
14
+ const {createInterface}=require('node:readline');
15
+ async function catalog(app){
16
+ const child=spawn(app==='codex'?'codex-acp':'claude-agent-acp',[],{
17
+ cwd:process.cwd(),env:{...process.env,SF_ACP_MODEL_DISCOVERY:'1'},detached:true,stdio:['pipe','pipe','pipe']});
18
+ const pending=new Map();let seq=0;
19
+ const kill=signal=>{try{process.kill(-child.pid,signal);}catch{}};
20
+ const interrupt=()=>kill('SIGKILL');
21
+ const terminate=()=>{kill('SIGKILL');process.exit(143);};
22
+ process.on('exit',interrupt);process.on('SIGTERM',terminate);process.on('SIGINT',terminate);
23
+ const closed=new Promise(resolve=>child.once('close',resolve));
24
+ const fail=error=>{for(const p of pending.values())p.reject(error);pending.clear();};
25
+ child.on('error',fail);child.on('exit',()=>fail(Error(app+' adapter exited')));
26
+ child.stdin.on('error',fail);
27
+ child.stderr.resume();
28
+ const lines=createInterface({input:child.stdout});
29
+ lines.on('line',line=>{
30
+ let m;try{m=JSON.parse(line);}catch{return;}
31
+ if(m.method&&m.id!==undefined){child.stdin.write(JSON.stringify({jsonrpc:'2.0',id:m.id,error:{code:-32601,message:'Catalog discovery has no client tools'}})+'\n');return;}
32
+ const p=pending.get(m.id);if(!p)return;pending.delete(m.id);
33
+ if(m.error)p.reject(Error(m.error.message));else p.resolve(m.result);
34
+ });
35
+ function rpc(method,params){return new Promise((resolve,reject)=>{
36
+ const id=++seq;pending.set(id,{resolve,reject});
37
+ child.stdin.write(JSON.stringify({jsonrpc:'2.0',id,method,params})+'\n');
38
+ });}
39
+ const timer=setTimeout(()=>{fail(Error(app+' model discovery timed out'));kill('SIGKILL');},45000);
40
+ try{
41
+ const init=await rpc('initialize',{protocolVersion:1,clientInfo:{name:'shardflux-models',version:'1'},clientCapabilities:{fs:{readTextFile:false,writeTextFile:false},terminal:false}});
42
+ const session=await rpc('session/new',{cwd:process.cwd(),mcpServers:[]});
43
+ return {agentInfo:init.agentInfo,configOptions:session.configOptions,models:session.models};
44
+ }finally{
45
+ clearTimeout(timer);lines.close();kill('SIGTERM');
46
+ const force=setTimeout(()=>kill('SIGKILL'),1000);
47
+ await closed;clearTimeout(force);kill('SIGKILL');
48
+ process.removeListener('exit',interrupt);process.removeListener('SIGTERM',terminate);process.removeListener('SIGINT',terminate);
49
+ }
50
+ }
51
+ (async()=>{
52
+ const results=Object.fromEntries(await Promise.all(['claude','codex'].map(async app=>{
53
+ try{return [app,await catalog(app)];}catch(e){return [app,{error:String(e.message)}];}
54
+ })));
55
+ process.stdout.write(JSON.stringify(results)+'\n');
56
+ })().catch(e=>{process.stderr.write(e.message+'\n');process.exitCode=1;});
57
+ `;
58
+ const modelKey = (scope, app) => JSON.stringify([scope.apiUrl, scope.organizationId, app, scope.template]);
59
+ export function acpScope(apiUrl, ws) {
60
+ if (!ws.organization_id || !ws.template.slug || !Number.isInteger(ws.template.version))
61
+ throw new Error('Conversation allocation omitted its account or template identity.');
62
+ return { apiUrl, organizationId: ws.organization_id, template: `${ws.template.slug}@${ws.template.immutable_version ?? ws.template.version}` };
63
+ }
64
+ /** Model payloads are account/harness/template scoped. The credential fingerprint
65
+ * only locates the last verified binding; it is never stored or logged in clear. */
66
+ export class AcpModelCache {
67
+ #store;
68
+ constructor(dir) { this.#store = new AgentStateStore(dir); }
69
+ #bindingKey(ctx, folder) {
70
+ const p = ctx.store.profile(ctx.apiUrl);
71
+ const credential = ctx.io.env.SHARDFLUX_API_KEY?.trim() || p.api_key?.token || ctx.io.env.SHARDFLUX_SESSION_TOKEN?.trim() || p.session?.token;
72
+ const authority = createHash('sha256').update(credential ?? 'no-credential').digest('hex');
73
+ const project = projectIdentity(folder);
74
+ return JSON.stringify([ctx.apiUrl, authority, project.remote ?? `file://${project.folder}`]);
75
+ }
76
+ lookup(ctx, folder) {
77
+ const doc = this.#store.read(), binding = objectValue(doc.acp_model_bindings)[this.#bindingKey(ctx, folder)];
78
+ if (!binding?.scope || binding.scope.apiUrl !== ctx.apiUrl)
79
+ return;
80
+ const catalog = this.read(binding.scope);
81
+ return Object.keys(catalog).length ? { scope: binding.scope, catalog } : undefined;
82
+ }
83
+ remember(ctx, folder, scope) {
84
+ const key = this.#bindingKey(ctx, folder);
85
+ this.#store.update(doc => { doc.acp_model_bindings = { ...objectValue(doc.acp_model_bindings), [key]: { scope } }; });
86
+ }
87
+ read(scope) {
88
+ const entries = objectValue(this.#store.read().acp_model_cache), catalog = {};
89
+ for (const app of ['claude', 'codex']) {
90
+ const session = objectValue(entries[modelKey(scope, app)]);
91
+ if (nativeModelOptions(session).length) {
92
+ nativeCurrentModel(session);
93
+ catalog[app] = session;
94
+ }
95
+ }
96
+ return catalog;
97
+ }
98
+ write(scope, catalog) {
99
+ this.#store.update(doc => {
100
+ const entries = { ...objectValue(doc.acp_model_cache) };
101
+ for (const app of ['claude', 'codex']) {
102
+ const session = catalog[app];
103
+ if (!session || !nativeModelOptions(session).length) {
104
+ delete entries[modelKey(scope, app)];
105
+ continue;
106
+ }
107
+ nativeCurrentModel(session);
108
+ // Never persist initialize auth status, MCP credentials or a native
109
+ // session identity. Only the model configuration belongs in this cache.
110
+ entries[modelKey(scope, app)] = { configOptions: [{ id: 'model', category: 'model', type: 'select',
111
+ currentValue: nativeCurrentModel(session), options: nativeModelOptions(session) }] };
112
+ }
113
+ doc.acp_model_cache = entries;
114
+ });
115
+ }
116
+ }
117
+ export async function discoverAcpCatalog(ctx, ws, root) {
118
+ const result = await ws.cell().exec.run(['node', '-e', ACP_MODELS_SCRIPT], { cwd: root, timeoutMs: 55_000,
119
+ signal: ctx.signal, maxOutputBytes: 1 << 20, env: { SF_ACP_MODEL_DISCOVERY: '1' } });
120
+ if (result.exitCode !== 0)
121
+ throw new Error(`Guest model discovery failed: ${result.stderr.trim()}`);
122
+ const discovered = JSON.parse(result.stdout), catalog = {};
123
+ for (const app of ['claude', 'codex'])
124
+ if (discovered[app] && nativeModelOptions(discovered[app]).length) {
125
+ nativeCurrentModel(discovered[app]);
126
+ catalog[app] = discovered[app];
127
+ }
128
+ if (!Object.keys(catalog).length)
129
+ throw new Error('The guest adapters returned no available models. Check your cloud agent sign-ins.');
130
+ return catalog;
131
+ }
132
+ export const ACP_LIFECYCLE = { async prepare(ctx, folder, name, callbacks) {
133
+ let allocation;
134
+ const c = await createConversationRecord(ctx, folder, 'claude', 'acp', name, view => { allocation = view; }, `acp-session-${name}`);
135
+ callbacks.allocated(c, allocation);
136
+ const ws = await conversationWorkspace(ctx, c);
137
+ callbacks.ready(ws);
138
+ if (!(await savedMachine(ctx))?.template)
139
+ await syncAgentConfig(ctx, ws);
140
+ await ws.cell().files.mkdir(c.worktree_path, { parents: true });
141
+ let initialReady, initialFailed;
142
+ const initialFiles = new Promise((resolve, reject) => { initialReady = resolve; initialFailed = reject; });
143
+ const seeded = (async () => {
144
+ await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true,
145
+ initialFiles: ['.claude/settings.json', '.claude/settings.local.json', '.codex/config.toml', '.mcp.json'], onInitialFiles: initialReady });
146
+ const result = await ws.cell().exec.run(['git', '-C', c.worktree_path, 'checkout', '-B', c.branch, c.base_commit], { timeoutMs: 30_000, signal: ctx.signal });
147
+ if (result.exitCode !== 0)
148
+ throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
149
+ callbacks.prepared();
150
+ })();
151
+ void seeded.catch(initialFailed);
152
+ const models = (async () => { await initialFiles; return discoverAcpCatalog(ctx, ws, c.worktree_path); })();
153
+ // Observe both failures immediately. Never start a second VM, and never delete
154
+ // the user's conversation VM as catalog cleanup.
155
+ const results = await Promise.allSettled([seeded, models]);
156
+ if (results[0].status === 'rejected')
157
+ throw results[0].reason;
158
+ if (results[1].status === 'rejected')
159
+ throw results[1].reason;
160
+ return results[1].value;
161
+ } };