@shardflux/cli 0.15.2 → 0.16.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +82 -12
  2. package/README.md +50 -3
  3. package/dist/acp-models.js +5 -1
  4. package/dist/agent-commands.d.ts +8 -2
  5. package/dist/agent-commands.js +23 -11
  6. package/dist/agent-target-commands.d.ts +7 -2
  7. package/dist/agent-target-commands.js +12 -7
  8. package/dist/agent-targets.d.ts +3 -0
  9. package/dist/agent-targets.js +4 -1
  10. package/dist/agent-terminal.d.ts +8 -0
  11. package/dist/agent-terminal.js +35 -2
  12. package/dist/args.d.ts +1 -1
  13. package/dist/args.js +26 -3
  14. package/dist/codex-cloud.d.ts +2 -0
  15. package/dist/codex-cloud.js +2 -0
  16. package/dist/command-center-actions.js +9 -0
  17. package/dist/commands.d.ts +22 -0
  18. package/dist/commands.js +54 -14
  19. package/dist/conversation-commands.js +6 -0
  20. package/dist/conversation-terminal.js +10 -1
  21. package/dist/conversations.d.ts +13 -1
  22. package/dist/conversations.js +41 -5
  23. package/dist/device-helper.js +9 -2
  24. package/dist/errors.d.ts +5 -0
  25. package/dist/errors.js +11 -1
  26. package/dist/files-mirror.d.ts +164 -0
  27. package/dist/files-mirror.js +761 -0
  28. package/dist/handlers.js +3 -1
  29. package/dist/main.js +9 -2
  30. package/dist/registry.d.ts +2 -42
  31. package/dist/registry.js +6 -90
  32. package/dist/thread-on-computer.d.ts +35 -0
  33. package/dist/thread-on-computer.js +200 -0
  34. package/dist/wait-status.d.ts +22 -0
  35. package/dist/wait-status.js +59 -0
  36. package/dist/worktree-twin-engine.d.ts +95 -0
  37. package/dist/worktree-twin-engine.js +402 -0
  38. package/dist/worktree-twin-land.py +331 -0
  39. package/dist/worktree-twin.d.ts +296 -0
  40. package/dist/worktree-twin.js +718 -0
  41. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -1,17 +1,87 @@
1
1
  # Changelog
2
2
 
3
- ## 0.15.2
4
-
5
- - Updates show up the moment they are published: the latest version comes from npm's `latest` tag (what
6
- `npm i -g shardflux` installs), checked at most every 12 hours with its ETag. The API still says which versions it
7
- supports, and can hold back a release. Offline, nothing is printed. `shard update --check` and the menu bar's update
8
- check use the same versions.
9
- - The menu bar keeps up with npm by itself: after `npm i -g shardflux`, every `shard` command compares the installed app
10
- with the one the package carries (two Info.plist reads, no network) and, when that one is newer, brings it in in the
11
- background: a stopped app is replaced on disk, a running one is left to update itself (menu bar 0.3.2+) or, for
12
- earlier apps, restarted on the new version when none of its windows is open. `shard update` swaps in an app the
13
- package already carries without another install. App versions compare as semver (0.3.10 after 0.3.9).
14
- - Installs menu bar 0.3.2 (`@shardflux/menubar-darwin ^0.3.2`).
3
+ ## 0.16.0 (not yet published)
4
+
5
+ - Cloud sessions are named for the work: `shard ./` starts the agent with the repository or folder name and the first
6
+ line of your prompt, so claude.ai, the Claude app and its notifications show `Add CSV export · todo-app`
7
+ (`New conversation · todo-app` until the first prompt arrives) instead of the project slug and conversation id.
8
+ Claude Desktop and Codex app targets are listed as `main · acme`, without the `[SF] ` tag.
9
+
10
+ - Updates show up the moment they are published (also in 0.15.2): the latest version comes from npm's `latest` tag
11
+ (what `npm i -g shardflux` installs), checked at most every 12 hours with its ETag. The API still says which
12
+ versions it supports, and can hold back a release. Offline, nothing is printed. `shard update --check` and the menu
13
+ bar's update check use the same versions.
14
+ - The menu bar keeps up with npm by itself (also in 0.15.2): after `npm i -g shardflux`, every `shard` command compares
15
+ the installed app with the one the package carries (two Info.plist reads, no network) and, when that one is newer,
16
+ brings it in in the background: a stopped app is replaced on disk, a running one is left to update itself (menu bar
17
+ 0.3.2+) or, for earlier apps, restarted on the new version when none of its windows is open. `shard update` swaps in
18
+ an app the package already carries without another install. App versions compare as semver (0.3.10 after 0.3.9).
19
+
20
+ - A new thread on an existing computer: `shard agents new --app claude|codex --on <ref>` and `shard ./ --on <ref>`
21
+ start Claude or Codex on the computer (VM) of an existing conversation instead of a new one. The thread works on its
22
+ own branch and worktree there, starting from that conversation's committed `HEAD`, and shares the computer's
23
+ installed packages, caches, running services, ports and files. `<ref>` is a conversation id, key or name, or the
24
+ computer's workspace key or id; a parked computer wakes for it. Codex threads are genuine Codex Cloud threads.
25
+ `--review` starts a read-only Claude reviewer of that conversation's worktree instead. `--prompt`, `--prompt-file`
26
+ and `--image` work as for a new conversation.
27
+ - `shard agents computers [--path <folder>]` lists the project's computers a thread can start on and the threads on
28
+ each (`--json`: `{computers: [{workspace_id, workspace_key, state, conversations: [...]}]}`).
29
+ - `shard agents list` shows each session's CONVERSATION and COMPUTER; `--json` rows add `conversation_id` and
30
+ `conversation_key`. `agents open <conversation> --in auto` opens that conversation's own Claude session when its
31
+ computer runs several, and the `--json` of a started Claude conversation names its `conversation.id`.
32
+ - A conversation's files folder starts from its own seed, also when several threads share one computer.
33
+
34
+ - Waits show where a queued start stands: while a command waits on an operation whose start is queued for capacity,
35
+ one line on stderr gives its place, the host being started for it and until when it holds its place
36
+ (`queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z`),
37
+ again whenever that changes. Dim on a terminal, plain when piped, never with `--json`. A command waiting for room on
38
+ the workspace's host or for a free working slot of the plan says so the same way.
39
+
40
+ - **Breaking:** queued starts are seen through: without `--timeout` (or `--wake-timeout`), a wait whose start is queued for capacity
41
+ keeps waiting until the start's deadline instead of exiting at the default 5 minutes (or 2 minutes for a wake). An
42
+ explicit `--timeout`, `--wake-timeout` or `SHARDFLUX_WAKE_TIMEOUT_MS` is kept as given; its timeout error (exit 5)
43
+ names the queue position, and `--json` adds `last_reason`, `deadline_at` and `queue_position`.
44
+
45
+ - Commands wait for room instead of failing (SDK 0.22.0): a call refused before it ran while the workspace's host makes
46
+ room (503 `host_capacity`) or while every workspace the plan lets work at once is busy (429 `quota_exceeded`) is sent
47
+ again after each `Retry-After` for up to 2 minutes, `ws exec` starts and `ws open` included.
48
+
49
+ - The agent terminal (`shard ./`, `shard agents open --in terminal`) waits `Retry-After` when the workspace's host is
50
+ making room, says `waiting for room on the workspace's host (position N)` once, and after a drop that follows a
51
+ working attach reconnects from the shortest backoff again.
52
+
53
+ - Every conversation's committed files on your Mac: while the menu bar app runs, its device helper keeps
54
+ `~/Shardflux/<repository>/<base branch>/<conversation>/` in step with what each conversation of your account
55
+ committed, read-only and without prompts. A folder holds the files that conversation changed or added since it
56
+ started (`.shardflux-manifest.json` lists them, deletions included); the base branch moving, a rebase, and the
57
+ uncommitted edits your worktree had when the conversation started are left out. All worktrees of one repository are
58
+ one `<repository>` folder. Files above 50 MB, and files past 2 GB per conversation, are listed as
59
+ `<file>.placeholder.txt`; set `max_file_mb` and `max_conversation_mb` in the repository's `.shardflux/files.yaml`.
60
+ A landed conversation's folder is marked `landed` and stops updating; a discarded one is removed. Your own
61
+ checkouts are never written.
62
+
63
+ - `shard files pull <conversation> <path>` downloads one listed file in place of its placeholder, whatever its size.
64
+
65
+ - New conversations record the working tree they start from (a seed snapshot made in the conversation's VM, never on
66
+ your Mac) and the base branch they start on.
67
+
68
+ - Finished conversations land in your local checkout with no input: while the menu bar app runs, a conversation
69
+ whose turn ends with its work committed lands into the branch it started from. The branch moves to its commits
70
+ (fast-forward, or its commits replayed onto your newer ones) and the files it changed are written atomically in the
71
+ worktree where that branch is checked out, three-way merged with your uncommitted edits (your text wins where both
72
+ changed the same lines). The index follows, so `git status` shows only your own edits. A file is written only after
73
+ 2 seconds without a change, never while your own merge, rebase, cherry-pick or bisect runs, and every rewritten file
74
+ is backed up first (`shard conversations landing backups`, `restore <backup>`; kept 14 days, up to 1 GiB). When the
75
+ conversation's commits conflict with your newer commits, its agent is asked in that conversation to merge your
76
+ branch and resolve; the result lands when it finishes. `shard conversations landing pr` opens a pull request
77
+ instead; `off` keeps conversations in the cloud. Conversations that finish while your computer is offline or asleep
78
+ land the moment it is back, one after another in the order they finished, also when their VM has been reclaimed.
79
+
80
+ - A conversation started from a checkout with uncommitted edits starts from them: its base is a snapshot commit of
81
+ your working tree (kept as `refs/shard/twin/<id>` in your repository; your index, HEAD and branches are not
82
+ touched), so its own commits hold only its work and your edits stay yours.
83
+
84
+ - Includes SDK 0.22.0.
15
85
 
16
86
  ## 0.15.1
17
87
 
package/README.md CHANGED
@@ -80,6 +80,20 @@ settings: T3 runs `shard acp --login` in its own terminal, which creates or sign
80
80
  Codex login, or your email) and connects Claude Code and Codex, skipping every step that is already done (0.15.0+).
81
81
  With T3 Code 0.0.45, `shard agents new --in t3` adds the project as an SSH environment instead.
82
82
 
83
+ Start another thread on a computer that is already set up (0.16.0+): it shares that conversation's installed
84
+ packages, caches, running services, ports and files, and works on its own branch and worktree.
85
+
86
+ ```sh
87
+ shard agents computers # the project's computers and the threads on each
88
+ shard ./ --on fix-login # Claude (or Codex) joins the computer of conversation fix-login
89
+ shard agents new --app codex --on fix-login --prompt "Load-test the API it is running"
90
+ shard ./ --on fix-login --review # a read-only Claude reviewer of its worktree
91
+ ```
92
+
93
+ `--on` takes a conversation's id, key or name, or the computer's workspace key or id. A parked computer wakes for
94
+ the new thread. Codex threads are Codex Cloud threads, on the web and your phone too. `shard agents list` shows each
95
+ thread's conversation and computer.
96
+
83
97
  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.
84
98
 
85
99
  To try a conversation on your Mac:
@@ -94,6 +108,26 @@ Device tools ask for approval on your Mac before preparing the conversation’s
94
108
  `~/.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+).
95
109
  From 0.14.3, transcript copying runs independently of device approvals. Unknown entries are skipped, and failed copies retry with backoff; diagnostics name the conversation and affected entry.
96
110
 
111
+ Every conversation's committed files are on your Mac too (0.16.0+, with the menu bar app running):
112
+ `~/Shardflux/<repository>/<base branch>/<conversation>/` holds what each conversation changed or added, read-only,
113
+ and updates when its branch moves. Large files are listed as `<file>.placeholder.txt`; download one with the menu
114
+ bar's **Download** or:
115
+
116
+ ```sh
117
+ shard files pull hype-video out/hype.mp4
118
+ ```
119
+
120
+ Finished conversations land in your checkout by themselves (0.16.0+, with the menu bar app running). When a
121
+ conversation's turn ends with its work committed, the branch it started from moves to its commits and the changed
122
+ files are written in place, next to your own uncommitted edits, which stay as they are (where both touched the same
123
+ lines, your text is kept). `git status` keeps showing only your edits. A conversation started from a checkout with
124
+ uncommitted edits starts from them too. Every rewritten file is backed up first:
125
+
126
+ ```sh
127
+ shard conversations landing # auto (default), pr or off for this project
128
+ shard conversations landing backups # then: shard conversations landing restore <backup>
129
+ ```
130
+
97
131
  For application workspace commands, `shard signup` creates an account and saves a project key, or set
98
132
  `SHARDFLUX_API_KEY` to a key from the console and run `shard login`. `shard --help` lists every command.
99
133
 
@@ -692,7 +726,11 @@ without waking it (the state at suspension); `files search` then notes it on std
692
726
  - `--wake-timeout <ms>` (or `SHARDFLUX_WAKE_TIMEOUT_MS`, default `120000`) bounds the wait. Past
693
727
  it, `shard` exits 5 and names the operation and its state (for example `Operation <id> (open)
694
728
  is still queued after 120000 ms`). The operation continues server side:
695
- `shard operations wait <id>` keeps waiting.
729
+ `shard operations wait <id>` keeps waiting. Without either (0.16.0+), a resume queued for capacity is waited for
730
+ until its deadline, its place shown on stderr (see [Start deadlines](#start-deadlines)).
731
+ - A call refused while the workspace's host makes room to wake it (503 `host_capacity`) or while every workspace the
732
+ plan lets work at once is busy (429 `quota_exceeded`) did not run: `shard` sends it again after each `Retry-After`,
733
+ for up to 2 minutes (0.16.0+), and stderr says `waiting for room on the workspace's host (position 2)` once.
696
734
  - A resume or open that fails exits 6.
697
735
  - `--no-wake` (or `SHARDFLUX_NO_WAKE=1`) turns this off. A workspace that is not running is then
698
736
  refused at once (exit 1, `workspace_not_running`).
@@ -875,7 +913,16 @@ that screenshot, default 250) and `--format jpeg --quality N` for smaller images
875
913
  ## Start deadlines
876
914
 
877
915
  A queued open, resume or fork (`capacity_pending`) has a deadline 15 minutes after it was created.
878
- If `--timeout` ends first, the timeout error names the deadline. A start still pending at the
916
+ While `shard` waits for it, stderr says where it stands (0.16.0+), again whenever that changes (dim on a terminal,
917
+ never with `--json`):
918
+
919
+ ```text
920
+ queued for capacity (position 3): a new host is starting, ready by 21:40Z; it holds its place until 21:51Z
921
+ ```
922
+
923
+ Without `--timeout`, a queued start is waited for until its deadline (0.16.0+). An explicit `--timeout` is kept: if it
924
+ ends first, `shard` exits 5 and the timeout error names the deadline and the queue position (`--json`:
925
+ `deadline_at`, `queue_position`, `last_reason`). A start still pending at the
879
926
  deadline fails with `capacity_unavailable`: nothing was started, and a suspended workspace stays
880
927
  suspended. `shard` exits 6 as for any failed operation, and the error says `retryable=true` (with
881
928
  `--json`, `"retryable": true`) with a hint to retry:
@@ -1047,7 +1094,7 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
1047
1094
  | 2 | usage error: bad arguments, or input the API rejected as `validation_failed` |
1048
1095
  | 3 | not found: workspace, operation or route |
1049
1096
  | 4 | authentication or authorization: missing, malformed, revoked or insufficient key or session; `step_up_required` (run `shard auth step-up`), `mfa_required` (a sign-in waiting for its code), `email_unverified`, `invalid_credentials`; `access_denied` or `expired_token` from `auth login --browser` (0.15.0+) |
1050
- | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`), up to a queued start's 15-minute deadline. `executions get` (0.5.0+): the execution is still queued or running. `ws exec-read` (0.8.0+): the background command still runs. `billing upgrade --wait` (0.5.0+): nobody paid in time (the checkout stays open) |
1097
+ | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`), up to a queued start's deadline (`deadline_at`). Without `--timeout`, a wait on a start queued for capacity lasts until that deadline (0.16.0+). `executions get` (0.5.0+): the execution is still queued or running. `ws exec-read` (0.8.0+): the background command still runs. `billing upgrade --wait` (0.5.0+): nobody paid in time (the checkout stays open) |
1051
1098
  | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace). `retryable=true` in the error means a retry can succeed (`capacity_unavailable`). Also a file-first execution that ended `failed` or `lost` (0.5.0+): nothing was published. `exec` (0.5.1+): the command could not start (`exec_failed_to_start`). `billing upgrade --wait` (0.5.0+): the checkout expired or was canceled (`checkout_ended`) |
1052
1099
  | 130 | interrupted (Ctrl-C). Waits stop at once, and exec sends a cancel to the session. A file-first execution runs to completion, and the error names the `shard executions get` command for its result. |
1053
1100
  | exec, executions get, ws exec-read | the command's own code: 124 if it timed out, 128+N if killed by signal N. Failures before the command ran use the codes above; use `--json` to tell them apart. |
@@ -7,6 +7,8 @@ import { createConversationRecord, conversationWorkspace } from "./conversations
7
7
  import { AgentStateStore } from "./agent-state.js";
8
8
  import { objectValue } from "./acp-rpc.js";
9
9
  import { nativeCurrentModel, nativeModelOptions } from "./acp-catalog.js";
10
+ import { recordSeedSnapshot } from "./files-mirror.js";
11
+ import { sendTwinSeed, twinCheckoutArgv } from "./worktree-twin.js";
10
12
  /** No prompts, login, logout or credential writes. Every child process group is
11
13
  * terminated, including initialization failures and interrupted clients. */
12
14
  export const ACP_MODELS_SCRIPT = String.raw `
@@ -143,9 +145,11 @@ export const ACP_LIFECYCLE = { async prepare(ctx, folder, name, callbacks) {
143
145
  const seeded = (async () => {
144
146
  await seedAgentRepository(ctx, ws, folder, { root: c.worktree_path, populate: true,
145
147
  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 });
148
+ await sendTwinSeed(ctx.store.dir, ws.cell(), c, folder, ctx.signal);
149
+ const result = await ws.cell().exec.run(twinCheckoutArgv(ctx.store.dir, c), { timeoutMs: 30_000, signal: ctx.signal });
147
150
  if (result.exitCode !== 0)
148
151
  throw new Error(`Conversation branch could not be prepared: ${result.stderr.trim()}`);
152
+ await recordSeedSnapshot(ws, c, ctx.signal);
149
153
  callbacks.prepared();
150
154
  })();
151
155
  void seeded.catch(initialFailed);
@@ -4,13 +4,19 @@ import type { Values } from './args.js';
4
4
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
5
5
  export declare function disambiguatedSlug(slug: string, identity: string): string;
6
6
  export declare function projectTemplate(ctx: Ctx, remote: string | null): Promise<string>;
7
- export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, project?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean, conversation?: {
7
+ /** `place` is the repository or folder name session titles end with ("todo-app"), `task` what a new session is titled
8
+ * with before the guest names it from its first prompt (a first prompt line or the conversation's title). */
9
+ export declare function agentPty(cell: CellClient, app: 'claude' | 'codex', agent: string, approvals: boolean, term: string, signal?: AbortSignal, place?: string, setupHash?: string, cwd?: string, ptyName?: string, onLaunch?: () => void, deferSetup?: boolean, conversation?: {
8
10
  id: string;
9
11
  session: string | null;
10
- }): Promise<PtySession>;
12
+ }, task?: string): Promise<PtySession>;
13
+ /** The first non-empty line of a prompt or title, bounded; the guest makes it the task part of the session title. */
14
+ export declare function sessionTask(text: string | undefined): string | undefined;
11
15
  export declare function terminal(ctx: Ctx, ws: Workspace, app: 'claude' | 'codex', agent: string, project: string, values: Values, setupHash?: string, cwd?: string, conversation?: {
12
16
  id: string;
13
17
  session: string | null;
18
+ pty?: string;
19
+ title?: string;
14
20
  }): Promise<number>;
15
21
  export declare const launchProject: Handler;
16
22
  export declare const AGENT_HANDLERS: Record<string, Handler>;
@@ -1,3 +1,4 @@
1
+ import { repositoryName } from "./files-mirror.js";
1
2
  import { prepareSetupCache } from "./setup-cache.js";
2
3
  import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
3
4
  import { agentReachability } from "./agent-reachability.js";
@@ -47,7 +48,9 @@ export async function projectTemplate(ctx, remote) {
47
48
  throw error;
48
49
  }
49
50
  }
50
- export async function agentPty(cell, app, agent, approvals, term, signal, project, setupHash, cwd, ptyName, onLaunch, deferSetup = false, conversation) {
51
+ /** `place` is the repository or folder name session titles end with ("todo-app"), `task` what a new session is titled
52
+ * with before the guest names it from its first prompt (a first prompt line or the conversation's title). */
53
+ export async function agentPty(cell, app, agent, approvals, term, signal, place, setupHash, cwd, ptyName, onLaunch, deferSetup = false, conversation, task) {
51
54
  const id = `shard-${ptyName || agent}-${app}`;
52
55
  // Arrival and changed locks queue the same durable guest job even when the
53
56
  // native agent is already alive. Setup never becomes its first user turn.
@@ -73,7 +76,7 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
73
76
  conversationArgs = ['--conversation', conversation.id, ...(conversation.session ? ['--resume-session', conversation.session] : [])];
74
77
  }
75
78
  const launch = await cell.exec.run(['sf-agent', 'run', app, '--name', agent, '--detach', ...conversationArgs, ...(ptyName ? ['--pty-name', ptyName] : []), ...(approvals ? ['--approvals'] : []), ...(setupHash ? ['--setup', '--setup-deps-hash', setupHash] : [])], {
76
- env: { TERM: term, ...(project ? { SF_PROJECT_NAME: project } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
79
+ env: { TERM: term, ...(place ? { SF_PROJECT_NAME: place, SF_REPO_NAME: place } : {}), ...(task ? { SF_TASK_TITLE: task } : {}), ...(setupHash && deferSetup ? { SF_SETUP_DEFERRED: '1' } : {}) }, timeoutMs: 30_000, maxOutputBytes: 4096, signal, ...(cwd ? { cwd } : {}),
77
80
  });
78
81
  if (launch.exitCode !== 0)
79
82
  throw new Error(`agent launch failed: ${launch.stderr.trim() || `exit ${launch.exitCode}`}`);
@@ -91,7 +94,13 @@ export async function agentPty(cell, app, agent, approvals, term, signal, projec
91
94
  throw new Error(`agent exited before attach: ${session.state}`);
92
95
  return session;
93
96
  }
97
+ /** The first non-empty line of a prompt or title, bounded; the guest makes it the task part of the session title. */
98
+ export function sessionTask(text) {
99
+ const line = text?.split(/\r?\n/).map(l => l.replace(/[\s\p{Cc}]+/gu, ' ').trim()).find(Boolean);
100
+ return line ? [...line].slice(0, 200).join('') : undefined;
101
+ }
94
102
  export async function terminal(ctx, ws, app, agent, project, values, setupHash, cwd = workspaceRepositoryPath(ws), conversation) {
103
+ const ptyName = conversation?.pty ?? ws.labels.agent_pty_name;
95
104
  const store = new AgentStateStore(ctx.store.dir);
96
105
  if (!ws.ready)
97
106
  await ws.wake({ timeoutMs: ctx.wakeTimeoutMs, signal: ctx.signal });
@@ -101,12 +110,12 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
101
110
  const tunnels = attach ? await startAgentConfigTunnels(ctx, ws) : null;
102
111
  try {
103
112
  if (app === 'codex' && typeof values.initialPrompt === 'string') {
104
- await prepareInitialCodexProject(cell, cwd, `shard-${ws.labels.agent_pty_name || agent}-${app}`);
113
+ await prepareInitialCodexProject(cell, cwd, `shard-${ptyName || agent}-${app}`);
105
114
  }
106
115
  const credentialFill = await configureCredentialFill(ctx, ws);
107
116
  // A cold launch is remembered before it starts, so the listing can show it before the projection does (agent-live.ts).
108
117
  const term = ctx.io.env.TERM && ctx.io.env.TERM !== 'dumb' ? ctx.io.env.TERM : 'xterm-256color';
109
- const session = await agentPty(cell, app, agent, values.approvals === true, term, ctx.signal, ws.labels.shard_project || ws.key.split('/')[0], setupHash, cwd, ws.labels.agent_pty_name, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation);
118
+ const session = await agentPty(cell, app, agent, values.approvals === true, term, ctx.signal, repositoryName(ws.labels.repo_remote || project), setupHash, cwd, ptyName, () => { rememberLaunch(ctx, ws.id); progress(ctx, `Starting ${app === 'claude' ? 'Claude' : 'Codex'} in ${ws.key}…`); }, true, conversation && { id: conversation.id, session: conversation.session }, sessionTask(typeof values.initialPrompt === 'string' ? values.initialPrompt : conversation?.title));
110
119
  // A new conversation's guest can start the agent before this command reaches it, so the launch above never ran.
111
120
  // Remember it either way: listing and `agents open` then read the guest until the projection lists the session.
112
121
  rememberLaunch(ctx, ws.id);
@@ -126,7 +135,8 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
126
135
  }
127
136
  if (!attach) {
128
137
  await preparation;
129
- ctx.io.stdout.write(json({ workspace: ws.data, session, ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}) }));
138
+ // conversation (0.16.0+): which conversation this session is, also when its computer hosts several threads.
139
+ ctx.io.stdout.write(json({ workspace: ws.data, session, ...(conversation ? { conversation: { id: conversation.id } } : {}), ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}) }));
130
140
  return 0;
131
141
  }
132
142
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
@@ -258,10 +268,11 @@ export const AGENT_HANDLERS = {
258
268
  }
259
269
  if (values.in === 'auto') {
260
270
  // The menu and other launchers use the same CLI-owned target selection. Never put provider URLs in a client.
261
- const ws = await resolveWorkspace(ctx, agent);
271
+ // A conversation id opens that conversation's own session, also when its computer hosts other threads.
272
+ const ws = resolvedConversation ? await conversationWorkspace(ctx, resolvedConversation) : await resolveWorkspace(ctx, agent);
262
273
  const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
263
274
  const store = new AgentStateStore(ctx.store.dir);
264
- const app = values.app ?? ws.labels.shard_app ?? ws.labels.app ?? store.app(project);
275
+ const app = values.app ?? resolvedConversation?.harness ?? ws.labels.shard_app ?? ws.labels.app ?? store.app(project);
265
276
  const detected = installedTargets(ctx.io.env);
266
277
  const saved = store.read().projects?.[project]?.target ??
267
278
  (project.startsWith('file://') ? store.read().projects?.[new URL(project).pathname]?.target : undefined);
@@ -270,7 +281,7 @@ export const AGENT_HANDLERS = {
270
281
  if ((!saved || !detected.targets.includes(saved)) && app === 'codex') {
271
282
  const next = { ...values };
272
283
  delete next.in;
273
- return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
284
+ return AGENT_HANDLERS['agents open'](ctx, next, [resolvedConversation?.id ?? ws.key]);
274
285
  }
275
286
  if (saved && detected.targets.includes(saved)) {
276
287
  const { HANDLERS } = await import("./handlers.js");
@@ -282,16 +293,17 @@ export const AGENT_HANDLERS = {
282
293
  // Claude opens where people work (0.15.1+): the conversation in Claude Desktop when it is installed, else Claude
283
294
  // Code in a terminal. The browser (claude.ai) only when it is the chosen target (--in web).
284
295
  if (detected.clients.includes('claude-desktop')) {
285
- return openClaudeDesktopSession(ctx, ws);
296
+ return openClaudeDesktopSession(ctx, ws, resolvedConversation);
286
297
  }
298
+ const opened = resolvedConversation?.id ?? ws.key;
287
299
  if (ctx.json) {
288
300
  // An app runs this in the person's terminal (the menu bar opens Terminal with it).
289
- out(ctx, `shard agents open ${ws.key}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', ws.key] });
301
+ out(ctx, `shard agents open ${opened}\n`, { target: 'terminal', workspace: ws.data, command: ['shard', 'agents', 'open', opened] });
290
302
  return 0;
291
303
  }
292
304
  const next = { ...values };
293
305
  delete next.in;
294
- return AGENT_HANDLERS['agents open'](ctx, next, [ws.key]);
306
+ return AGENT_HANDLERS['agents open'](ctx, next, [opened]);
295
307
  }
296
308
  if (values.in !== undefined && values.in !== 'terminal') {
297
309
  const { HANDLERS } = await import("./handlers.js");
@@ -1,5 +1,6 @@
1
1
  import { Duplex } from 'node:stream';
2
2
  import type { CellClient, Workspace } from '@shardflux/sdk';
3
+ import type { Conversation } from './conversations.js';
3
4
  import type { Ctx, Handler, Io } from './commands.js';
4
5
  import { AgentTargets } from './agent-targets.js';
5
6
  import type { DesktopTarget } from './agent-targets.js';
@@ -13,7 +14,11 @@ export declare function proxyStream(io: Io, ended?: () => void, outputGate?: Pro
13
14
  * this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
14
15
  * launch probes the guest for up to 30 s; history browsing stays passive.
15
16
  */
16
- export declare function remoteControlUrl(ctx: Ctx, ws: Workspace): Promise<string>;
17
+ /**
18
+ * The Claude Remote Control URL of a session in `ws`. With several threads on one computer (0.16.0+) a conversation's
19
+ * own session is chosen by its harness session id; a thread that has not reported one yet is unambiguous only alone.
20
+ */
21
+ export declare function remoteControlUrl(ctx: Ctx, ws: Workspace, conversation?: Pick<Conversation, 'harness_session_id'>): Promise<string>;
17
22
  /** Claude Desktop's own link to a Remote Control session: claude://code/<session id> opens it in its Code tab. */
18
23
  export declare function claudeDesktopLink(remoteControl: string): string;
19
24
  export declare const CLAUDE_DESKTOP_BUNDLE = "com.anthropic.claudefordesktop";
@@ -21,7 +26,7 @@ export declare const CLAUDE_DESKTOP_BUNDLE = "com.anthropic.claudefordesktop";
21
26
  * Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
22
27
  * opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
23
28
  */
24
- export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace): Promise<number>;
29
+ export declare function openClaudeDesktopSession(ctx: Ctx, ws: Workspace, conversation?: Pick<Conversation, 'harness_session_id'>): Promise<number>;
25
30
  export declare function openDesktopWorkspace(ctx: Ctx, ws: Workspace, target: DesktopTarget, t3Version?: string, prewarmed?: boolean): Promise<number>;
26
31
  export declare function queueT3Prewarm(ctx: Ctx, ws: Workspace, version: string): Promise<void>;
27
32
  export declare const TARGET_HANDLERS: Record<string, Handler>;
@@ -6,7 +6,7 @@ import { spawn } from 'node:child_process';
6
6
  import { Readable, Duplex } from 'node:stream';
7
7
  import { ShardfluxApiError, WorkspaceTunnels } from '@shardflux/sdk';
8
8
  import { out, resolveWorkspace } from "./commands.js";
9
- import { AgentTargets, installedTargets } from "./agent-targets.js";
9
+ import { AgentTargets, installedTargets, targetLabel } from "./agent-targets.js";
10
10
  import { join, relative } from 'node:path';
11
11
  import { projectIdentity } from "./project-identity.js";
12
12
  import { projectWorkspacePath } from "./agent-repository.js";
@@ -137,12 +137,18 @@ export function proxyStream(io, ended, outputGate) {
137
137
  * this computer just started can accept its first prompt before Remote Control registers, so an open of our recent
138
138
  * launch probes the guest for up to 30 s; history browsing stays passive.
139
139
  */
140
- export async function remoteControlUrl(ctx, ws) {
140
+ /**
141
+ * The Claude Remote Control URL of a session in `ws`. With several threads on one computer (0.16.0+) a conversation's
142
+ * own session is chosen by its harness session id; a thread that has not reported one yet is unambiguous only alone.
143
+ */
144
+ export async function remoteControlUrl(ctx, ws, conversation) {
141
145
  let cursor;
142
146
  let url;
147
+ const session = conversation?.harness_session_id ?? undefined;
148
+ const mine = (id) => session === undefined || id === session;
143
149
  do {
144
150
  const page = await ctx.cloud().agents.list({ limit: 200, ...(cursor ? { cursor } : {}) });
145
- url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
151
+ url = page.data.find(a => a.workspace_id === ws.id && a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.id.slice(a.workspace_id.length + 1)))?.remote_control_url;
146
152
  cursor = page.nextCursor ?? undefined;
147
153
  } while (!url && cursor && !ctx.signal.aborted);
148
154
  // A new Claude process can accept its first prompt before Remote Control registers.
@@ -158,7 +164,7 @@ export async function remoteControlUrl(ctx, ws) {
158
164
  });
159
165
  if (result.exitCode !== 0)
160
166
  break;
161
- url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url)?.remote_control_url;
167
+ url = guestSessions(result.stdout).find(a => a.app === 'claude' && a.state !== 'exited' && a.remote_control_url && mine(a.session_id))?.remote_control_url;
162
168
  if (!url && Date.now() < deadline)
163
169
  await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal: ctx.signal });
164
170
  }
@@ -184,9 +190,9 @@ export const CLAUDE_DESKTOP_BUNDLE = 'com.anthropic.claudefordesktop';
184
190
  * Opens the conversation itself (its live Remote Control session) in Claude Desktop (0.15.1+), the way `--in auto`
185
191
  * opens a Claude conversation when Claude Desktop is installed. --json prints {target, url, app_bundle_id} for apps.
186
192
  */
187
- export async function openClaudeDesktopSession(ctx, ws) {
193
+ export async function openClaudeDesktopSession(ctx, ws, conversation) {
188
194
  await ensureCloudClaude(ctx, ws);
189
- const session = await remoteControlUrl(ctx, ws);
195
+ const session = await remoteControlUrl(ctx, ws, conversation);
190
196
  const url = claudeDesktopLink(session);
191
197
  if (!ctx.json)
192
198
  await openLink(url, ctx.io.env);
@@ -251,7 +257,6 @@ export async function queueT3Prewarm(ctx, ws, version) {
251
257
  }
252
258
  }
253
259
  const tilde = (targets, file) => file.startsWith(`${targets.home}/`) ? `~/${relative(targets.home, file)}` : file;
254
- const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
255
260
  /** Where each managed connection of an entry lives: the SSH host block (and its Include), then each desktop app's file. */
256
261
  function configuredTargets(targets, e, t3 = false) {
257
262
  return [
@@ -7,6 +7,9 @@ export interface TargetEntry {
7
7
  agent: string;
8
8
  targets: DesktopTarget[];
9
9
  }
10
+ /** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
11
+ * "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
12
+ export declare const targetLabel: (e: Pick<TargetEntry, "agent" | "project">) => string;
10
13
  export declare function targetIdentity(key: string): Pick<TargetEntry, 'host' | 'workspace' | 'project' | 'agent'>;
11
14
  /** Replace only a marked block, retaining every byte outside it. Put Includes before user Host blocks. */
12
15
  export declare function managedBlock(text: string, body: string): string;
@@ -15,6 +15,9 @@ const shellQuote = (s) => "'" + s.replaceAll("'", "'\\''").replaceAll('%', '%%')
15
15
  const quote = (s) => '"' + s.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%') + '"';
16
16
  // Some SSH config readers (T3 Code's host discovery) split Include on whitespace and keep quotes; quote only when needed.
17
17
  const includePath = (s) => /^[^\s"'\\#%]+$/.test(s) ? s : quote(s);
18
+ /** What Claude Desktop and the Codex app list a target as: the project's router "Shardflux · <project>", an agent
19
+ * "<agent> · <project>" (the thing, then the place, as session titles are; no tag). */
20
+ export const targetLabel = (e) => e.agent === 'router' ? `Shardflux · ${e.project}` : `${e.agent} · ${e.project}`;
18
21
  export function targetIdentity(key) {
19
22
  const parts = key.split('/');
20
23
  if (parts.length !== 2 || parts.some(p => !/^[a-z0-9][a-z0-9._-]{0,95}$/.test(p)))
@@ -280,7 +283,7 @@ export class AgentTargets {
280
283
  if (!(prop in doc) && !manifest.created[file].includes(prop))
281
284
  manifest.created[file].push(prop);
282
285
  doc[prop] = [...kept, ...entries.filter(e => e.targets.includes(target)).map(e => {
283
- const label = e.agent === 'router' ? `Shardflux · ${e.project}` : `[SF] ${e.project} · ${e.agent}`;
286
+ const label = targetLabel(e);
284
287
  return target === 'claude-desktop' ? { id: e.host, name: label, sshHost: e.host, startDirectory: projectWorkspacePath({ slug: e.project }) } : { sshAlias: e.host, projects: [{ remotePath: projectWorkspacePath({ slug: e.project }), label }] };
285
288
  })];
286
289
  if (!doc[prop].length && manifest.created[file].includes(prop))
@@ -8,6 +8,14 @@ export interface TerminalConnection {
8
8
  }
9
9
  /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
10
10
  export declare function sleepingAttach(error: unknown): boolean;
11
+ /**
12
+ * The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
13
+ * workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
14
+ * 2 s by consecutive failures, which reset once an attach delivers.
15
+ */
16
+ export declare function reattachDelayMs(error: unknown, failures: number, random?: () => number): number;
17
+ /** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
18
+ export declare function admissionNotice(error: unknown): string | null;
11
19
  export declare function retryableAttach(error: unknown): boolean;
12
20
  /** Offsets address bytes, including escape sequences and UTF-8 split across frames. */
13
21
  export declare class PtyOutputCursor {
@@ -1,11 +1,29 @@
1
1
  import { StringDecoder } from 'node:string_decoder';
2
- import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix } from '@shardflux/sdk';
2
+ import { PtyConnection, ShardfluxApiError, ShardfluxProtocolError, exitCodePosix, isAdmissionRefusal } from '@shardflux/sdk';
3
3
  import { UsageError } from "./errors.js";
4
4
  /** The workspace (or the VM under this terminal) is asleep: the user's next keystroke wakes it. */
5
5
  export function sleepingAttach(error) {
6
6
  return error instanceof ShardfluxApiError && error.code === 'workspace_not_running';
7
7
  }
8
8
  const ASLEEP_NOTICE = '\r\n\x1b[2m· asleep. Type to wake it.\x1b[0m\r\n';
9
+ /**
10
+ * The wait before the next attach (0.16.0+): an attach refused with a Retry-After (503 `host_capacity` while the
11
+ * workspace's host makes room, 429 `quota_exceeded`) waits it with up to 20 % jitter; others back off from 100 ms to
12
+ * 2 s by consecutive failures, which reset once an attach delivers.
13
+ */
14
+ export function reattachDelayMs(error, failures, random = Math.random) {
15
+ const hinted = error instanceof ShardfluxApiError ? error.retryAfterSeconds : undefined;
16
+ if (hinted !== undefined && hinted > 0)
17
+ return Math.floor(hinted * 1000 * (1 + 0.2 * random()));
18
+ return Math.min(2_000, 100 * 2 ** Math.min(failures, 5));
19
+ }
20
+ /** The line an attach that waits for room prints (0.16.0+), or null for any other refusal. */
21
+ export function admissionNotice(error) {
22
+ if (!isAdmissionRefusal(error))
23
+ return null;
24
+ const position = typeof error.details?.queue_position === 'number' ? ` (position ${error.details.queue_position})` : '';
25
+ return error.reason === 'host_capacity' ? `waiting for room on the workspace's host${position}` : 'waiting for a free working slot: every workspace the plan lets work at once is busy';
26
+ }
9
27
  export function retryableAttach(error) {
10
28
  if (error instanceof ShardfluxProtocolError && /invalid|byte gap|omitted/.test(error.message))
11
29
  return false;
@@ -164,6 +182,10 @@ export async function attachTerminal(options) {
164
182
  controller.abort();
165
183
  } });
166
184
  let reconnects = 0;
185
+ // Consecutive failed attaches: the backoff's exponent, back to 0 once an attach delivers a message.
186
+ let failures = 0;
187
+ let lastError;
188
+ let notice = null;
167
189
  let asleep = false;
168
190
  try {
169
191
  while (!signal.aborted) {
@@ -193,6 +215,9 @@ export async function attachTerminal(options) {
193
215
  for await (const message of connection.messages()) {
194
216
  if (signal.aborted)
195
217
  break;
218
+ // The attach delivers: a later drop starts the backoff over, and the next wait for room is said again.
219
+ failures = 0;
220
+ notice = null;
196
221
  const bytes = cursor.consume(message);
197
222
  if (bytes?.length)
198
223
  io.stdout.write(bytes);
@@ -212,6 +237,7 @@ export async function attachTerminal(options) {
212
237
  asleep = true;
213
238
  continue;
214
239
  }
240
+ lastError = error;
215
241
  }
216
242
  finally {
217
243
  const previous = connection;
@@ -220,7 +246,14 @@ export async function attachTerminal(options) {
220
246
  await previous?.close();
221
247
  }
222
248
  reconnects += 1;
223
- await options.sleep(Math.min(2_000, 100 * 2 ** Math.min(reconnects, 5)), signal);
249
+ failures += 1;
250
+ // Waiting for room on the workspace's host (or a working slot): said once, and again when the position changes.
251
+ const waiting = admissionNotice(lastError);
252
+ if (waiting && waiting !== notice)
253
+ io.stderr.write(`\r\n\x1b[2m· ${waiting}\x1b[0m\r\n`);
254
+ notice = waiting;
255
+ await options.sleep(reattachDelayMs(lastError, failures), signal);
256
+ lastError = undefined;
224
257
  }
225
258
  if (inputFailure)
226
259
  throw inputFailure;
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.15.2";
1
+ export declare const CLI_VERSION = "0.16.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;