@shardflux/cli 0.19.1 → 0.22.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 (115) hide show
  1. package/CHANGELOG.md +278 -1
  2. package/README.md +225 -73
  3. package/dist/account-args.js +7 -2
  4. package/dist/account-commands.js +56 -106
  5. package/dist/account-pool.d.ts +20 -15
  6. package/dist/account-pool.js +51 -97
  7. package/dist/account-scope.d.ts +65 -0
  8. package/dist/account-scope.js +246 -0
  9. package/dist/acp-login.d.ts +4 -3
  10. package/dist/acp-login.js +7 -5
  11. package/dist/acp-models.js +20 -15
  12. package/dist/acp.js +66 -13
  13. package/dist/agent-commands.d.ts +4 -1
  14. package/dist/agent-commands.js +86 -32
  15. package/dist/agent-config-files.d.ts +19 -0
  16. package/dist/agent-config-files.js +70 -1
  17. package/dist/agent-config-return.d.ts +194 -0
  18. package/dist/agent-config-return.js +724 -0
  19. package/dist/agent-config-sync.d.ts +10 -2
  20. package/dist/agent-config-sync.js +45 -51
  21. package/dist/agent-credentials.js +7 -3
  22. package/dist/agent-repository.d.ts +16 -3
  23. package/dist/agent-repository.js +149 -74
  24. package/dist/agent-setup.d.ts +14 -3
  25. package/dist/agent-setup.js +78 -36
  26. package/dist/agent-target-commands.js +24 -13
  27. package/dist/args.d.ts +1 -1
  28. package/dist/args.js +104 -55
  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-forward.d.ts +2 -0
  35. package/dist/cli-proxy-forward.js +9 -2
  36. package/dist/cli-proxy-hub.d.ts +69 -25
  37. package/dist/cli-proxy-hub.js +231 -54
  38. package/dist/cli-proxy.d.ts +70 -1
  39. package/dist/cli-proxy.js +298 -32
  40. package/dist/client-settings.d.ts +39 -0
  41. package/dist/client-settings.js +91 -0
  42. package/dist/codex-cloud.d.ts +5 -0
  43. package/dist/codex-cloud.js +88 -17
  44. package/dist/command-center-actions.js +5 -1
  45. package/dist/commands.js +171 -22
  46. package/dist/conversation-commands.d.ts +5 -0
  47. package/dist/conversation-commands.js +8 -5
  48. package/dist/conversation-terminal.d.ts +10 -2
  49. package/dist/conversation-terminal.js +81 -15
  50. package/dist/conversations.d.ts +77 -1
  51. package/dist/conversations.js +257 -23
  52. package/dist/device-bridge.d.ts +1 -1
  53. package/dist/device-bridge.js +6 -4
  54. package/dist/device-helper.js +52 -4
  55. package/dist/errors.d.ts +34 -1
  56. package/dist/errors.js +56 -5
  57. package/dist/filesystem-commands.d.ts +22 -3
  58. package/dist/filesystem-commands.js +132 -16
  59. package/dist/format.d.ts +2 -1
  60. package/dist/format.js +10 -1
  61. package/dist/handlers.js +4 -1
  62. package/dist/initial-prompt.d.ts +8 -0
  63. package/dist/initial-prompt.js +43 -1
  64. package/dist/large-media.d.ts +14 -0
  65. package/dist/large-media.js +100 -0
  66. package/dist/linux-terminal.d.ts +5 -3
  67. package/dist/linux-terminal.js +6 -20
  68. package/dist/local-sessions.d.ts +26 -2
  69. package/dist/local-sessions.js +113 -24
  70. package/dist/machine-setup.d.ts +190 -0
  71. package/dist/machine-setup.js +849 -0
  72. package/dist/machine-status.d.ts +67 -0
  73. package/dist/machine-status.js +140 -0
  74. package/dist/main.js +23 -13
  75. package/dist/move-worktrees-restore.py +144 -0
  76. package/dist/move-worktrees.d.ts +63 -0
  77. package/dist/move-worktrees.js +201 -0
  78. package/dist/move.d.ts +10 -1
  79. package/dist/move.js +116 -16
  80. package/dist/onboarding.d.ts +11 -7
  81. package/dist/onboarding.js +59 -27
  82. package/dist/prepared-repository.d.ts +202 -0
  83. package/dist/prepared-repository.js +860 -0
  84. package/dist/prepared-repository.py +1306 -0
  85. package/dist/project-preparation.d.ts +48 -0
  86. package/dist/project-preparation.js +304 -0
  87. package/dist/project-sync-manifest.d.ts +4 -0
  88. package/dist/project-sync-manifest.js +5 -1
  89. package/dist/project-sync-transfer.js +1 -1
  90. package/dist/project-sync.d.ts +19 -4
  91. package/dist/project-sync.js +31 -8
  92. package/dist/project-template-setup.d.ts +33 -1
  93. package/dist/project-template-setup.js +59 -2
  94. package/dist/secret-ask.d.ts +72 -0
  95. package/dist/secret-ask.js +351 -0
  96. package/dist/seed-phases.d.ts +13 -3
  97. package/dist/seed-phases.js +21 -3
  98. package/dist/setup-cache.d.ts +7 -0
  99. package/dist/setup-cache.js +32 -0
  100. package/dist/t3-launch.js +1 -4
  101. package/dist/t3-mode.d.ts +8 -2
  102. package/dist/t3-mode.js +19 -4
  103. package/dist/t3-phone.d.ts +4 -2
  104. package/dist/t3-phone.js +12 -4
  105. package/dist/terminal-apps.d.ts +157 -0
  106. package/dist/terminal-apps.js +561 -0
  107. package/dist/terminal-onboarding.d.ts +2 -0
  108. package/dist/terminal-onboarding.js +11 -1
  109. package/dist/this-computer.d.ts +11 -0
  110. package/dist/this-computer.js +36 -0
  111. package/dist/thread-on-computer.js +4 -1
  112. package/dist/update.js +11 -3
  113. package/dist/worktree-twin.d.ts +10 -2
  114. package/dist/worktree-twin.js +27 -9
  115. package/package.json +5 -5
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readInput, str, strs } from "./commands.js";
3
3
  import { PtyOutputCursor } from "./agent-terminal.js";
4
4
  import { CodedError, UsageError } from "./errors.js";
5
+ import { seedEvent, seedPhase } from "./seed-phases.js";
5
6
  import { ShardfluxApiError } from '@shardflux/sdk';
6
7
  import { parse, stringify } from 'smol-toml';
7
8
  /** The user chose this repository. Trust exactly that new VM worktree before launching Codex's first turn. */
@@ -150,6 +151,41 @@ export function inputBoxReady(tail, glyph) {
150
151
  const at = plain.lastIndexOf(glyph);
151
152
  return at >= 0 && !/^[\s\u00a0]*\d+\./.test(plain.slice(at + glyph.length));
152
153
  }
154
+ /**
155
+ * The first prompt typed by the VM's own guest (0.21.1+, with a guest that has it): `sf-agent conversation first-prompt` waits for
156
+ * the input box, pastes, waits for the paste to show and presses Enter inside the VM, with the same checks as
157
+ * `submitInitialPrompt`, in one request from here instead of an attachment and three round trips. Nothing was typed
158
+ * when the result is `unsupported` (an older guest without the command: this terminal types it) or `not_ready` (the
159
+ * harness never showed its input box within the guest's wait: the conversation's inbox takes it, where there is one).
160
+ */
161
+ export async function guestFirstPrompt(cell, ptyId, app, prompt, workspaceKey, signal) {
162
+ const asked = Date.now();
163
+ const r = await cell.exec.run(['sf-agent', 'conversation', 'first-prompt', '--pty', ptyId, '--app', app], { stdin: prompt, timeoutMs: 30_000, maxOutputBytes: 8192, signal });
164
+ let result;
165
+ try {
166
+ result = JSON.parse(r.stdout.trim().split('\n').at(-1) ?? '');
167
+ }
168
+ catch {
169
+ result = undefined;
170
+ }
171
+ // The guest's own steps, on this computer's clock from when the request left (an upper bound by its transit).
172
+ for (const [name, ms] of [['guest_input_box', result?.ready_ms], ['guest_echo', result?.echo_ms], ['guest_submitted', result?.submitted_ms]]) {
173
+ if (typeof ms === 'number')
174
+ seedEvent(name, asked + ms);
175
+ }
176
+ // An older guest answers "unknown tool conversation_first-prompt" and prints no result.
177
+ if (!result || typeof result.pasted !== 'boolean')
178
+ return 'unsupported';
179
+ if (result.submitted === true) {
180
+ seedPhase('prompt_echo');
181
+ return 'submitted';
182
+ }
183
+ if (!result.pasted)
184
+ return 'not_ready';
185
+ // Typed into the input box: it must not be typed again.
186
+ const sent = typeof result.echo_ms === 'number' && result.echo_ms > 0;
187
+ throw new CodedError('initial_prompt_failed', `Created ${workspaceKey}, but the initial prompt ${sent ? 'could not be confirmed' : 'was not submitted'}. Open that conversation before starting another.`, undefined, { workspace_key: workspaceKey, submitted: sent });
188
+ }
153
189
  /**
154
190
  * Wait for the native terminal to enable paste before submitting one first turn. No blind timing or input replay.
155
191
  * When nothing was typed yet and the terminal never became ready, `queue` hands the prompt to the conversation's guest
@@ -162,6 +198,7 @@ export async function submitInitialPrompt(connect, prompt, workspaceKey, signal,
162
198
  let sent = false, pasted = false;
163
199
  try {
164
200
  connection = await connect(bounded);
201
+ seedPhase('attach');
165
202
  let tail = '', pasteEnabled = false;
166
203
  const cursor = new PtyOutputCursor();
167
204
  for await (const message of connection.messages()) {
@@ -176,8 +213,10 @@ export async function submitInitialPrompt(connect, prompt, workspaceKey, signal,
176
213
  pasteEnabled ||= tail.includes('\x1b[?2004h');
177
214
  // eslint-disable-next-line no-control-regex -- Strip native terminal ANSI before matching prompt echo.
178
215
  const plain = tail.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\s/g, '');
179
- if (sent && bytes.length)
216
+ if (sent && bytes.length) {
217
+ seedPhase('prompt_echo');
180
218
  return 'submitted';
219
+ }
181
220
  if (pasted) {
182
221
  // A long prompt overflows the input box: a 24-row terminal shows only its end, so either end confirms the paste.
183
222
  const compact = prompt.replace(/\s/g, '');
@@ -188,6 +227,7 @@ export async function submitInitialPrompt(connect, prompt, workspaceKey, signal,
188
227
  await write(enter);
189
228
  else
190
229
  connection.input(enter);
230
+ seedPhase('prompt_enter');
191
231
  sent = true;
192
232
  tail = '';
193
233
  continue;
@@ -196,10 +236,12 @@ export async function submitInitialPrompt(connect, prompt, workspaceKey, signal,
196
236
  if (!ready || !pasteEnabled)
197
237
  continue;
198
238
  const paste = Buffer.from(`\x1b[200~${prompt}\x1b[201~`);
239
+ seedPhase('input_box');
199
240
  if (write)
200
241
  await write(paste);
201
242
  else
202
243
  connection.input(paste);
244
+ seedPhase('prompt_paste');
203
245
  pasted = true;
204
246
  tail = '';
205
247
  }
@@ -0,0 +1,14 @@
1
+ /** Images, audio and video from this size on (marketing assets of a few hundred KB are the common case). */
2
+ export declare const LARGE_MEDIA_BYTES: number;
3
+ export interface OutsideFile {
4
+ path: string;
5
+ size: number;
6
+ mode: number;
7
+ meta: string;
8
+ }
9
+ /** The worktree's large untracked media, sorted; each file's sample is judged once per version (size, times, inode). */
10
+ export declare function largeUntrackedMedia(root: string, stateDir: string, id: string, signal?: AbortSignal): Promise<OutsideFile[]>;
11
+ /** Pathspecs that leave these files out of `git add -A`. */
12
+ export declare const outsideSpecs: (files: readonly OutsideFile[]) => string[];
13
+ /** The Git object id of a file's bytes with no filters (`git hash-object --no-filters`), streamed. */
14
+ export declare function blobId(path: string, format: 'sha1' | 'sha256', size: number): Promise<string>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Large untracked media (0.22.0+): untracked files of at least LARGE_MEDIA_BYTES whose bytes barely compress (video,
3
+ * images, audio, archives). Twin snapshots leave them out, so a start or a landing never hashes and stores hundreds of
4
+ * megabytes in Git; a start carries them as files beside the snapshot instead (prepared-repository.ts), and the VM's
5
+ * Git ignores them as the user's Git sees them: untracked. Text, however large, stays in the snapshot.
6
+ */
7
+ import { createHash } from 'node:crypto';
8
+ import { constants, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { lstat, open } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { deflateRawSync } from 'node:zlib';
12
+ import { projectGitPaths, validSyncPath } from "./project-sync-manifest.js";
13
+ /** Images, audio and video from this size on (marketing assets of a few hundred KB are the common case). */
14
+ export const LARGE_MEDIA_BYTES = 256 << 10;
15
+ /** A 64 KiB sample that deflates to at least this share of its size counts as incompressible. */
16
+ const INCOMPRESSIBLE = 0.92;
17
+ /** A path the VM can write and its Git can ignore with one exclude line (no line breaks, valid UTF-8). */
18
+ const carriable = (path) => validSyncPath(path) && !/[\n\r]/.test(path) && Buffer.from(path, 'utf8').toString('utf8') === path && !path.includes('�');
19
+ async function sampleIncompressible(path) {
20
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
21
+ try {
22
+ const sample = Buffer.allocUnsafe(64 << 10), { bytesRead } = await file.read(sample, 0, sample.length, 0);
23
+ return bytesRead > 0 && deflateRawSync(sample.subarray(0, bytesRead), { level: 1 }).length >= bytesRead * INCOMPRESSIBLE;
24
+ }
25
+ finally {
26
+ await file.close();
27
+ }
28
+ }
29
+ /** The worktree's large untracked media, sorted; each file's sample is judged once per version (size, times, inode). */
30
+ export async function largeUntrackedMedia(root, stateDir, id, signal) {
31
+ const candidates = [];
32
+ await projectGitPaths(root, ['ls-files', '-z', '--others', '--exclude-standard'], path => { candidates.push(path); }, { signal });
33
+ const dir = join(stateDir, 'twin'), cachePath = join(dir, `${id}.media.json`);
34
+ let cache = {};
35
+ try {
36
+ cache = JSON.parse(readFileSync(cachePath, 'utf8'));
37
+ }
38
+ catch { /* first snapshot */ }
39
+ const next = {}, found = [];
40
+ let at = 0;
41
+ await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, async () => {
42
+ for (let i = at++; i < candidates.length; i = at++) {
43
+ const path = candidates[i];
44
+ if (path.endsWith('/') || path.split('/').some(part => part === '.shard' || part.includes('.shard-tmp-')) || !carriable(path))
45
+ continue;
46
+ let st;
47
+ try {
48
+ st = await lstat(join(root, path));
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ if (!st.isFile() || st.size < LARGE_MEDIA_BYTES)
54
+ continue;
55
+ const meta = `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}:${st.mode}`;
56
+ let media = cache[path]?.meta === meta ? cache[path].media : undefined;
57
+ if (media === undefined) {
58
+ try {
59
+ media = await sampleIncompressible(join(root, path));
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ }
65
+ next[path] = { meta, media };
66
+ if (media)
67
+ found.push({ path, size: st.size, mode: st.mode & 0o777, meta });
68
+ }
69
+ }));
70
+ if (JSON.stringify(next) !== JSON.stringify(cache)) {
71
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ const temp = `${cachePath}.${process.pid}.tmp`;
73
+ writeFileSync(temp, JSON.stringify(next), { mode: 0o600 });
74
+ renameSync(temp, cachePath);
75
+ }
76
+ return found.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
77
+ }
78
+ /** Pathspecs that leave these files out of `git add -A`. */
79
+ export const outsideSpecs = (files) => files.map(f => `:(exclude,literal)${f.path}`);
80
+ /** The Git object id of a file's bytes with no filters (`git hash-object --no-filters`), streamed. */
81
+ export async function blobId(path, format, size) {
82
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW), hash = createHash(format).update(`blob ${size}\0`);
83
+ try {
84
+ const buffer = Buffer.allocUnsafe(4 << 20);
85
+ let read = 0;
86
+ for (;;) {
87
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, read);
88
+ if (!bytesRead)
89
+ break;
90
+ hash.update(buffer.subarray(0, bytesRead));
91
+ read += bytesRead;
92
+ }
93
+ if (read !== size)
94
+ throw new Error('changed while it was read');
95
+ }
96
+ finally {
97
+ await file.close();
98
+ }
99
+ return hash.digest('hex');
100
+ }
@@ -3,9 +3,11 @@ import type { Values } from './args.js';
3
3
  /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
4
4
  export declare function linuxTerminalSession(ctx: Pick<Ctx, 'io' | 'json'>, values: Values, platform?: NodeJS.Platform): boolean;
5
5
  export declare const shellWord: (s: string) => string;
6
- export declare function linuxTerminalCommand(argv: string[], env: Record<string, string | undefined>): {
6
+ /**
7
+ * The Linux default terminal running `argv` (in `cwd` when given): $TERMINAL, x-terminal-emulator, then gnome-terminal.
8
+ * The other terminal apps (Ghostty, kitty, WezTerm, Alacritty, Warp) open through terminal-apps.ts.
9
+ */
10
+ export declare function linuxTerminalCommand(argv: string[], env: Record<string, string | undefined>, cwd?: string): {
7
11
  command: string;
8
12
  args: string[];
9
13
  } | null;
10
- /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
11
- export declare function openLinuxTerminal(ctx: Ctx, ref: string, app?: string): Promise<number>;
@@ -1,12 +1,15 @@
1
1
  import { accessSync, constants } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { spawn } from 'node:child_process';
4
3
  /** Linux terminals use the existing native Codex harness; explicit app routes keep Cloud. */
5
4
  export function linuxTerminalSession(ctx, values, platform = process.platform) {
6
5
  return platform === 'linux' && (values.in === 'terminal' || (values.in === undefined && !!ctx.io.stdin.isTTY && !ctx.json));
7
6
  }
8
7
  export const shellWord = (s) => `'${s.replaceAll("'", "'\\''")}'`;
9
- export function linuxTerminalCommand(argv, env) {
8
+ /**
9
+ * The Linux default terminal running `argv` (in `cwd` when given): $TERMINAL, x-terminal-emulator, then gnome-terminal.
10
+ * The other terminal apps (Ghostty, kitty, WezTerm, Alacritty, Warp) open through terminal-apps.ts.
11
+ */
12
+ export function linuxTerminalCommand(argv, env, cwd) {
10
13
  const executable = (name) => name.includes('/') ? [name] : (env.PATH ?? '').split(':').filter(Boolean).map(p => join(p, name));
11
14
  const exists = (name) => executable(name).some(p => { try {
12
15
  accessSync(p, constants.X_OK);
@@ -16,27 +19,10 @@ export function linuxTerminalCommand(argv, env) {
16
19
  return false;
17
20
  } });
18
21
  // The shell keeps the window open on an attach error so the user can read and retry it.
19
- const attach = ['sh', '-c', `${argv.map(shellWord).join(' ')}; status=$?; if [ "$status" -ne 0 ]; then printf '\nPress Enter to close.'; read answer; fi; exit "$status"`];
22
+ const attach = ['sh', '-c', `${cwd ? `cd ${shellWord(cwd)} 2>/dev/null; ` : ''}${argv.map(shellWord).join(' ')}; status=$?; if [ "$status" -ne 0 ]; then printf '\nPress Enter to close.'; read answer; fi; exit "$status"`];
20
23
  for (const command of [...new Set([env.TERMINAL?.trim(), 'x-terminal-emulator', 'gnome-terminal'].filter((s) => !!s))]) {
21
24
  if (exists(command))
22
25
  return { command, args: [command === 'gnome-terminal' ? '--' : '-e', ...attach] };
23
26
  }
24
27
  return null;
25
28
  }
26
- /** Invoked before cloud work: the new terminal owns the attach, credentials and any prompts. */
27
- export async function openLinuxTerminal(ctx, ref, app) {
28
- const words = ['agents', 'open', ref, '--in', 'terminal', ...(app ? ['--app', app] : [])];
29
- const argv = ctx.runtime ? [ctx.runtime.node, ctx.runtime.script, '--api-url', ctx.apiUrl, ...words] : ['shard', ...words];
30
- const choice = linuxTerminalCommand(argv, ctx.io.env);
31
- if (choice) {
32
- const child = spawn(choice.command, choice.args, { env: ctx.io.env, cwd: process.cwd(), detached: true, stdio: 'ignore' });
33
- try {
34
- await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
35
- child.unref();
36
- return 0;
37
- }
38
- catch { /* print a runnable command if the terminal could not start */ }
39
- }
40
- ctx.io.stdout.write(`${['shard', '--api-url', ctx.apiUrl, ...words].map(shellWord).join(' ')}\n`);
41
- return 0;
42
- }
@@ -4,8 +4,13 @@ export interface LocalSession {
4
4
  session_id: string;
5
5
  /** The transcript (Claude) or rollout (Codex) file. */
6
6
  file: string;
7
- /** The session's working directory, as its transcript records it. */
7
+ /**
8
+ * The session's working directory (0.20.2+: the one most of its latest records name, ties to the newest; before, the
9
+ * first record's). A session started in a scratch folder that went on in a repository is that repository's.
10
+ */
8
11
  cwd: string;
12
+ /** Every working directory its records name (the transcript's head and tail), the latest used last (0.20.2+). */
13
+ cwds: string[];
9
14
  title: string;
10
15
  updated_at: string;
11
16
  bytes: number;
@@ -28,10 +33,29 @@ export declare const codexHome: (env: Env) => string;
28
33
  export declare function claudeProjectName(cwd: string): string;
29
34
  /** Records appended after `offset` (whole lines only). */
30
35
  export declare function recordsAfter(file: string, offset: number): Rec[];
36
+ /** A record's working directory: Claude's `cwd` on every record, Codex's on session_meta and turn_context. */
37
+ export declare function recordCwd(r: Rec): string;
38
+ /** The working directories records name, the latest used last. */
39
+ export declare function recordCwds(records: Rec[]): string[];
40
+ /**
41
+ * Where a session works: the working directory most of its latest records name (the newest wins a tie), so a session
42
+ * started in a scratch folder that went on in a repository belongs to the repository, and a brief `cd` elsewhere at the
43
+ * end does not move it. `recent` is the transcript's tail; `fallback` when it names none.
44
+ */
45
+ export declare function workingDirectory(recent: Rec[], fallback: string): string;
31
46
  /** Whether the transcript's last turn is still running, from its records (see the module comment). */
32
47
  export declare function claudeTurnFromRecords(records: Rec[]): 'working' | 'idle';
33
48
  export declare function codexTurnFromRecords(records: Rec[]): 'working' | 'idle';
34
- /** A project's sessions on this computer, newest first: those started in the folder or below it. */
49
+ /**
50
+ * Whether a session worked in `folder` (or below it): any working directory its records name (0.20.2+; before, only
51
+ * the first). A session started in a scratch folder that went on in a repository is listed under the repository too.
52
+ */
53
+ export declare function workedIn(s: Pick<LocalSession, 'cwd' | 'cwds'>, folder: string): boolean;
54
+ /**
55
+ * A project's sessions on this computer, newest first: those that worked in the folder or below it. Claude keeps a
56
+ * transcript under the folder it started in, so one that started elsewhere (a scratch folder) and went on here is found
57
+ * by its records: the newest transcripts of other folders whose last 64 KiB name this folder are read.
58
+ */
35
59
  export declare function listLocalSessions(folder: string, env: Env, options?: {
36
60
  harness?: SessionHarness;
37
61
  limit?: number;
@@ -93,6 +93,37 @@ const oneLine = (text, max = 80) => {
93
93
  return line.length > max ? `${line.slice(0, max - 1).trimEnd()}…` : line;
94
94
  };
95
95
  // ---------------------------------------------------------------------------------------------------------------
96
+ // Working directories
97
+ /** A record's working directory: Claude's `cwd` on every record, Codex's on session_meta and turn_context. */
98
+ export function recordCwd(r) {
99
+ if (typeof r.cwd === 'string')
100
+ return r.cwd;
101
+ return r.type === 'session_meta' || r.type === 'turn_context' ? str(obj(r.payload).cwd) : '';
102
+ }
103
+ /** The working directories records name, the latest used last. */
104
+ export function recordCwds(records) {
105
+ const last = new Map();
106
+ records.forEach((r, i) => { const cwd = recordCwd(r); if (cwd)
107
+ last.set(cwd, i); });
108
+ return [...last.entries()].sort((a, b) => a[1] - b[1]).map(([cwd]) => cwd);
109
+ }
110
+ /** How many of the latest records with a working directory decide where a session works. */
111
+ const LATEST_CWDS = 100;
112
+ /**
113
+ * Where a session works: the working directory most of its latest records name (the newest wins a tie), so a session
114
+ * started in a scratch folder that went on in a repository belongs to the repository, and a brief `cd` elsewhere at the
115
+ * end does not move it. `recent` is the transcript's tail; `fallback` when it names none.
116
+ */
117
+ export function workingDirectory(recent, fallback) {
118
+ const count = new Map(), last = new Map();
119
+ recent.map(recordCwd).filter(Boolean).slice(-LATEST_CWDS).forEach((cwd, i) => { count.set(cwd, (count.get(cwd) ?? 0) + 1); last.set(cwd, i); });
120
+ let best = '';
121
+ for (const [cwd, n] of count)
122
+ if (!best || n > count.get(best) || (n === count.get(best) && last.get(cwd) > last.get(best)))
123
+ best = cwd;
124
+ return best || fallback;
125
+ }
126
+ // ---------------------------------------------------------------------------------------------------------------
96
127
  // Claude
97
128
  function claudeText(content) {
98
129
  if (typeof content === 'string')
@@ -153,10 +184,12 @@ function claudeSession(file) {
153
184
  return null;
154
185
  const titled = [...last].reverse().find(r => (r.type === 'custom-title' && str(r.customTitle)) || (r.type === 'ai-title' && str(r.aiTitle)) || (r.type === 'summary' && str(r.summary)));
155
186
  const title = titled ? str(titled.customTitle) || str(titled.aiTitle) || str(titled.summary) : claudeText(obj(prompt.message).content);
156
- // Where the session started: its folder (a later `cd` in a tool call moves later records' cwd, not the session).
187
+ // Where it works now (a session started in a scratch folder that went on in a repository is the repository's), and
188
+ // every folder it worked in.
157
189
  const started = [...first, ...last].find(r => str(r.cwd)) ?? prompt;
158
- return { harness: 'claude', session_id: id, file, cwd: str(started.cwd), title: oneLine(title), updated_at: st.mtime.toISOString(), bytes: st.size,
159
- source: str(prompt.entrypoint) || 'cli' };
190
+ const records = st.size <= (256 << 10) ? first : [...first, ...last];
191
+ return { harness: 'claude', session_id: id, file, cwd: workingDirectory(last, str(started.cwd)), cwds: recordCwds(records), title: oneLine(title),
192
+ updated_at: st.mtime.toISOString(), bytes: st.size, source: str(prompt.entrypoint) || 'cli' };
160
193
  }
161
194
  function claudeProcesses(env) {
162
195
  const dir = join(claudeConfigDir(env), 'sessions');
@@ -209,22 +242,33 @@ export function codexTurnFromRecords(records) {
209
242
  }
210
243
  return 'idle';
211
244
  }
212
- function codexSession(file, metaOnly) {
245
+ /**
246
+ * A Codex rollout. `belongs` (a listing's folder test) is asked of its working directories: the head's first, and only
247
+ * when none matches, the tail's too (a thread that moved on to another folder in a later turn).
248
+ */
249
+ function codexSession(file, belongs) {
213
250
  const match = ROLLOUT.exec(basename(file));
214
251
  if (!match)
215
252
  return null;
216
253
  const st = statSync(file);
217
254
  const first = head(file);
218
255
  const meta = first.find(r => r.type === 'session_meta');
219
- const cwd = str(obj(meta?.payload).cwd);
220
- if (!meta || !cwd || (metaOnly && !metaOnly(cwd)))
256
+ const started = str(obj(meta?.payload).cwd);
257
+ if (!meta || !started)
258
+ return null;
259
+ if (belongs && !recordCwds(first).some(belongs) && (st.size <= (256 << 10) || !tailNames(file, st.size, belongs)))
221
260
  return null;
222
261
  const last = tail(file, st.size);
223
262
  const prompt = [...first, ...last].map(codexPrompt).find(Boolean);
224
263
  if (!prompt)
225
264
  return null;
226
- return { harness: 'codex', session_id: match[1], file, cwd, title: oneLine(prompt), updated_at: st.mtime.toISOString(), bytes: st.size,
227
- source: str(obj(meta.payload).originator) || 'codex' };
265
+ const records = st.size <= (256 << 10) ? first : [...first, ...last];
266
+ return { harness: 'codex', session_id: match[1], file, cwd: workingDirectory(last, started), cwds: recordCwds(records), title: oneLine(prompt),
267
+ updated_at: st.mtime.toISOString(), bytes: st.size, source: str(obj(meta.payload).originator) || 'codex' };
268
+ }
269
+ /** Whether the transcript's last 64 KiB name a working directory `belongs` takes, in record metadata only. */
270
+ function tailNames(file, size, belongs) {
271
+ return recordCwds(tail(file, size, 64 << 10)).some(belongs);
228
272
  }
229
273
  /** Every rollout file, newest first (one stat each; heads are read only for the ones a caller looks at). */
230
274
  function codexRollouts(env) {
@@ -273,14 +317,29 @@ const real = (path) => { try {
273
317
  catch {
274
318
  return path;
275
319
  } };
276
- /** A project's sessions on this computer, newest first: those started in the folder or below it. */
320
+ /**
321
+ * Whether a session worked in `folder` (or below it): any working directory its records name (0.20.2+; before, only
322
+ * the first). A session started in a scratch folder that went on in a repository is listed under the repository too.
323
+ */
324
+ export function workedIn(s, folder) {
325
+ const inside = within(real(folder));
326
+ return [s.cwd, ...s.cwds].some(cwd => inside(real(cwd)));
327
+ }
328
+ /** Claude transcripts stored under another folder's name that a listing looks at (the newest), each by its tail first. */
329
+ const FOREIGN_CLAUDE_LIMIT = 400;
330
+ /**
331
+ * A project's sessions on this computer, newest first: those that worked in the folder or below it. Claude keeps a
332
+ * transcript under the folder it started in, so one that started elsewhere (a scratch folder) and went on here is found
333
+ * by its records: the newest transcripts of other folders whose last 64 KiB name this folder are read.
334
+ */
277
335
  export function listLocalSessions(folder, env, options = {}) {
278
336
  const root = real(folder), limit = options.limit ?? 10, inside = within(root), out = [];
337
+ const belongs = (cwd) => inside(real(cwd));
279
338
  if (options.harness !== 'codex') {
280
339
  const projects = join(claudeConfigDir(env), 'projects'), exact = claudeProjectName(root);
281
340
  let dirs = [];
282
341
  try {
283
- dirs = readdirSync(projects).filter(d => d === exact || d.startsWith(`${exact}-`));
342
+ dirs = readdirSync(projects);
284
343
  }
285
344
  catch { /* no Claude sessions */ }
286
345
  const files = [];
@@ -292,22 +351,43 @@ export function listLocalSessions(folder, env, options = {}) {
292
351
  catch {
293
352
  continue;
294
353
  }
354
+ const own = dir === exact || dir.startsWith(`${exact}-`);
295
355
  for (const name of names)
296
356
  if (name.endsWith('.jsonl')) {
297
357
  const file = join(projects, dir, name);
298
358
  try {
299
- files.push({ file, mtime: statSync(file).mtimeMs });
359
+ files.push({ file, mtime: statSync(file).mtimeMs, own });
300
360
  }
301
361
  catch { /* removed */ }
302
362
  }
303
363
  }
304
- let found = 0;
305
- for (const { file } of files.sort((a, b) => b.mtime - a.mtime)) {
364
+ let found = 0, foreign = 0;
365
+ for (const { file, own } of files.sort((a, b) => b.mtime - a.mtime)) {
306
366
  if (found >= limit)
307
367
  break;
308
- const s = claudeSession(file);
368
+ if (!own) {
369
+ if (foreign >= FOREIGN_CLAUDE_LIMIT)
370
+ continue;
371
+ foreign++;
372
+ let size;
373
+ try {
374
+ size = statSync(file).size;
375
+ }
376
+ catch {
377
+ continue;
378
+ }
379
+ if (!tailNames(file, size, belongs))
380
+ continue;
381
+ }
382
+ let s;
383
+ try {
384
+ s = claudeSession(file);
385
+ }
386
+ catch {
387
+ continue;
388
+ }
309
389
  // A folder named like another's prefix ("app" and "app-web") is told apart by the recorded cwd.
310
- if (s && inside(real(s.cwd))) {
390
+ if (s && workedIn(s, root)) {
311
391
  out.push(s);
312
392
  found++;
313
393
  }
@@ -318,7 +398,7 @@ export function listLocalSessions(folder, env, options = {}) {
318
398
  for (const { file } of codexRollouts(env)) {
319
399
  if (found >= limit)
320
400
  break;
321
- const s = codexSession(file, cwd => inside(real(cwd)));
401
+ const s = codexSession(file, belongs);
322
402
  if (s) {
323
403
  out.push(s);
324
404
  found++;
@@ -397,25 +477,34 @@ export function rewriteSessionPaths(bytes, from, to) {
397
477
  return bytes;
398
478
  const needles = [from, JSON.stringify(from).slice(1, -1)];
399
479
  const move = (path) => (path === from || path.startsWith(`${from}/`) ? to + path.slice(from.length) : path);
400
- const rewrite = (value) => {
401
- if (Array.isArray(value))
402
- return value.map(rewrite);
403
- if (!value || typeof value !== 'object')
404
- return value;
480
+ const rewriteFields = (value) => {
405
481
  return Object.fromEntries(Object.entries(value).map(([key, leaf]) => {
406
482
  if (typeof leaf === 'string' && ['cwd', 'workdir', 'working_directory'].includes(key))
407
483
  return [key, move(leaf)];
408
484
  if (Array.isArray(leaf) && ['workspace_roots', 'runtime_workspace_roots', 'writable_roots'].includes(key))
409
- return [key, leaf.map(v => (typeof v === 'string' ? move(v) : rewrite(v)))];
410
- return [key, rewrite(leaf)];
485
+ return [key, leaf.map(v => (typeof v === 'string' ? move(v) : v))];
486
+ return [key, leaf];
411
487
  }));
412
488
  };
489
+ const rewrite = (r) => {
490
+ const moved = rewriteFields(r);
491
+ if (r.type === 'session_meta' || r.type === 'turn_context') {
492
+ const payload = rewriteFields(obj(r.payload));
493
+ for (const key of ['sandbox_policy', 'permission_profile']) {
494
+ if (payload[key] && typeof payload[key] === 'object' && !Array.isArray(payload[key]))
495
+ payload[key] = rewriteFields(obj(payload[key]));
496
+ }
497
+ moved.payload = payload;
498
+ }
499
+ return moved;
500
+ };
413
501
  const lines = bytes.toString('utf8').split('\n');
414
502
  return Buffer.from(lines.map(line => {
415
503
  if (!line || !needles.some(n => line.includes(n)))
416
504
  return line;
417
505
  try {
418
- return JSON.stringify(rewrite(JSON.parse(line)));
506
+ const parsed = JSON.parse(line);
507
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? JSON.stringify(rewrite(obj(parsed))) : line;
419
508
  }
420
509
  catch {
421
510
  return line;