@shardflux/cli 0.20.0 → 0.23.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 (124) hide show
  1. package/CHANGELOG.md +312 -1
  2. package/README.md +242 -75
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +19 -15
  6. package/dist/account-pool.js +47 -96
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-journal.js +58 -6
  10. package/dist/acp-login.d.ts +13 -5
  11. package/dist/acp-login.js +37 -8
  12. package/dist/acp-models.d.ts +6 -1
  13. package/dist/acp-models.js +29 -19
  14. package/dist/acp.js +120 -14
  15. package/dist/agent-commands.d.ts +4 -1
  16. package/dist/agent-commands.js +110 -42
  17. package/dist/agent-config-return.d.ts +46 -2
  18. package/dist/agent-config-return.js +110 -4
  19. package/dist/agent-credentials.js +7 -3
  20. package/dist/agent-repository.d.ts +16 -3
  21. package/dist/agent-repository.js +149 -74
  22. package/dist/agent-setup.d.ts +14 -3
  23. package/dist/agent-setup.js +78 -36
  24. package/dist/agent-target-commands.d.ts +20 -1
  25. package/dist/agent-target-commands.js +73 -31
  26. package/dist/agent-targets.js +6 -1
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +95 -54
  29. package/dist/bin.js +5 -1
  30. package/dist/browser.d.ts +13 -2
  31. package/dist/browser.js +30 -14
  32. package/dist/claude-cloud-login.d.ts +24 -2
  33. package/dist/claude-cloud-login.js +151 -11
  34. package/dist/cli-proxy-hub.d.ts +61 -25
  35. package/dist/cli-proxy-hub.js +202 -54
  36. package/dist/cli-proxy.d.ts +44 -1
  37. package/dist/cli-proxy.js +177 -28
  38. package/dist/client-settings.d.ts +39 -0
  39. package/dist/client-settings.js +91 -0
  40. package/dist/codex-cloud.d.ts +14 -4
  41. package/dist/codex-cloud.js +135 -31
  42. package/dist/codex-linux.d.ts +14 -0
  43. package/dist/codex-linux.js +69 -0
  44. package/dist/command-center-actions.js +18 -5
  45. package/dist/command-center-model.d.ts +2 -0
  46. package/dist/command-center-model.js +6 -1
  47. package/dist/command-center.js +3 -3
  48. package/dist/commands.js +173 -24
  49. package/dist/conversation-commands.d.ts +5 -0
  50. package/dist/conversation-commands.js +8 -5
  51. package/dist/conversation-terminal.d.ts +10 -2
  52. package/dist/conversation-terminal.js +78 -13
  53. package/dist/conversations.d.ts +86 -1
  54. package/dist/conversations.js +306 -23
  55. package/dist/desktop-linux.js +16 -3
  56. package/dist/device-bridge.d.ts +1 -1
  57. package/dist/device-bridge.js +6 -4
  58. package/dist/device-helper.js +43 -22
  59. package/dist/errors.d.ts +29 -2
  60. package/dist/errors.js +50 -7
  61. package/dist/filesystem-commands.d.ts +16 -9
  62. package/dist/filesystem-commands.js +133 -57
  63. package/dist/format.d.ts +2 -1
  64. package/dist/format.js +10 -1
  65. package/dist/handlers.js +3 -1
  66. package/dist/initial-prompt.d.ts +8 -0
  67. package/dist/initial-prompt.js +43 -1
  68. package/dist/large-media.d.ts +14 -0
  69. package/dist/large-media.js +100 -0
  70. package/dist/linux-terminal.d.ts +5 -3
  71. package/dist/linux-terminal.js +6 -20
  72. package/dist/local-sessions.d.ts +26 -2
  73. package/dist/local-sessions.js +113 -24
  74. package/dist/machine-setup.d.ts +11 -1
  75. package/dist/machine-setup.js +69 -22
  76. package/dist/machine-status.d.ts +67 -0
  77. package/dist/machine-status.js +140 -0
  78. package/dist/main.js +11 -13
  79. package/dist/menubar.d.ts +6 -5
  80. package/dist/menubar.js +38 -28
  81. package/dist/move-worktrees-restore.py +144 -0
  82. package/dist/move-worktrees.d.ts +69 -0
  83. package/dist/move-worktrees.js +252 -0
  84. package/dist/move.d.ts +10 -1
  85. package/dist/move.js +111 -16
  86. package/dist/onboarding.d.ts +11 -7
  87. package/dist/onboarding.js +63 -29
  88. package/dist/prepared-repository.d.ts +204 -0
  89. package/dist/prepared-repository.js +860 -0
  90. package/dist/prepared-repository.py +1307 -0
  91. package/dist/project-preparation.d.ts +62 -0
  92. package/dist/project-preparation.js +316 -0
  93. package/dist/project-sync-manifest.d.ts +4 -0
  94. package/dist/project-sync-manifest.js +5 -1
  95. package/dist/project-sync-transfer.js +1 -1
  96. package/dist/project-sync.d.ts +22 -4
  97. package/dist/project-sync.js +34 -8
  98. package/dist/project-template-setup.d.ts +33 -1
  99. package/dist/project-template-setup.js +59 -2
  100. package/dist/secret-ask.d.ts +72 -0
  101. package/dist/secret-ask.js +351 -0
  102. package/dist/seed-phases.d.ts +8 -1
  103. package/dist/seed-phases.js +17 -2
  104. package/dist/setup-cache.d.ts +7 -0
  105. package/dist/setup-cache.js +32 -0
  106. package/dist/t3-launch.d.ts +2 -1
  107. package/dist/t3-launch.js +12 -6
  108. package/dist/t3-mode.d.ts +8 -2
  109. package/dist/t3-mode.js +19 -4
  110. package/dist/t3-phone.d.ts +4 -2
  111. package/dist/t3-phone.js +12 -4
  112. package/dist/t3-proxy.d.ts +81 -0
  113. package/dist/t3-proxy.js +325 -0
  114. package/dist/terminal-apps.d.ts +168 -0
  115. package/dist/terminal-apps.js +574 -0
  116. package/dist/terminal-onboarding.js +6 -1
  117. package/dist/this-computer.d.ts +11 -0
  118. package/dist/this-computer.js +36 -0
  119. package/dist/thread-on-computer.js +4 -1
  120. package/dist/update.d.ts +2 -0
  121. package/dist/update.js +43 -9
  122. package/dist/worktree-twin.d.ts +10 -2
  123. package/dist/worktree-twin.js +27 -9
  124. package/package.json +5 -5
@@ -20,6 +20,7 @@ import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialIm
20
20
  import { recordSeedSnapshot } from "./files-mirror.js";
21
21
  import { ensureCodexCloud, printCloudThread, submitCloudInitialPrompt } from "./codex-cloud.js";
22
22
  import { modelChoice } from "./agent-models.js";
23
+ import { storedTitlePrefix } from "./client-settings.js";
23
24
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
25
  const SHA = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
25
26
  /** A conversation still bound to its computer: not ended, archived or discarded (a settled `done` turn still is). */
@@ -168,9 +169,11 @@ export async function startThreadOn(ctx, values, path, explicitApp) {
168
169
  const commit = await targetHead(ws, target, ctx.signal);
169
170
  const baseBranch = target.base_branch ?? undefined;
170
171
  await uploadInitialImages(ws.cell(), images, ctx.signal);
172
+ // This computer's title marker (0.20.1+); without one the thread keeps its computer's conversation's.
173
+ const titlePrefix = storedTitlePrefix(ctx.store.dir);
171
174
  const response = await conversationClient(ctx).conversations.create({ projectId: target.project_id, repoRemote: target.repo_remote ?? ws.labels.repo_remote ?? '',
172
175
  harness: app, route: app === 'codex' && !nativeCodex ? 'codex-cloud' : 'terminal', mode: 'join', parentId: target.id, ...(review ? { joinMode: 'reviewer' } : {}),
173
- base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}), ...choice }, { idempotencyKey: randomUUID(), signal: ctx.signal });
176
+ base: { commit, ...(baseBranch ? { branch: baseBranch } : {}) }, ...(name ? { name } : {}), ...choice, ...(titlePrefix !== undefined ? { titlePrefix } : {}) }, { idempotencyKey: randomUUID(), signal: ctx.signal });
174
177
  let c = validateConversation({ ...response.conversation, restore: response.restore });
175
178
  rememberConversation(ctx, c, project.folder, false, baseBranch);
176
179
  store.save(projectKey, app);
package/dist/update.d.ts CHANGED
@@ -55,6 +55,8 @@ export interface UpdateDeps {
55
55
  /** True when nothing is behind but the menu bar app, and the app the npm package carries is already that version. */
56
56
  export declare function onlyAppToSwap(pending: Component[], packagedApp: string | null): boolean;
57
57
  export declare function update(ctx: Ctx, values: Record<string, unknown>, deps?: UpdateDeps): Promise<number>;
58
+ /** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
59
+ export declare function stableNodePath(node: string, env: NodeJS.ProcessEnv): string;
58
60
  /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
59
61
  export declare function recordInstall(dir: string, runtime: Runtime, distribution: {
60
62
  package: string;
package/dist/update.js CHANGED
@@ -9,9 +9,9 @@
9
9
  * macOS) and shows the outcome from last-update.json, also when the update replaced the app that started it.
10
10
  */
11
11
  import { spawn } from 'node:child_process';
12
- import { closeSync, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
12
+ import { accessSync, closeSync, constants, existsSync, openSync, readFileSync, realpathSync, rmSync, writeSync } from 'node:fs';
13
13
  import { homedir } from 'node:os';
14
- import { basename, dirname, join, sep } from 'node:path';
14
+ import { basename, dirname, isAbsolute, join, sep } from 'node:path';
15
15
  import { clientVersionStatus, SDK_VERSION } from '@shardflux/sdk';
16
16
  import { CLI_VERSION } from "./args.js";
17
17
  import { AgentStateStore } from "./agent-state.js";
@@ -95,9 +95,11 @@ export function displayCommand(install, specs) {
95
95
  case 'bun': return `bun add -g ${specs.join(' ')}`;
96
96
  case 'volta': return `volta install ${specs.join(' ')}`;
97
97
  case 'npx': return `npx ${specs[0]}`;
98
- default: return `npm install -g ${specs.join(' ')}`;
98
+ default: return `npm install -g${install.manager === 'npm' && install.prefix ? ` --prefix ${shellQuote(install.prefix)}` : ''} ${specs.join(' ')}`;
99
99
  }
100
100
  }
101
+ /** Prefixes can contain spaces, quotes or shell substitutions. The actual install still uses argv. */
102
+ const shellQuote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
101
103
  const LABELS = { shardflux: 'shardflux', '@shardflux/cli': 'shard', '@shardflux/sdk': 'SDK', [MENUBAR]: 'menu bar', [MCP]: 'MCP server' };
102
104
  /** Every local piece and its status against the catalog. */
103
105
  export async function components(ctx, install, catalog, menubar = menubarManager(ctx)) {
@@ -106,7 +108,7 @@ export async function components(ctx, install, catalog, menubar = menubarManager
106
108
  local.push(['@shardflux/cli', CLI_VERSION]);
107
109
  local.push(['@shardflux/sdk', SDK_VERSION]);
108
110
  if (menubar.platform === 'darwin') {
109
- const status = await menubar.status().catch(() => null);
111
+ const status = await menubar.status(ctx.store.dir).catch(() => null);
110
112
  if (status?.installed && status.version)
111
113
  local.push([MENUBAR, status.version]);
112
114
  }
@@ -225,12 +227,18 @@ export async function update(ctx, values, deps = {}) {
225
227
  const result = await run(argv, env, ctx.signal);
226
228
  if (result.code !== 0) {
227
229
  const denied = /\bEACCES\b|\bEPERM\b/.test(result.output);
228
- const fix = denied ? `sudo ${command}` : command;
230
+ const home = ctx.io.env.HOME || homedir();
231
+ const userPrefix = join(home, '.local');
232
+ const recoverNpm = denied && install.manager === 'npm';
233
+ const fix = recoverNpm ? displayCommand({ ...install, prefix: userPrefix }, specs) : command;
229
234
  const message = denied
230
235
  ? `${install.manager} cannot write to ${install.prefix ?? install.root}.`
231
236
  : `${install.manager} failed: ${result.output.trim().split('\n').slice(-3).join(' ').slice(0, 400)}`;
232
237
  writeLastUpdate(ctx.store.dir, { status: 'failed', changes: [], message, command: fix });
233
- throw new CodedError(denied ? 'update_permission_denied' : 'update_failed', message, `run: ${fix}`);
238
+ const hint = recoverNpm
239
+ ? `run: ${fix}; then use ${shellQuote(join(userPrefix, 'bin', 'shard'))} instead of the old global shard. This leaves npm config and PATH unchanged.`
240
+ : `run: ${fix}`;
241
+ throw new CodedError(denied ? 'update_permission_denied' : 'update_failed', message, hint);
234
242
  }
235
243
  // The new shard reports what is installed now.
236
244
  const after = await run([runtime.node, runtime.script, 'version', '--json'], { ...env, SHARDFLUX_NO_UPDATE_CHECK: '1' }, ctx.signal);
@@ -284,7 +292,7 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
284
292
  return { status: 'off' };
285
293
  if (new AgentStateStore(ctx.store.dir).read().menubar_stopped === true)
286
294
  return { status: 'off' };
287
- const before = await menubar.status().catch(() => null);
295
+ const before = await menubar.status(ctx.store.dir).catch(() => null);
288
296
  if (!before?.installed)
289
297
  return { status: 'off' };
290
298
  if (runtime && run && env) {
@@ -298,9 +306,35 @@ async function refreshMenubar(ctx, menubar, runtime, run, env) {
298
306
  return { status: 'current' };
299
307
  await menubar.start(ctx.store.dir, ctx.apiUrl);
300
308
  }
301
- const after = await menubar.status().catch(() => null);
309
+ const after = await menubar.status(ctx.store.dir).catch(() => null);
302
310
  return after?.version && after.version !== before.version ? { status: 'restarted', version: after.version } : { status: 'current' };
303
311
  }
312
+ /** Keep an upgrade-stable alias only when it runs this exact Node (never another version from PATH). */
313
+ export function stableNodePath(node, env) {
314
+ let real;
315
+ try {
316
+ real = realpathSync(node);
317
+ }
318
+ catch {
319
+ return node;
320
+ }
321
+ const candidates = (env.PATH ?? '').split(':').filter(isAbsolute).map(dir => join(dir, 'node'));
322
+ // Homebrew's opt link also works when launchd's PATH has no Homebrew entry (including versioned formulae).
323
+ const brew = /^(.*)\/Cellar\/(node(?:@[^/]+)?)\/[^/]+\/bin\/node$/.exec(node);
324
+ if (brew)
325
+ candidates.push(join(brew[1], 'opt', brew[2], 'bin/node'), join(brew[1], 'bin/node'));
326
+ for (const candidate of candidates) {
327
+ try {
328
+ if (candidate.includes('/Cellar/'))
329
+ continue;
330
+ accessSync(candidate, constants.X_OK);
331
+ if (realpathSync(candidate) === real)
332
+ return candidate;
333
+ }
334
+ catch { /* missing alias: try the next one */ }
335
+ }
336
+ return node;
337
+ }
304
338
  /** Records where `shard` runs, for the menu bar's update button. Written on macOS, only when it changed. */
305
339
  export function recordInstall(dir, runtime, distribution, env = {}) {
306
340
  let script = runtime.script;
@@ -328,7 +362,7 @@ export function recordInstall(dir, runtime, distribution, env = {}) {
328
362
  catch {
329
363
  menubarApp = null;
330
364
  }
331
- const doc = { version: 1, node: runtime.node, script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
365
+ const doc = { version: 1, node: stableNodePath(runtime.node, env), script, package: distribution.package, package_version: distribution.version, cli_version: CLI_VERSION, ...(menubarApp ? { menubar_app: menubarApp } : {}) };
332
366
  const text = `${JSON.stringify(doc, null, 2)}\n`;
333
367
  try {
334
368
  if (readFileSync(join(dir, INSTALL_FILE), 'utf8') === text)
@@ -1,5 +1,6 @@
1
1
  import type { CellClient } from '@shardflux/sdk';
2
2
  import { AgentStateStore } from './agent-state.js';
3
+ import type { OutsideFile } from './large-media.js';
3
4
  /** A file the landing rewrites must be untouched this long first. */
4
5
  export declare const QUIET_MS = 2000;
5
6
  export declare const HUB_PATH = "/shardflux/project/git/hub.git";
@@ -49,10 +50,12 @@ export interface Snapshot {
49
50
  root: string;
50
51
  id: string;
51
52
  headTree: string;
53
+ /** Large untracked media left out of the tree (large-media.ts): a start carries them as files beside it (0.22.0+). */
54
+ outside: OutsideFile[];
52
55
  }
53
56
  /**
54
- * One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` left out),
55
- * first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
57
+ * One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` and large
58
+ * untracked media left out), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
56
59
  */
57
60
  export declare function snapshotWorktree(info: WorktreeInfo, options: {
58
61
  device: string;
@@ -247,6 +250,8 @@ export interface TwinConversationState {
247
250
  seed?: string;
248
251
  seed_snapshot?: boolean;
249
252
  folder?: string;
253
+ /** Large untracked media the seed snapshot left out (large-media.ts): the copy seed's VM repository ignores them. */
254
+ outside?: string[];
250
255
  upto?: string;
251
256
  tip?: string;
252
257
  landed_revision?: string;
@@ -282,6 +287,7 @@ export declare function deviceId(stateDir: string): string;
282
287
  export interface TwinSeed {
283
288
  commit: string;
284
289
  snapshot: boolean;
290
+ outside?: OutsideFile[];
285
291
  }
286
292
  /**
287
293
  * The commit a new conversation starts from: HEAD when the worktree is clean (or landing is off, or the user's Git
@@ -306,4 +312,6 @@ export declare function twinCheckoutArgv(stateDir: string, conversation: {
306
312
  base_commit: string;
307
313
  worktree_path: string;
308
314
  }): string[];
315
+ /** A gitignore line matching exactly this path from the worktree root (prepared-repository.py exclude_pattern). */
316
+ export declare function excludePattern(path: string): string;
309
317
  export {};
@@ -18,6 +18,7 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
18
18
  import { setTimeout as delay } from 'node:timers/promises';
19
19
  import { AgentStateStore } from "./agent-state.js";
20
20
  import { readSyncFileTo, writeSyncFileFrom } from "./project-sync-transfer.js";
21
+ import { largeUntrackedMedia, outsideSpecs } from "./large-media.js";
21
22
  /** A file the landing rewrites must be untouched this long first. */
22
23
  export const QUIET_MS = 2000;
23
24
  export const HUB_PATH = '/shardflux/project/git/hub.git';
@@ -108,8 +109,8 @@ export const hubTwinRef = (device, id) => `refs/shard/twin/${device}/${id}`;
108
109
  const identity = (now) => ({ GIT_AUTHOR_NAME: 'Shardflux', GIT_AUTHOR_EMAIL: 'twin@shardflux.dev', GIT_COMMITTER_NAME: 'Shardflux', GIT_COMMITTER_EMAIL: 'twin@shardflux.dev',
109
110
  GIT_AUTHOR_DATE: `@${Math.floor(now.getTime() / 1000)} +0000`, GIT_COMMITTER_DATE: `@${Math.floor(now.getTime() / 1000)} +0000` });
110
111
  /**
111
- * One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` left out),
112
- * first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
112
+ * One snapshot commit of the worktree's working state (what `git add -A` sees, any file size; `.shard/` and large
113
+ * untracked media left out), first parent HEAD. Kept as refs/shard/twin/<worktree id>; an unchanged state reuses it.
113
114
  */
114
115
  export async function snapshotWorktree(info, options) {
115
116
  if (!info.head)
@@ -130,8 +131,10 @@ export async function snapshotWorktree(info, options) {
130
131
  }
131
132
  else
132
133
  await git(info.root, ['read-tree', info.head], { env, signal });
133
- // Every file, whatever its size: Git stores large files as streamed loose objects.
134
- const specs = ['.', ':(exclude).shard', ':(exclude,glob)**/.shard/**', ':(exclude,glob)**/*.shard-tmp-*'];
134
+ // Every file, whatever its size (Git stores large files as streamed loose objects), except untracked media: hashing
135
+ // and storing those cost seconds per snapshot (617 MB of media: 13.4 s), and they are not the user's code.
136
+ const outside = await largeUntrackedMedia(info.root, options.stateDir, id, signal);
137
+ const specs = ['.', ':(exclude).shard', ':(exclude,glob)**/.shard/**', ':(exclude,glob)**/*.shard-tmp-*', ...outsideSpecs(outside)];
135
138
  await git(info.root, ['add', '-A', '--ignore-errors', '--pathspec-from-file=-', '--pathspec-file-nul'], { env, input: specs.join('\0') + '\0', signal });
136
139
  const tree = await gitText(info.root, ['write-tree'], { env, signal });
137
140
  const headTree = await gitText(info.root, ['rev-parse', `${info.head}^{tree}`], { signal });
@@ -147,7 +150,7 @@ export async function snapshotWorktree(info, options) {
147
150
  commit = await gitText(info.root, ['commit-tree', tree, '-p', info.head, '-F', '-'], { env: identity(options.now ?? new Date()), input: message, signal });
148
151
  await git(info.root, ['update-ref', twinRef(id), commit], { signal });
149
152
  }
150
- return { commit, tree, base: info.head, branch: info.branch, root: info.root, id, headTree };
153
+ return { commit, tree, base: info.head, branch: info.branch, root: info.root, id, headTree, outside };
151
154
  }
152
155
  finally {
153
156
  rmSync(index, { force: true });
@@ -162,7 +165,7 @@ export async function refTwin(repo, branch, device, now = new Date()) {
162
165
  const id = createHash('sha256').update(`${realpathSync(repo)}\0${branch}`).digest('hex').slice(0, 16);
163
166
  const commit = await gitText(repo, ['commit-tree', tree, '-p', base, '-F', '-'], { env: identity(now), input: `Shardflux twin of ${branch}\n\n${SNAPSHOT_TRAILER}: ${device}/${id}\nShardflux-Branch: ${branch}\n` });
164
167
  await git(repo, ['update-ref', twinRef(id), commit]);
165
- return { commit, tree, base, branch, root: realpathSync(repo), id, headTree: tree };
168
+ return { commit, tree, base, branch, root: realpathSync(repo), id, headTree: tree, outside: [] };
166
169
  }
167
170
  async function rawDiff(cwd, a, b) {
168
171
  const parts = (await git(cwd, ['diff-tree', '-r', '-z', '--raw', '--no-renames', '--no-abbrev', a, b])).stdout.toString('utf8').split('\0');
@@ -771,10 +774,11 @@ export async function twinSeed(stateDir, folder, head, project) {
771
774
  if (info.head !== head || pauseReason(info))
772
775
  return { commit: head, snapshot: false };
773
776
  const snap = await snapshotWorktree(info, { device: deviceId(stateDir), stateDir });
774
- return snap.tree === snap.headTree ? { commit: head, snapshot: false } : { commit: snap.commit, snapshot: true };
777
+ return snap.tree === snap.headTree ? { commit: head, snapshot: false, outside: snap.outside } : { commit: snap.commit, snapshot: true, outside: snap.outside };
775
778
  }
776
779
  export function rememberTwinSeed(stateDir, conversation, folder, seed, branch) {
777
- new TwinLedger(stateDir).patch(conversation, { seed: seed.commit, seed_snapshot: seed.snapshot, folder, ...(branch ? { base_branch: branch } : {}) });
780
+ new TwinLedger(stateDir).patch(conversation, { seed: seed.commit, seed_snapshot: seed.snapshot, folder, ...(branch ? { base_branch: branch } : {}),
781
+ ...(seed.outside?.length ? { outside: seed.outside.map(f => f.path) } : {}) });
778
782
  }
779
783
  /** Carries a snapshot seed into the conversation's VM repository (one small bundle: the snapshot over HEAD). */
780
784
  export async function sendTwinSeed(stateDir, cell, conversation, folder, signal) {
@@ -804,5 +808,19 @@ export function twinCheckoutArgv(stateDir, conversation) {
804
808
  const state = new TwinLedger(stateDir).conversation(conversation.id);
805
809
  if (!state.seed_snapshot || state.seed !== conversation.base_commit)
806
810
  return ['git', '-C', conversation.worktree_path, 'checkout', '-B', conversation.branch, conversation.base_commit];
807
- return ['sh', '-c', 'set -e; git -C "$1" checkout -q -B "$2" HEAD; git -C "$1" reset -q --mixed "$3"', 'shard-seed', conversation.worktree_path, conversation.branch, conversation.base_commit];
811
+ // The copy seed copied the large media the snapshot left out: its Git ignores them too, as the user's sees them
812
+ // (untracked), so an agent never commits them and landing never meets them (0.22.0+).
813
+ const ignore = state.outside?.length ? `${EXCLUDE_BEGIN}\n${state.outside.map(excludePattern).join('\n')}\n${EXCLUDE_END}\n` : '';
814
+ return ['sh', '-c', 'set -e; git -C "$1" checkout -q -B "$2" HEAD; git -C "$1" reset -q --mixed "$3"; if [ -n "$4" ]; then printf %s "$4" >> "$1/.git/info/exclude"; fi',
815
+ 'shard-seed', conversation.worktree_path, conversation.branch, conversation.base_commit, ignore];
816
+ }
817
+ /** The block prepared-repository.py writes for the same files (EXCLUDE_BEGIN/END, exclude_pattern). */
818
+ const EXCLUDE_BEGIN = '# shardflux: large files outside the snapshot (begin)', EXCLUDE_END = '# shardflux: (end)';
819
+ /** A gitignore line matching exactly this path from the worktree root (prepared-repository.py exclude_pattern). */
820
+ export function excludePattern(path) {
821
+ let s = [...path].map(c => '\\*?['.includes(c) ? `\\${c}` : c).join('');
822
+ if (s.startsWith('!') || s.startsWith('#'))
823
+ s = `\\${s}`;
824
+ const stripped = s.replace(/ +$/, '');
825
+ return `/${stripped}${'\\ '.repeat(s.length - stripped.length)}`;
808
826
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.20.0",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork serverless agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,7 @@
47
47
  "smol-toml": "1.9.0",
48
48
  "snappyjs": "0.7.0",
49
49
  "yaml": "2.9.1",
50
- "@shardflux/sdk": "^0.25.1"
50
+ "@shardflux/sdk": "^0.27.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@eslint/js": "10.0.1",
@@ -58,9 +58,9 @@
58
58
  "typescript-eslint": "8.70.1"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@shardflux/desktop-linux-x64": "0.1.5",
62
- "@shardflux/desktop-linux-arm64": "0.1.5",
63
- "@shardflux/menubar-darwin": "^0.8.0"
61
+ "@shardflux/desktop-linux-x64": "0.1.10",
62
+ "@shardflux/desktop-linux-arm64": "0.1.10",
63
+ "@shardflux/menubar-darwin": "^0.8.5"
64
64
  },
65
65
  "scripts": {
66
66
  "build": "node scripts/build.mjs",