@shardflux/cli 0.18.1 → 0.19.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 (53) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +27 -0
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/initial-prompt.d.ts +15 -2
  31. package/dist/initial-prompt.js +29 -4
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-mode.d.ts +7 -2
  49. package/dist/t3-mode.js +39 -5
  50. package/dist/thread-on-computer.js +4 -0
  51. package/dist/worktree-twin.d.ts +2 -6
  52. package/dist/worktree-twin.js +16 -51
  53. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,6 +1,98 @@
1
1
  # Changelog
2
2
 
3
- ## 0.18.1 (not yet published)
3
+ ## 0.19.1 (not yet published)
4
+
5
+ - A first prompt is never lost to a slow or unusual start. `--prompt` / `--prompt-file` types the prompt only into the
6
+ agent's input box, never into one of its dialogs ("❯ 1. Yes"). If the terminal never shows the input box and
7
+ nothing was typed, the prompt goes to the conversation's own inbox, which delivers it once the agent reads input.
8
+ `--json` then reports `"initial_prompt": {"submitted": false, "queued": true}` instead of failing with
9
+ `initial_prompt_failed`.
10
+ - Brings the Shardflux app for Ubuntu 0.1.3: Try it once never waits silently. A demo agent still waiting with nothing
11
+ said 45 s after its start gets its task once more through its conversation, and one line says so; still nothing
12
+ 45 s later, one line says it hasn't started. The optional dependency on `@shardflux/desktop-linux-x64` / `-arm64` is
13
+ exactly 0.1.3.
14
+
15
+ ## 0.19.0 (2026-10-11)
16
+
17
+ - A new conversation on an account without a saved machine gets its first prompt in seconds, whatever is in
18
+ `~/.claude` and `~/.codex`. The prompt is typed first, and this computer's Claude and Codex config is copied into
19
+ the VM after the start, by a detached `shard` that the starting command does not wait for (its one-line result goes
20
+ to `agent-config-sync.log` in the config directory). Before, the copy ran first, one request per file: a config with
21
+ 3,000 files held the prompt back for over 5 minutes.
22
+ - The config copy sends a pass as one archive and one command in the VM instead of a check and a write per file
23
+ (3,090 files: 6.6 s including plugin installs, an unchanged copy 1 s). The rules stay the same: a file you changed
24
+ in the VM is never overwritten or removed, and a path that is not a regular file there is skipped.
25
+ - Claude Code plugins are installed by the VM's own Claude Code (`claude plugin marketplace add` and `claude plugin
26
+ install --scope user` for each user-scope plugin) instead of copying plugin caches and marketplace clones, so
27
+ Linux builds of each plugin are used. Codex's bundled `~/.codex/skills/.system` is left to the VM's Codex. A plugin
28
+ from a marketplace on a local folder is listed as skipped.
29
+ - A config file that does not parse (`~/.claude.json`, `~/.codex/config.toml`, the plugin registries) is skipped with
30
+ its reason; the rest of the config is still copied.
31
+ - A start reaches the agent about 7 s sooner: the snapshot of the working tree a conversation starts from is still
32
+ recorded before the agent starts, and its copy to the project's shared history now finishes in the background
33
+ (staging: Start to the first prompt typed 10.0 s, from 17.1 s).
34
+ - `shard setup --inspect`'s demo step reads "Paste the task, then Start.": the launcher opens on Daylight (sample).
35
+ - A conversation start tells the launcher which phase it is in (for the Mac menu bar and the Ubuntu app's phase line
36
+ under **Starting…**): with `SHARDFLUX_PROGRESS_FILE` naming a private file the launcher created, `shard agents new`
37
+ rewrites it atomically with one JSON line each time the phase changes, `{"phase":"copy_changes","line":"Copying
38
+ your changes…"}`. Phases: `computer` (Starting a computer…), `project` (Getting your project…), `checkout` (Checking
39
+ out your files…), `copy_changes` (Copying your changes…) and `harness` (Starting Claude… / Starting Codex…). Nothing
40
+ is written without the variable; a symlink, a file or directory others can read or write, or a file the launcher
41
+ already removed is never written.
42
+ - T3 Code (stable 0.0.45) connects with T3 Connect, once per person: `shard setup --inspect --t3-connect --json`
43
+ starts the sign-in, and its `t3` row carries the page and code (`t3_link`). Once it is approved the row is
44
+ `verified` per account (`Your projects appear in T3 Code and the T3 app.`, or `<project> is linked to your T3
45
+ account.` for the newest project set up for T3 Code), and every project set up for T3 Code appears in T3 Code and
46
+ the T3 app signed in to that account. With the sign-in, `agents targets` reports T3 Code connected for every
47
+ project, so a project's first thread can start in T3 Code: it is linked first, then the thread starts. Without the
48
+ sign-in the row offers **Connect T3 Code**; `next` still names `shard agents new --in t3 --path <project>` for
49
+ scripts. `shard agents new|open --in t3` says the project is linked to your T3 account instead of how to add an SSH
50
+ environment, and the first time a project is set up for T3 Code it opens T3 on its Connections page
51
+ (`t3code://app/settings/connections`) once the project is linked (on Linux through T3's registered URL handler, else
52
+ T3 Code opens). The project's SSH host is still configured for anyone who adds it in T3 Code themselves.
53
+ - Brings the Shardflux app for Ubuntu 0.1.2: T3 Code connects from the first run like Claude and Codex (one T3
54
+ Connect sign-in), and your own project starts in T3 Code. The optional dependency on
55
+ `@shardflux/desktop-linux-x64` / `-arm64` is exactly 0.1.2.
56
+
57
+ - `shard usage` shows GPU-hours on plans that include them: a `GPU-hours` line with the hours used of those included,
58
+ the hours past them and the part billed as overage, and while the GPU is stopped a `GPU` line naming the reason
59
+ (`allowance_used`, `overage_paused`, `spend_cap_reached`) and what brings it back (turn on overage, upgrade, pay the
60
+ invoice, raise the cap, or the period reset). Workspaces keep running meanwhile. `shard billing overage` lists the
61
+ GPU-hour rate, and a 402 `entitlement_required` `not_included` refusal gets an upgrade hint naming the plans that
62
+ include the feature (the GPU: Startup or Scale). Needs SDK 0.25.0.
63
+
64
+ - `shard move`: a Claude Code or Codex session on this computer continues in the cloud as the same session, with its
65
+ full history. `shard move --last` in the project folder moves its newest session (`--claude` or `--codex` to
66
+ choose), `--session <id>` a given one, and `--list` shows the project's sessions and where moved ones continue; `--json`
67
+ for scripts and apps, `--detach` to start it without opening it here. A running turn finishes here first. The
68
+ conversation starts from your project exactly as it is, uncommitted edits included (your working tree, index and
69
+ branch are untouched), its VM resumes the session (`claude --resume`, `codex resume`) with its paths moved to the
70
+ VM's folder, and the session's transcript is in the project space from the start. The cloud work comes back to
71
+ your checkout by itself, and typing in the local Claude Code copy shows one line that it now continues in the cloud.
72
+ Sessions from Claude Desktop's Code tab and the Codex app move the same way. Needs SDK 0.25.0.
73
+ - When a moved session's conversation ends, its transcript comes back to the file it left on this computer, as long as
74
+ nobody continued that local copy meanwhile (the copy it replaces stays in the conversation's stash).
75
+ - Claude Code's folder name for a long project path (above 200 characters) is the one Claude Code uses, so finished
76
+ conversations of such projects appear in its own history.
77
+ - A new conversation's VM gets your project from Git: HEAD is checked out in the VM, and only what differs from HEAD
78
+ on this computer is copied (changed, added and untracked files Git does not ignore, `.env*` files, Git LFS files,
79
+ deletions), from `git status`, not a hash of every file. A project's file count, size and history no longer refuse
80
+ a start (`repository_too_large` is gone): a history above 32 MiB comes from origin as a blobless partial clone (all
81
+ commits and trees; HEAD's files in one request; the rest backfills in the background at the lowest priority), and
82
+ without a reachable origin the history bundle streams from this computer in chunks of any size. The only start
83
+ refusal left, a folder inside another repository, comes before a VM is allocated. `SHARDFLUX_TRACE_SEED=1` prints
84
+ each start phase's duration on stderr.
85
+ - Project sync carries a project of any shape: no file-count limit and no per-file size limit. Files above 8 MiB
86
+ stream both ways one chunk at a time (verified against their hash, never held whole in memory), files older VMs
87
+ leave out of their manifest for size are hashed in the VM, and only what a VM cannot hold is reported as skipped
88
+ (sockets and devices, nested repositories, names with a backslash) instead of failing the sync. A path that turns
89
+ from a directory into a file (or back) syncs both ways.
90
+ - The live twin's snapshot and landing carry files and bundles of any size (streamed from and to disk); the 64 MiB
91
+ per-file hold and the 256 MiB transfer bound are gone.
92
+ - Conversation transcripts of any size mirror into the local stash (read in chunks, checked as UTF-8 and against
93
+ their hash); the 64 MiB mirror bound is gone. The project router copies history of any size between VMs.
94
+
95
+ ## 0.18.1 (2026-10-10)
4
96
 
5
97
  - `shard agents logs` takes a conversation id (`shard agents logs conv_… --json`) and follows that conversation's own
6
98
  terminal: the PTY its computer recorded for it (a fork's inherited one included), also when you name the
package/README.md CHANGED
@@ -129,6 +129,21 @@ keeps the agent's own choice. The conversation keeps its model and effort when i
129
129
  adds `model_choice: {model, effort, applied}`. `shard agents targets --json` lists the choices as `models`, with menu
130
130
  and pill text and the agent's own default, so apps never hardcode them. `--in t3` passes them to T3 Code.
131
131
 
132
+ Move a session from this computer to the cloud (0.19.0+): it continues there as the same Claude Code or Codex
133
+ session, with its full history, from your project exactly as it is.
134
+
135
+ ```sh
136
+ shard move --last # this project's newest session (--claude or --codex to choose)
137
+ shard move --list # the project's sessions, and where moved ones continue
138
+ shard move --session 3d085e35-aa66-4866-9a53-f09719d6767c --detach --json
139
+ ```
140
+
141
+ A running turn finishes here first. Uncommitted edits come along, and your working tree, index and branch stay as
142
+ they are. The cloud work comes back to your checkout by itself, and typing in the local Claude Code copy shows one line
143
+ that it continues in the cloud. Sessions from Claude Desktop's Code tab and the Codex app move the same way; in the
144
+ macOS menu bar, a project's recent sessions have **Move to cloud** in their ⋯ menu, and with the Shardflux MCP server
145
+ you can ask the agent itself to continue in the cloud.
146
+
132
147
  Start a thread in T3 Code from the command line (0.17.0+):
133
148
 
134
149
  ```sh
@@ -1039,6 +1054,18 @@ names `details.reason`, and a hint says what to do (0.5.0+):
1039
1054
  | `spend_cap_reached` | The spend cap is reached: raise it (`shard billing overage cap <dollars>`, at most the plan price) or upgrade. |
1040
1055
  | `overage_paused` | A payment is past due: update the payment method (`shard billing portal --open`). |
1041
1056
 
1057
+ GPU-hours (0.19.0+): on plans with GPU-hours, `shard usage` adds a `GPU-hours` line with the hours used of those
1058
+ included (and, with overage on, the hours past them and the part billed), and a `GPU` line with the next step whenever
1059
+ GPU rendering needs one:
1060
+
1061
+ ```text
1062
+ GPU-hours 3.4 of 25 used
1063
+ ```
1064
+
1065
+ `shard billing overage` lists the GPU-hour rate with the others, and a 402 `entitlement_required` refusal with reason
1066
+ `not_included` gets a hint naming the plans that include the feature. [/limits](https://docs.shardflux.dev/limits#gpu-hours)
1067
+ has the allowances and the rate.
1068
+
1042
1069
  ## Timing
1043
1070
 
1044
1071
  `--timing` (0.3.0+) says where the time of an open or a wait went. After the call, `shard` prints the lifecycle
@@ -209,6 +209,7 @@ export const ACCOUNT_COMMANDS = [
209
209
  apps: { type: 'string', multiple: true, value: '<claude,codex>', description: 'The apps to verify (default: the ones found on this Mac). Repeatable or comma separated.' },
210
210
  connect: { type: 'boolean', description: 'With --inspect: also copy this Mac’s Codex login and enable the account’s Codex remote device when Codex is picked.' },
211
211
  'retry-codex': { type: 'boolean', description: 'With --inspect --connect: start a Codex remote device that failed or is stuck starting again, once.' },
212
+ 't3-connect': { type: 'boolean', description: 'With --inspect: start your T3 Connect sign-in for T3 Code (0.0.45, over SSH); the t3 row carries its page and code (t3_link). After it, your projects appear in T3 Code and the T3 app (0.19.0+).' },
212
213
  'org-name': { type: 'string', value: '<name>', description: 'Name of the organization to create when you have none.' },
213
214
  'project-name': { type: 'string', value: '<name>', description: 'Name of the project to create when the organization has none (default Default).' },
214
215
  'key-name': { type: 'string', value: '<name>', description: 'Name of the API key (default "shard-cli <hostname>").' },
@@ -23,6 +23,7 @@ export declare function terminal(ctx: Ctx, ws: Workspace, app: 'claude' | 'codex
23
23
  pty?: string;
24
24
  title?: string;
25
25
  choice?: ModelChoice;
26
+ afterStart?: () => Promise<void> | undefined;
26
27
  }): Promise<number>;
27
28
  export declare const launchProject: Handler;
28
29
  /** The project VM launch; with `--in t3` a first message becomes a thread on the T3 server in it (SSH mode, 0.17.0+). */
@@ -17,7 +17,7 @@ import { out, resolveWorkspace } from "./commands.js";
17
17
  import { savedMachine } from "./filesystem-state.js";
18
18
  import { startAgentConfigTunnels, syncAgentConfig } from "./agent-config-sync.js";
19
19
  import { rememberGitAgent } from "./agent-git-commands.js";
20
- import { projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
20
+ import { preflightProjectSeed, projectWorkspacePath, seedAgentRepository, workspaceRepositoryPath } from "./agent-repository.js";
21
21
  import { agentName } from "./agent-git.js";
22
22
  import { cloudClaudeLogin, ensureCloudClaude, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
23
23
  import { progress } from "./progress.js";
@@ -28,6 +28,7 @@ import { openInFor } from "./t3-mode.js";
28
28
  import { openClaudeDesktopSession, openDesktopWorkspace, queueT3Prewarm } from "./agent-target-commands.js";
29
29
  import { submitInitialPrompt, prepareInitialCodexProject } from "./initial-prompt.js";
30
30
  import { conversationWorkspace, conversationsUnavailable, getConversation, listConversations, resumeConversation } from "./conversations.js";
31
+ import { conversationClient } from "./conversation-commands.js";
31
32
  import { conversationTerminal, reopenConversation } from "./conversation-terminal.js";
32
33
  import { CHOICE_PENDING, choiceReport, hasChoice, launchChoice, modelChoice, runFlags } from "./agent-models.js";
33
34
  const active = (session) => session.state === 'running' || session.state === 'starting';
@@ -145,15 +146,22 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
145
146
  throw new Error(`project setup could not be queued: ${queued.stderr.trim() || `exit ${queued.exitCode}`}`);
146
147
  });
147
148
  store.save(project, app);
149
+ let delivery;
148
150
  if (typeof values.initialPrompt === 'string') {
149
- await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), values.initialPrompt, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes));
151
+ const text = values.initialPrompt;
152
+ // A conversation's guest delivers the prompt itself when this terminal never showed its input box (0.19.1+).
153
+ const queue = conversation ? () => conversationClient(ctx).conversations.send(conversation.id, text, { signal: ctx.signal }) : undefined;
154
+ delivery = await submitInitialPrompt(signal => cellConnector(cell, session.session_id)(0, signal), text, ws.key, ctx.signal, app, bytes => cell.pty.input(session.session_id, bytes), queue);
150
155
  }
156
+ // Work that must never delay the first prompt (the laptop's agent config, 0.19.0+) starts once the agent has it.
157
+ const after = conversation?.afterStart?.();
151
158
  if (!attach) {
152
159
  await preparation;
153
160
  // conversation (0.16.0+): which conversation this session is, also when its computer hosts several threads.
154
161
  // model_choice (0.17.0+): the requested model and effort, and whether this thread started with them.
155
- ctx.io.stdout.write(json({ workspace: ws.data, session, ...(conversation ? { conversation: { id: conversation.id } } : {}), ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}),
162
+ ctx.io.stdout.write(json({ workspace: ws.data, session, ...(conversation ? { conversation: { id: conversation.id } } : {}), ...(delivery ? { initial_prompt: delivery === 'submitted' ? { submitted: true } : { submitted: false, queued: true } } : {}),
156
163
  ...(hasChoice(explicit) ? { model_choice: choiceReport(explicit, applied) } : {}) }));
164
+ await after;
157
165
  return 0;
158
166
  }
159
167
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
@@ -171,7 +179,7 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
171
179
  done();
172
180
  }),
173
181
  });
174
- const [code] = await Promise.all([attached, preparation]);
182
+ const [code] = await Promise.all([attached, preparation, after]);
175
183
  return code;
176
184
  }
177
185
  finally {
@@ -222,8 +230,10 @@ export async function launchProjectWith(ctx, values, [path = './', explicitApp],
222
230
  const labels = projectLabels(project, agent, app);
223
231
  const local = like ? projectIdentity(path) : project;
224
232
  // A cloud-only --like launch must not initialize an unrelated local checkout.
225
- if (!like?.labels.repo_remote || like.labels.repo_remote === (local.remote ?? `file://${local.folder}`))
233
+ if (!like?.labels.repo_remote || like.labels.repo_remote === (local.remote ?? `file://${local.folder}`)) {
226
234
  await ensureProjectGit(ctx, local.folder);
235
+ await preflightProjectSeed(local.folder, ctx.signal);
236
+ }
227
237
  const cloud = ctx.cloud();
228
238
  let existing = await cloud.workspaces.findByKey(`${project.slug}/${agent}`, { includeDeleted: false, signal: ctx.signal });
229
239
  const owner = agent === 'main' ? existing : like ?? await cloud.workspaces.findByKey(`${project.slug}/main`, { includeDeleted: false, signal: ctx.signal });
@@ -17,7 +17,21 @@ export declare function readObject(file: string): Promise<ObjectValue | undefine
17
17
  export declare function collectTree(source: string, dest: string, files: ConfigFiles, issues: ConfigIssue[], parents?: Set<string>): Promise<void>;
18
18
  export declare function collectUserFiles(home: string, issues: ConfigIssue[]): Promise<ConfigFiles>;
19
19
  export declare function macBinary(bytes: Buffer): boolean;
20
- /** Preserve v2 install metadata and user-scope records, and copy just the trees needed to load them on Linux. */
21
- export declare function collectPlugins(home: string, remoteHome: string, files: ConfigFiles, issues: ConfigIssue[]): Promise<Set<string>>;
20
+ /**
21
+ * Plugins travel as an install plan, never as files (0.19.0+): the VM's own Claude Code adds each marketplace from its
22
+ * source and installs each user-scope plugin (`claude plugin marketplace add` / `claude plugin install`), so plugin
23
+ * caches and marketplace clones (thousands of files, often built for macOS) are rebuilt there instead of copied.
24
+ * A marketplace with a local source cannot be rebuilt in the VM and is listed as skipped.
25
+ */
26
+ export type PluginPlan = {
27
+ marketplaces: {
28
+ name: string;
29
+ source: string;
30
+ }[];
31
+ plugins: string[];
32
+ };
33
+ /** A remote marketplace source as `claude plugin marketplace add` takes it; undefined for a local one. */
34
+ export declare function marketplaceSource(entry: ObjectValue): string | undefined;
35
+ export declare function collectPlugins(home: string, issues: ConfigIssue[]): Promise<PluginPlan>;
22
36
  /** Only rewrite paths to files we actually send. Missing laptop paths are never made to look portable. */
23
37
  export declare function rewritePaths(value: unknown, home: string, remoteHome: string, files: ConfigFiles, issues: ConfigIssue[], item: string): unknown;
@@ -1,5 +1,5 @@
1
1
  import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
- import { join, relative, resolve } from 'node:path';
2
+ import { join, relative } from 'node:path';
3
3
  import { LOCAL_PATH_REASON } from "./agent-config-report.js";
4
4
  export const object = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
5
5
  export const text = (v) => typeof v === 'string' ? v : undefined;
@@ -48,7 +48,7 @@ export async function collectTree(source, dest, files, issues, parents = new Set
48
48
  if (info.isDirectory()) {
49
49
  const next = new Set(parents).add(resolved);
50
50
  for (const name of (await readdir(resolved)).sort()) {
51
- if (excluded.has(name) || name.startsWith('.env.'))
51
+ if (excluded.has(name) || name.startsWith('.env.') || (dest === '.codex/skills' && name === '.system'))
52
52
  continue;
53
53
  await collectTree(join(resolved, name), `${dest}/${name}`, files, issues, next);
54
54
  }
@@ -95,70 +95,57 @@ export function macBinary(bytes) {
95
95
  return bytes.length >= 8 && bytes.readUInt32BE(4) > 0 && bytes.readUInt32BE(4) <= 20;
96
96
  return new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xbebafeca, 0xbfbafeca]).has(magic);
97
97
  }
98
- /** Preserve v2 install metadata and user-scope records, and copy just the trees needed to load them on Linux. */
99
- export async function collectPlugins(home, remoteHome, files, issues) {
100
- const skipped = new Set();
98
+ /** A remote marketplace source as `claude plugin marketplace add` takes it; undefined for a local one. */
99
+ export function marketplaceSource(entry) {
100
+ const source = object(entry.source);
101
+ const kind = text(source.source);
102
+ const value = kind === 'github' ? text(source.repo) : kind === 'git' || kind === 'url' ? text(source.url) : undefined;
103
+ return value && !value.startsWith('-') && !/[\0\r\n]/.test(value) ? value : undefined;
104
+ }
105
+ async function readPluginRegistry(file, item, issues) {
106
+ try {
107
+ return await readObject(file);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof SyntaxError || /expected an object/.test(String(error))) {
111
+ issues.push({ item, reason: 'not valid JSON; skipped' });
112
+ return undefined;
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+ export async function collectPlugins(home, issues) {
118
+ const plan = { marketplaces: [], plugins: [] };
101
119
  const root = join(home, '.claude/plugins');
102
- const installed = await readObject(join(root, 'installed_plugins.json'));
103
- if (installed) {
104
- const plugins = {};
105
- for (const [id, records] of Object.entries(object(installed.plugins))) {
106
- const kept = [];
107
- for (const raw of Array.isArray(records) ? records : [records]) {
108
- const record = object(raw);
109
- if (record.scope !== 'user') {
110
- issues.push({ item: `plugin:${id}`, reason: 'project/local scope travels with its repository' });
111
- continue;
112
- }
113
- const path = text(record.installPath);
114
- if (!path || !under(resolve(path), join(root, 'cache'))) {
115
- issues.push({ item: `plugin:${id}`, reason: 'install path is outside the plugin cache' });
116
- continue;
117
- }
118
- const dest = relative(home, resolve(path));
119
- const tree = new Map();
120
- const before = issues.length;
121
- await collectTree(path, dest, tree, issues);
122
- if (issues.length !== before || [...tree.values()].some(f => macBinary(f.content))) {
123
- issues.push({ item: `plugin:${id}`, reason: issues.length !== before ? 'plugin tree is incomplete' : 'contains macOS native binaries; install a Linux version' });
124
- continue;
125
- }
126
- for (const [name, file] of tree)
127
- files.set(name, file);
128
- kept.push({ ...record, installPath: `${remoteHome}/${dest}` });
129
- }
130
- if (kept.length)
131
- plugins[id] = kept;
132
- else
133
- skipped.add(id);
120
+ const known = await readPluginRegistry(join(root, 'known_marketplaces.json'), '.claude/plugins/known_marketplaces.json', issues);
121
+ const local = new Set();
122
+ for (const [name, raw] of Object.entries(known ?? {})) {
123
+ const source = marketplaceSource(object(raw));
124
+ if (source)
125
+ plan.marketplaces.push({ name, source });
126
+ else {
127
+ local.add(name);
128
+ issues.push({ item: `marketplace:${name}`, reason: 'local marketplace; add it in the VM with claude plugin marketplace add' });
134
129
  }
135
- files.set('.claude/plugins/installed_plugins.json', { content: Buffer.from(JSON.stringify({ ...installed, plugins }) + '\n'), mode: '0600' });
136
130
  }
137
- const known = await readObject(join(root, 'known_marketplaces.json'));
138
- if (known) {
139
- const portable = {};
140
- for (const [id, raw] of Object.entries(known)) {
141
- const entry = object(raw);
142
- const location = text(entry.installLocation);
143
- if (!location || !under(resolve(location), join(root, 'marketplaces'))) {
144
- issues.push({ item: `marketplace:${id}`, reason: 'marketplace is outside the marketplace directory' });
145
- continue;
146
- }
147
- const dest = relative(home, resolve(location));
148
- const tree = new Map();
149
- const before = issues.length;
150
- await collectTree(location, dest, tree, issues);
151
- if (issues.length !== before)
152
- continue;
153
- // Marketplace source can contain binary packages for other platforms; the installed version is checked above.
154
- for (const [name, file] of tree)
155
- if (!macBinary(file.content))
156
- files.set(name, file);
157
- portable[id] = { ...entry, installLocation: `${remoteHome}/${dest}` };
131
+ const installed = await readPluginRegistry(join(root, 'installed_plugins.json'), '.claude/plugins/installed_plugins.json', issues);
132
+ for (const [id, records] of Object.entries(object(installed?.plugins))) {
133
+ const user = (Array.isArray(records) ? records : [records]).some(r => object(r).scope === 'user');
134
+ if (!user) {
135
+ issues.push({ item: `plugin:${id}`, reason: 'project/local scope travels with its repository' });
136
+ continue;
137
+ }
138
+ if (!/^[A-Za-z0-9._-]+(?:@[A-Za-z0-9._-]+)?$/.test(id)) {
139
+ issues.push({ item: `plugin:${id}`, reason: 'not a plugin@marketplace id' });
140
+ continue;
141
+ }
142
+ if (local.has(id.split('@')[1] ?? '')) {
143
+ issues.push({ item: `plugin:${id}`, reason: 'its marketplace is local to this computer' });
144
+ continue;
158
145
  }
159
- files.set('.claude/plugins/known_marketplaces.json', { content: Buffer.from(JSON.stringify(portable) + '\n'), mode: '0600' });
146
+ plan.plugins.push(id);
160
147
  }
161
- return skipped;
148
+ return plan;
162
149
  }
163
150
  /** Only rewrite paths to files we actually send. Missing laptop paths are never made to look portable. */
164
151
  export function rewritePaths(value, home, remoteHome, files, issues, item) {
@@ -28,6 +28,8 @@ export type ConfigManifest = {
28
28
  hash: string;
29
29
  created: boolean;
30
30
  }>;
31
+ /** The plugin install plan last applied in the VM (0.19.0+): an unchanged plan is not installed again. */
32
+ plugins_hash?: string;
31
33
  skipped: ConfigIssue[];
32
34
  mcp: McpStatus[];
33
35
  tunnels: ConfigTunnel[];
@@ -44,12 +46,21 @@ export type ConfigSyncResult = {
44
46
  export declare function configManifests(ctx: Pick<Ctx, 'store'>): Record<string, ConfigManifest>;
45
47
  /** `like` must not inherit generated MCP secrets restricted to the source workspace. */
46
48
  export declare function agentConfigLikeSecrets(ctx: Ctx, reference: string): Promise<string[] | undefined>;
47
- export declare function applyConfigFiles(cell: CellClient, home: string, files: ConfigFiles, previous: Record<string, ManifestFile>, trustManifest: boolean, issues: ConfigIssue[], signal: AbortSignal, deleteMissing?: boolean, progress?: (path: string, file: ManifestFile | undefined) => void): Promise<{
49
+ /** At least this many files to check go into the VM as one archive and one command instead of a stat and a write each. */
50
+ export declare const BULK_MIN_FILES = 8;
51
+ export declare function applyConfigFiles(cell: CellClient, home: string, files: ConfigFiles, previous: Record<string, ManifestFile>, trustManifest: boolean, issues: ConfigIssue[], signal: AbortSignal, deleteMissing?: boolean, progress?: (path: string, file: ManifestFile | undefined) => void, trusted?: ReadonlySet<string>): Promise<{
48
52
  files: Record<string, ManifestFile>;
49
53
  copied: string[];
50
54
  removed: string[];
51
55
  }>;
52
56
  export declare function syncAgentConfig(ctx: Ctx, ws: Workspace): Promise<ConfigSyncResult>;
57
+ /**
58
+ * Copies the laptop's agent config into a new conversation's VM after its agent started (0.19.0+): the first prompt
59
+ * never waits for it. From an installed shard it runs as its own detached process (`shard agents config-sync`, its
60
+ * output in agent-config-sync.log of the config directory), so the command that started the conversation reports the
61
+ * start at once; without a runtime (tests, an embedding) it runs here and the returned promise settles when it ends.
62
+ */
63
+ export declare function syncAgentConfigAfterStart(ctx: Ctx, ws: Workspace): Promise<void> | undefined;
53
64
  /** Tunnels are owned by the attachment, and close even if the terminal fails or the user detaches. */
54
65
  export declare function startAgentConfigTunnels(ctx: Ctx, ws: Workspace): Promise<{
55
66
  stop(): Promise<void>;