@shardflux/cli 0.18.0 → 0.19.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 (54) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +28 -1
  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 +55 -18
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +16 -4
  18. package/dist/command-center-actions.js +31 -5
  19. package/dist/conversation-commands.js +6 -1
  20. package/dist/conversation-router.js +7 -11
  21. package/dist/conversation-stash.js +64 -18
  22. package/dist/conversation-terminal.d.ts +11 -1
  23. package/dist/conversation-terminal.js +28 -7
  24. package/dist/conversations.d.ts +17 -3
  25. package/dist/conversations.js +58 -6
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.js +1 -1
  28. package/dist/files-mirror.d.ts +1 -9
  29. package/dist/files-mirror.js +19 -3
  30. package/dist/format.js +37 -2
  31. package/dist/handlers.js +3 -1
  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-launch.js +3 -1
  49. package/dist/t3-mode.d.ts +7 -2
  50. package/dist/t3-mode.js +39 -5
  51. package/dist/thread-on-computer.js +4 -0
  52. package/dist/worktree-twin.d.ts +2 -6
  53. package/dist/worktree-twin.js +16 -51
  54. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,6 +1,101 @@
1
1
  # Changelog
2
2
 
3
- ## 0.18.0 (not yet published)
3
+ ## 0.19.0 (not yet published)
4
+
5
+ - A new conversation on an account without a saved machine gets its first prompt in seconds, whatever is in
6
+ `~/.claude` and `~/.codex`. The prompt is typed first, and this computer's Claude and Codex config is copied into
7
+ the VM after the start, by a detached `shard` that the starting command does not wait for (its one-line result goes
8
+ to `agent-config-sync.log` in the config directory). Before, the copy ran first, one request per file: a config with
9
+ 3,000 files held the prompt back for over 5 minutes.
10
+ - The config copy sends a pass as one archive and one command in the VM instead of a check and a write per file
11
+ (3,090 files: 6.6 s including plugin installs, an unchanged copy 1 s). The rules stay the same: a file you changed
12
+ in the VM is never overwritten or removed, and a path that is not a regular file there is skipped.
13
+ - Claude Code plugins are installed by the VM's own Claude Code (`claude plugin marketplace add` and `claude plugin
14
+ install --scope user` for each user-scope plugin) instead of copying plugin caches and marketplace clones, so
15
+ Linux builds of each plugin are used. Codex's bundled `~/.codex/skills/.system` is left to the VM's Codex. A plugin
16
+ from a marketplace on a local folder is listed as skipped.
17
+ - A config file that does not parse (`~/.claude.json`, `~/.codex/config.toml`, the plugin registries) is skipped with
18
+ its reason; the rest of the config is still copied.
19
+ - A start reaches the agent about 7 s sooner: the snapshot of the working tree a conversation starts from is still
20
+ recorded before the agent starts, and its copy to the project's shared history now finishes in the background
21
+ (staging: Start to the first prompt typed 10.0 s, from 17.1 s).
22
+ - `shard setup --inspect`'s demo step reads "Paste the task, then Start.": the launcher opens on Daylight (sample).
23
+ - A conversation start tells the launcher which phase it is in (for the Mac menu bar and the Ubuntu app's phase line
24
+ under **Starting…**): with `SHARDFLUX_PROGRESS_FILE` naming a private file the launcher created, `shard agents new`
25
+ rewrites it atomically with one JSON line each time the phase changes, `{"phase":"copy_changes","line":"Copying
26
+ your changes…"}`. Phases: `computer` (Starting a computer…), `project` (Getting your project…), `checkout` (Checking
27
+ out your files…), `copy_changes` (Copying your changes…) and `harness` (Starting Claude… / Starting Codex…). Nothing
28
+ is written without the variable; a symlink, a file or directory others can read or write, or a file the launcher
29
+ already removed is never written.
30
+ - T3 Code (stable 0.0.45) connects with T3 Connect, once per person: `shard setup --inspect --t3-connect --json`
31
+ starts the sign-in, and its `t3` row carries the page and code (`t3_link`). Once it is approved the row is
32
+ `verified` per account (`Your projects appear in T3 Code and the T3 app.`, or `<project> is linked to your T3
33
+ account.` for the newest project set up for T3 Code), and every project set up for T3 Code appears in T3 Code and
34
+ the T3 app signed in to that account. With the sign-in, `agents targets` reports T3 Code connected for every
35
+ project, so a project's first thread can start in T3 Code: it is linked first, then the thread starts. Without the
36
+ sign-in the row offers **Connect T3 Code**; `next` still names `shard agents new --in t3 --path <project>` for
37
+ scripts. `shard agents new|open --in t3` says the project is linked to your T3 account instead of how to add an SSH
38
+ environment, and the first time a project is set up for T3 Code it opens T3 on its Connections page
39
+ (`t3code://app/settings/connections`) once the project is linked (on Linux through T3's registered URL handler, else
40
+ T3 Code opens). The project's SSH host is still configured for anyone who adds it in T3 Code themselves.
41
+ - Brings the Shardflux app for Ubuntu 0.1.2: T3 Code connects from the first run like Claude and Codex (one T3
42
+ Connect sign-in), and your own project starts in T3 Code. The optional dependency on
43
+ `@shardflux/desktop-linux-x64` / `-arm64` is exactly 0.1.2.
44
+
45
+ - `shard usage` shows GPU-hours on plans that include them: a `GPU-hours` line with the hours used of those included,
46
+ the hours past them and the part billed as overage, and while the GPU is stopped a `GPU` line naming the reason
47
+ (`allowance_used`, `overage_paused`, `spend_cap_reached`) and what brings it back (turn on overage, upgrade, pay the
48
+ invoice, raise the cap, or the period reset). Workspaces keep running meanwhile. `shard billing overage` lists the
49
+ GPU-hour rate, and a 402 `entitlement_required` `not_included` refusal gets an upgrade hint naming the plans that
50
+ include the feature (the GPU: Startup or Scale). Needs SDK 0.25.0.
51
+
52
+ - `shard move`: a Claude Code or Codex session on this computer continues in the cloud as the same session, with its
53
+ full history. `shard move --last` in the project folder moves its newest session (`--claude` or `--codex` to
54
+ choose), `--session <id>` a given one, and `--list` shows the project's sessions and where moved ones continue; `--json`
55
+ for scripts and apps, `--detach` to start it without opening it here. A running turn finishes here first. The
56
+ conversation starts from your project exactly as it is, uncommitted edits included (your working tree, index and
57
+ branch are untouched), its VM resumes the session (`claude --resume`, `codex resume`) with its paths moved to the
58
+ VM's folder, and the session's transcript is in the project space from the start. The cloud work comes back to
59
+ your checkout by itself, and typing in the local Claude Code copy shows one line that it now continues in the cloud.
60
+ Sessions from Claude Desktop's Code tab and the Codex app move the same way. Needs SDK 0.25.0.
61
+ - When a moved session's conversation ends, its transcript comes back to the file it left on this computer, as long as
62
+ nobody continued that local copy meanwhile (the copy it replaces stays in the conversation's stash).
63
+ - Claude Code's folder name for a long project path (above 200 characters) is the one Claude Code uses, so finished
64
+ conversations of such projects appear in its own history.
65
+ - A new conversation's VM gets your project from Git: HEAD is checked out in the VM, and only what differs from HEAD
66
+ on this computer is copied (changed, added and untracked files Git does not ignore, `.env*` files, Git LFS files,
67
+ deletions), from `git status`, not a hash of every file. A project's file count, size and history no longer refuse
68
+ a start (`repository_too_large` is gone): a history above 32 MiB comes from origin as a blobless partial clone (all
69
+ commits and trees; HEAD's files in one request; the rest backfills in the background at the lowest priority), and
70
+ without a reachable origin the history bundle streams from this computer in chunks of any size. The only start
71
+ refusal left, a folder inside another repository, comes before a VM is allocated. `SHARDFLUX_TRACE_SEED=1` prints
72
+ each start phase's duration on stderr.
73
+ - Project sync carries a project of any shape: no file-count limit and no per-file size limit. Files above 8 MiB
74
+ stream both ways one chunk at a time (verified against their hash, never held whole in memory), files older VMs
75
+ leave out of their manifest for size are hashed in the VM, and only what a VM cannot hold is reported as skipped
76
+ (sockets and devices, nested repositories, names with a backslash) instead of failing the sync. A path that turns
77
+ from a directory into a file (or back) syncs both ways.
78
+ - The live twin's snapshot and landing carry files and bundles of any size (streamed from and to disk); the 64 MiB
79
+ per-file hold and the 256 MiB transfer bound are gone.
80
+ - Conversation transcripts of any size mirror into the local stash (read in chunks, checked as UTF-8 and against
81
+ their hash); the 64 MiB mirror bound is gone. The project router copies history of any size between VMs.
82
+
83
+ ## 0.18.1 (2026-10-10)
84
+
85
+ - `shard agents logs` takes a conversation id (`shard agents logs conv_… --json`) and follows that conversation's own
86
+ terminal: the PTY its computer recorded for it (a fork's inherited one included), also when you name the
87
+ conversation's VM by its key. `--json` adds `conversation: { id }`. Before, a conversation id read as an unknown
88
+ workspace key and a conversation VM's key looked for a terminal named after the VM.
89
+ - A finished conversation whose VM was removed opens again from every app: `shard agents open <conversation>` with
90
+ `--in auto` (the menu bar), `--in web`, `--in claude-desktop` or `--in t3` restores it into a fresh VM from its saved
91
+ branch and transcript (the same conversation, its session resumed and started) instead of answering `This
92
+ conversation is saved. Resume it to continue.` A restore says `Restored` once. `shard conversations send` says it
93
+ too when the API restored the conversation to take the message.
94
+ - Brings the Shardflux app for Ubuntu 0.1.1, which reads this CLI's `--json` answers whole: Settings → Agents shows
95
+ Connected, the top-bar panel lists your conversations, and Start opens the conversation it started. The optional
96
+ dependency on `@shardflux/desktop-linux-x64` / `-arm64` is exactly 0.1.1.
97
+
98
+ ## 0.18.0 (2026-10-10)
4
99
 
5
100
  - A conversation that needs you says why: `shard conversations list` adds it as a fifth column, `shard conversations
6
101
  get` prints it after the branch, and `shard agents list` shows it as the session's LAST MESSAGE (one line, cut to
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
@@ -428,7 +443,7 @@ plan only when Stripe confirms the payment, so `--wait` polls until the subscrip
428
443
  open). An organization that already has a subscription changes plans in the billing portal: the
429
444
  command prints the portal URL instead (exit 0).
430
445
 
431
- On the signup trial (60 days of Pro, no card), `billing status` shows when it ends and
446
+ On the signup trial (30 days of Pro, no card), `billing status` shows when it ends and
432
447
  what follows (0.6.2+): `plan trial, ends 2026-11-29 14:03:00Z, then free (trial, catalog ...)`, then
433
448
  `keep these limits after the trial: shard billing upgrade pro` (0.13.4+). A subscription outranks the
434
449
  trial and ends it. The trial is granted, never bought, so `billing plans` leaves it out.
@@ -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,7 +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 { conversationTerminal } from "./conversation-terminal.js";
31
+ import { conversationTerminal, reopenConversation } from "./conversation-terminal.js";
32
32
  import { CHOICE_PENDING, choiceReport, hasChoice, launchChoice, modelChoice, runFlags } from "./agent-models.js";
33
33
  const active = (session) => session.state === 'running' || session.state === 'starting';
34
34
  /** `<slug>-<6 hex of sha256(identity)>`: the same repository gets the same key on every machine. */
@@ -148,12 +148,15 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
148
148
  if (typeof values.initialPrompt === 'string') {
149
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));
150
150
  }
151
+ // Work that must never delay the first prompt (the laptop's agent config, 0.19.0+) starts once the agent has it.
152
+ const after = conversation?.afterStart?.();
151
153
  if (!attach) {
152
154
  await preparation;
153
155
  // conversation (0.16.0+): which conversation this session is, also when its computer hosts several threads.
154
156
  // model_choice (0.17.0+): the requested model and effort, and whether this thread started with them.
155
157
  ctx.io.stdout.write(json({ workspace: ws.data, session, ...(conversation ? { conversation: { id: conversation.id } } : {}), ...(typeof values.initialPrompt === 'string' ? { initial_prompt: { submitted: true } } : {}),
156
158
  ...(hasChoice(explicit) ? { model_choice: choiceReport(explicit, applied) } : {}) }));
159
+ await after;
157
160
  return 0;
158
161
  }
159
162
  ctx.io.stderr.write(`Shardflux · ${ws.key}\nDetach: Ctrl-] then d\n`);
@@ -171,7 +174,7 @@ export async function terminal(ctx, ws, app, agent, project, values, setupHash,
171
174
  done();
172
175
  }),
173
176
  });
174
- const [code] = await Promise.all([attached, preparation]);
177
+ const [code] = await Promise.all([attached, preparation, after]);
175
178
  return code;
176
179
  }
177
180
  finally {
@@ -222,8 +225,10 @@ export async function launchProjectWith(ctx, values, [path = './', explicitApp],
222
225
  const labels = projectLabels(project, agent, app);
223
226
  const local = like ? projectIdentity(path) : project;
224
227
  // 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}`))
228
+ if (!like?.labels.repo_remote || like.labels.repo_remote === (local.remote ?? `file://${local.folder}`)) {
226
229
  await ensureProjectGit(ctx, local.folder);
230
+ await preflightProjectSeed(local.folder, ctx.signal);
231
+ }
227
232
  const cloud = ctx.cloud();
228
233
  let existing = await cloud.workspaces.findByKey(`${project.slug}/${agent}`, { includeDeleted: false, signal: ctx.signal });
229
234
  const owner = agent === 'main' ? existing : like ?? await cloud.workspaces.findByKey(`${project.slug}/main`, { includeDeleted: false, signal: ctx.signal });
@@ -299,7 +304,12 @@ export const AGENT_HANDLERS = {
299
304
  if (values.in === 'auto') {
300
305
  // The menu and other launchers use the same CLI-owned target selection. Never put provider URLs in a client.
301
306
  // A conversation id opens that conversation's own session, also when its computer hosts other threads.
302
- const ws = resolvedConversation ? await conversationWorkspace(ctx, resolvedConversation) : await resolveWorkspace(ctx, agent);
307
+ // A conversation whose VM was removed restores into a fresh one first (0.18.1+).
308
+ let ws;
309
+ if (resolvedConversation)
310
+ ({ conversation: resolvedConversation, ws } = await reopenConversation(ctx, resolvedConversation, values));
311
+ else
312
+ ws = await resolveWorkspace(ctx, agent);
303
313
  const project = ws.labels.repo_remote || ws.labels.shard_project || ws.key;
304
314
  const store = new AgentStateStore(ctx.store.dir);
305
315
  const app = values.app ?? resolvedConversation?.harness ?? ws.labels.shard_app ?? ws.labels.app ?? store.app(project);
@@ -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>;