lark-remote 0.1.1 → 0.1.3

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 (50) hide show
  1. package/README.en.md +2 -5
  2. package/README.md +1 -4
  3. package/dist/bridge/index.d.ts +1 -3
  4. package/dist/bridge/index.js +11 -12
  5. package/dist/card/card-budget.js +1 -2
  6. package/dist/card/card-shared.d.ts +8 -0
  7. package/dist/card/card-shared.js +14 -0
  8. package/dist/card/run-renderer.js +1 -0
  9. package/dist/card/run-state.d.ts +4 -0
  10. package/dist/config/codex-config.d.ts +3 -5
  11. package/dist/config/codex-config.js +7 -7
  12. package/dist/config/dir.d.ts +1 -0
  13. package/dist/config/dir.js +4 -0
  14. package/dist/config/index.d.ts +0 -10
  15. package/dist/config/index.js +0 -11
  16. package/dist/connector/index.js +28 -0
  17. package/dist/index.js +89 -41
  18. package/dist/router/config/codex.js +8 -12
  19. package/dist/router/index.d.ts +15 -2
  20. package/dist/router/index.js +314 -44
  21. package/dist/router/utils.d.ts +3 -0
  22. package/dist/router/utils.js +15 -3
  23. package/dist/runner/claude/runner.d.ts +0 -1
  24. package/dist/runner/claude/runner.js +1 -1
  25. package/dist/runner/codex/runner.d.ts +0 -2
  26. package/dist/runner/codex/runner.js +1 -1
  27. package/dist/runner/common/spawning-runner.d.ts +0 -1
  28. package/dist/runner/common/spawning-runner.js +2 -4
  29. package/dist/runner/kimi/runner.d.ts +0 -1
  30. package/dist/runner/kimi/runner.js +1 -1
  31. package/dist/runner/opencode/runner.d.ts +0 -2
  32. package/dist/runner/opencode/runner.js +1 -1
  33. package/dist/runner/pi/runner.d.ts +0 -1
  34. package/dist/runner/pi/runner.js +1 -1
  35. package/dist/runner/probe.d.ts +45 -0
  36. package/dist/runner/probe.js +87 -0
  37. package/dist/runner/types.d.ts +3 -0
  38. package/dist/session/claude/sessions.d.ts +8 -6
  39. package/dist/session/claude/sessions.js +178 -26
  40. package/dist/session/codex/rollout-reader.d.ts +1 -0
  41. package/dist/session/codex/rollout-reader.js +15 -0
  42. package/dist/session/codex/sessions.d.ts +1 -1
  43. package/dist/session/codex/sessions.js +2 -1
  44. package/dist/session/kimi/sessions.d.ts +3 -1
  45. package/dist/session/kimi/sessions.js +42 -6
  46. package/dist/session/opencode/sessions.d.ts +0 -2
  47. package/dist/session/opencode/sessions.js +1 -1
  48. package/dist/startup-contact.d.ts +4 -2
  49. package/dist/startup-contact.js +7 -4
  50. package/package.json +39 -4
@@ -34,7 +34,6 @@ export class CodexExecRunner extends SpawningRunner {
34
34
  reasoningEffort;
35
35
  constructor(opts) {
36
36
  super({
37
- binary: opts.binary ?? 'codex',
38
37
  pidDir: opts.pidDir,
39
38
  workspace: opts.workspace,
40
39
  stopGraceMs: opts.stopGraceMs,
@@ -42,6 +41,7 @@ export class CodexExecRunner extends SpawningRunner {
42
41
  pidFilePrefix: 'codex',
43
42
  logTag: 'codex-exec-runner',
44
43
  });
44
+ this.binary = 'codex';
45
45
  this.defaultModel = opts.model;
46
46
  this.modelProvider = opts.modelProvider;
47
47
  this.reasoningEffort = opts.reasoningEffort;
@@ -109,7 +109,6 @@ export declare abstract class SpawningRunner {
109
109
  */
110
110
  protected stoppedByUser: boolean;
111
111
  constructor(opts: {
112
- binary?: string;
113
112
  pidDir?: string;
114
113
  workspace: string;
115
114
  stopGraceMs?: number;
@@ -111,10 +111,8 @@ export class SpawningRunner {
111
111
  */
112
112
  stoppedByUser = false;
113
113
  constructor(opts) {
114
- // Neutral default — empty string fails loudly at spawn time if a caller
115
- // forgets to configure binary. 'claude' would be wrong (biases the base
116
- // class toward one agent). Subclasses pass their own binary.
117
- this.binary = opts.binary ?? '';
114
+ // Subclasses set this.binary after super()
115
+ this.binary = '';
118
116
  this.stopGraceMs = opts.stopGraceMs ?? DEFAULT_STOP_GRACE_MS;
119
117
  this.logTag = opts.logTag ?? 'spawning-runner';
120
118
  const pidDir = opts.pidDir ?? path.join(os.homedir(), '.lark-remote');
@@ -2,7 +2,6 @@ import type { ChildProcess } from 'node:child_process';
2
2
  import { SpawningRunner, type RunnerTranslator } from '../common/spawning-runner.js';
3
3
  import type { AgentRunner, AgentSessionReader, SpawnOptions, AgentStatusInfo } from '../types.js';
4
4
  interface KimiRunnerConfig {
5
- binary?: string;
6
5
  model?: string;
7
6
  thinkingEffort?: string;
8
7
  stopGraceMs?: number;
@@ -32,7 +32,6 @@ export class KimiRunner extends SpawningRunner {
32
32
  completionTimeoutMs;
33
33
  constructor(opts) {
34
34
  super({
35
- binary: opts.binary ?? 'kimi',
36
35
  pidDir: opts.pidDir,
37
36
  workspace: opts.workspace,
38
37
  stopGraceMs: opts.stopGraceMs,
@@ -40,6 +39,7 @@ export class KimiRunner extends SpawningRunner {
40
39
  pidFilePrefix: 'kimi',
41
40
  logTag: 'kimi-runner',
42
41
  });
42
+ this.binary = 'kimi';
43
43
  this.defaultModel = opts.model ?? 'kimi-code/k3';
44
44
  this.thinkingEffort = opts.thinkingEffort ?? 'max';
45
45
  this.completionTimeoutMs = opts.completionTimeoutMs ?? KIMI_TIMEOUT_MS;
@@ -30,8 +30,6 @@ import type { AgentKind, AgentSessionReader, AgentRunner, AgentStatusInfo } from
30
30
  import type { SpawnOptions } from '../types.js';
31
31
  import { SpawningRunner, type RunnerTranslator } from '../common/spawning-runner.js';
32
32
  interface OpencodeExecRunnerOptions {
33
- /** Path to opencode binary. Default: 'opencode' (in PATH). */
34
- binary?: string;
35
33
  /** Model override in provider/model format (e.g. 'anthropic/claude-sonnet-4-20250514'). Omitted → opencode config default. */
36
34
  model?: string;
37
35
  /** Grace period (ms) for SIGTERM→SIGKILL. Default: 5000. */
@@ -35,7 +35,6 @@ export class OpencodeExecRunner extends SpawningRunner {
35
35
  defaultModel;
36
36
  constructor(opts) {
37
37
  super({
38
- binary: opts.binary ?? 'opencode',
39
38
  pidDir: opts.pidDir,
40
39
  workspace: opts.workspace,
41
40
  stopGraceMs: opts.stopGraceMs,
@@ -43,6 +42,7 @@ export class OpencodeExecRunner extends SpawningRunner {
43
42
  pidFilePrefix: 'opencode',
44
43
  logTag: 'opencode-exec-runner',
45
44
  });
45
+ this.binary = 'opencode';
46
46
  this.defaultModel = opts.model;
47
47
  this.sessionReader = opts.sessionReader;
48
48
  }
@@ -1,7 +1,6 @@
1
1
  import type { AgentRunner, AgentSessionReader, SpawnOptions, AgentStatusInfo } from '../types.js';
2
2
  import { SpawningRunner, type RunnerTranslator } from '../common/spawning-runner.js';
3
3
  interface PiRunnerConfig {
4
- binary?: string;
5
4
  provider?: string;
6
5
  model?: string;
7
6
  thinking?: string;
@@ -18,7 +18,6 @@ export class PiRunner extends SpawningRunner {
18
18
  tools;
19
19
  constructor(opts) {
20
20
  super({
21
- binary: opts.binary ?? 'pi',
22
21
  pidDir: opts.pidDir,
23
22
  workspace: opts.workspace,
24
23
  stopGraceMs: opts.stopGraceMs,
@@ -26,6 +25,7 @@ export class PiRunner extends SpawningRunner {
26
25
  pidFilePrefix: 'pi',
27
26
  logTag: 'pi-runner',
28
27
  });
28
+ this.binary = 'pi';
29
29
  this.provider = opts.provider ?? 'Volcano';
30
30
  this.defaultModel = opts.model ?? 'glm-5.2';
31
31
  this.thinking = opts.thinking ?? 'medium';
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Agent availability probe — detect whether each CLI binary is installed
3
+ * by checking whether it resolves on PATH via `which`.
4
+ *
5
+ * Why `which` over `<binary> --help`:
6
+ * - `--help` starts a full Node.js process per agent (2–8 s on
7
+ * resource-constrained devices like Raspberry Pi). Under startup load
8
+ * (auto-resume spawns, CPU contention) probes time out and installed
9
+ * agents are falsely reported as unavailable.
10
+ * - `which` is a lightweight PATH lookup (<10 ms), immune to CPU contention.
11
+ * - "Installed but broken" cases surface at run time via SpawningRunner's
12
+ * ENOENT/error handling (src/runner/common/spawning-runner.ts), which
13
+ * shows a friendly error card instead of a startup-time mislabel.
14
+ *
15
+ * Why `which` over `command -v`:
16
+ * - `which` is a standalone executable (e.g. /usr/bin/which on macOS and
17
+ * Debian-based Linux), so no `shell: true` and no Node DEP0190 warning.
18
+ * - Caveat: `which` is not POSIX-guaranteed. macOS, Raspberry Pi OS and
19
+ * other Debian-based distributions ship it by default; if Windows or
20
+ * minimal Alpine containers become supported, prefer a pure-Node
21
+ * PATH + X_OK lookup instead.
22
+ */
23
+ import type { AgentKind } from './types.js';
24
+ /**
25
+ * Probe a single agent, using cache when fresh.
26
+ * Updates cache on completion.
27
+ */
28
+ export declare function probeAgentAvailability(kind: AgentKind): Promise<boolean>;
29
+ /**
30
+ * Probe all agents concurrently.
31
+ *
32
+ * Concurrent (not sequential) because `which` is a lightweight PATH lookup
33
+ * (<10 ms) with no CPU contention risk — unlike the previous `--help`
34
+ * probes, which needed to run one-at-a-time to avoid starving each other
35
+ * on resource-constrained devices.
36
+ */
37
+ export declare function probeAllAgents(): Promise<Map<AgentKind, boolean>>;
38
+ /**
39
+ * Synchronous cache read — returns cached availability if fresh,
40
+ * or `undefined` if no fresh cache entry exists.
41
+ * Used by synchronous code paths (e.g. buildConfigCard) that cannot await.
42
+ */
43
+ export declare function getCachedAvailability(kind: AgentKind): boolean | undefined;
44
+ /** Clear all cache entries. Exported for test use only (cache auto-expires via TTL). */
45
+ export declare function _clearCacheForTest(): void;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Agent availability probe — detect whether each CLI binary is installed
3
+ * by checking whether it resolves on PATH via `which`.
4
+ *
5
+ * Why `which` over `<binary> --help`:
6
+ * - `--help` starts a full Node.js process per agent (2–8 s on
7
+ * resource-constrained devices like Raspberry Pi). Under startup load
8
+ * (auto-resume spawns, CPU contention) probes time out and installed
9
+ * agents are falsely reported as unavailable.
10
+ * - `which` is a lightweight PATH lookup (<10 ms), immune to CPU contention.
11
+ * - "Installed but broken" cases surface at run time via SpawningRunner's
12
+ * ENOENT/error handling (src/runner/common/spawning-runner.ts), which
13
+ * shows a friendly error card instead of a startup-time mislabel.
14
+ *
15
+ * Why `which` over `command -v`:
16
+ * - `which` is a standalone executable (e.g. /usr/bin/which on macOS and
17
+ * Debian-based Linux), so no `shell: true` and no Node DEP0190 warning.
18
+ * - Caveat: `which` is not POSIX-guaranteed. macOS, Raspberry Pi OS and
19
+ * other Debian-based distributions ship it by default; if Windows or
20
+ * minimal Alpine containers become supported, prefer a pure-Node
21
+ * PATH + X_OK lookup instead.
22
+ */
23
+ import { spawn } from 'child_process';
24
+ /** Map from AgentKind to its CLI binary name. */
25
+ const BINARY_MAP = {
26
+ claude: 'claude',
27
+ codex: 'codex',
28
+ opencode: 'opencode',
29
+ pi: 'pi',
30
+ kimi: 'kimi',
31
+ };
32
+ const CACHE_TTL_MS = 5 * 60_000;
33
+ /** Module-level cache, survives across calls within the process. */
34
+ const cache = new Map();
35
+ /**
36
+ * Probe a single agent's availability by spawning `which <binary>`.
37
+ * Exit code 0 = binary on PATH; error / non-zero = unavailable.
38
+ */
39
+ async function probeOne(kind) {
40
+ const binary = BINARY_MAP[kind];
41
+ return new Promise((resolve) => {
42
+ const proc = spawn('which', [binary], { stdio: 'ignore' });
43
+ proc.on('error', () => resolve(false));
44
+ proc.on('exit', (code) => resolve(code === 0));
45
+ });
46
+ }
47
+ /**
48
+ * Probe a single agent, using cache when fresh.
49
+ * Updates cache on completion.
50
+ */
51
+ export async function probeAgentAvailability(kind) {
52
+ const cached = cache.get(kind);
53
+ if (cached && Date.now() - cached.ts < CACHE_TTL_MS)
54
+ return cached.ok;
55
+ const ok = await probeOne(kind);
56
+ cache.set(kind, { ts: Date.now(), ok });
57
+ return ok;
58
+ }
59
+ /**
60
+ * Probe all agents concurrently.
61
+ *
62
+ * Concurrent (not sequential) because `which` is a lightweight PATH lookup
63
+ * (<10 ms) with no CPU contention risk — unlike the previous `--help`
64
+ * probes, which needed to run one-at-a-time to avoid starving each other
65
+ * on resource-constrained devices.
66
+ */
67
+ export async function probeAllAgents() {
68
+ const kinds = Object.keys(BINARY_MAP);
69
+ const entries = await Promise.all(kinds.map(async (k) => [k, await probeAgentAvailability(k)]));
70
+ return new Map(entries);
71
+ }
72
+ /**
73
+ * Synchronous cache read — returns cached availability if fresh,
74
+ * or `undefined` if no fresh cache entry exists.
75
+ * Used by synchronous code paths (e.g. buildConfigCard) that cannot await.
76
+ */
77
+ export function getCachedAvailability(kind) {
78
+ const cached = cache.get(kind);
79
+ if (!cached || Date.now() - cached.ts >= CACHE_TTL_MS)
80
+ return undefined;
81
+ return cached.ok;
82
+ }
83
+ /** Clear all cache entries. Exported for test use only (cache auto-expires via TTL). */
84
+ export function _clearCacheForTest() {
85
+ cache.clear();
86
+ }
87
+ //# sourceMappingURL=probe.js.map
@@ -153,6 +153,9 @@ export interface AgentSessionUsage {
153
153
  inputTokens: number;
154
154
  outputTokens: number;
155
155
  contextLength: number;
156
+ /** Agent 上报的当前模型 context window 上限(codex token_count.info.model_context_window)。
157
+ * 仅 codex 提供;缺省时卡片只显示绝对量、不显示百分比。 */
158
+ contextLimit?: number;
156
159
  /** Claude auto-compact 次数(数 compact_boundary 事件);codex/opencode 无此概念,留 undefined。 */
157
160
  compactCount?: number;
158
161
  /** 从缓存读取的 token 数(节省的费用)。 */
@@ -1,13 +1,15 @@
1
1
  import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../runner/index.js';
2
2
  /**
3
- * Read cwd from a Claude session JSONL by scanning events for the first
4
- * `cwd` field. Claude CLI writes the absolute (symlink-resolved) cwd on
3
+ * Read the FIRST cwd from a Claude session JSONL. Used for display and
4
+ * diagnostics. Claude CLI writes the absolute (symlink-resolved) cwd on
5
5
  * every event that produces one; older versions wrote it once on init.
6
6
  * Returns the first non-empty value, or undefined if absent.
7
7
  *
8
- * This is the SOLE source of truth for cwd. The directory name under
9
- * ~/.claude/projects/ is lossy (encodes `/` AND `_` as `-`), so it cannot
10
- * be decoded back to a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
8
+ * For cwd GUARD checks (list/read), use `jsonlContainsCwd` which accepts
9
+ * any matching cwd field (required for relocated sessions whose first cwd
10
+ * is the pre-relocate path). The directory name under ~/.claude/projects/
11
+ * is lossy (encodes `/` AND `_` as `-`), so it cannot be decoded back to
12
+ * a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
11
13
  */
12
14
  export declare function readCwdFromJsonl(filePath: string): string | undefined;
13
15
  /**
@@ -18,7 +20,7 @@ export declare function readCwdFromJsonl(filePath: string): string | undefined;
18
20
  *
19
21
  * We locate the directory via `projectDirForCwd` (lossy but OK for locating
20
22
  * a known cwd) and then **verify** each jsonl's cwd field matches. This
21
- * prevents collision: `my_disk/foo` and `my-disk/foo` both encode to the same
23
+ * prevents collision: `disk_d/foo` and `disk-d/foo` both encode to the same
22
24
  * directory name, but only the matching cwd field is kept.
23
25
  */
24
26
  export declare function listClaudeSessions(cwd: string, opts?: {
@@ -12,8 +12,8 @@ import { truncate } from '../../card/card-shared.js';
12
12
  * Claude encodes the cwd by replacing `/` with `-` (e.g.
13
13
  * `/Users/x/proj` -> `-Users-x-proj`). Underscores are also replaced with `-`.
14
14
  *
15
- * Note: this encoding is LOSSY (`my_disk/foo` and `my-disk/foo` both encode to
16
- * `my-disk-foo`). It is only safe to use the result to **locate** files; never
15
+ * Note: this encoding is LOSSY (`disk_d/foo` and `disk-d/foo` both encode to
16
+ * `disk-d-foo`). It is only safe to use the result to **locate** files; never
17
17
  * to **decode** a directory name back to a cwd. Use `readCwdFromJsonl` for
18
18
  * cwd (regression 2026-06-21 /resume & /cd 路径错乱).
19
19
  */
@@ -22,14 +22,16 @@ function projectDirForCwd(cwd, projectsDir) {
22
22
  return path.join(projectsDir, encoded);
23
23
  }
24
24
  /**
25
- * Read cwd from a Claude session JSONL by scanning events for the first
26
- * `cwd` field. Claude CLI writes the absolute (symlink-resolved) cwd on
25
+ * Read the FIRST cwd from a Claude session JSONL. Used for display and
26
+ * diagnostics. Claude CLI writes the absolute (symlink-resolved) cwd on
27
27
  * every event that produces one; older versions wrote it once on init.
28
28
  * Returns the first non-empty value, or undefined if absent.
29
29
  *
30
- * This is the SOLE source of truth for cwd. The directory name under
31
- * ~/.claude/projects/ is lossy (encodes `/` AND `_` as `-`), so it cannot
32
- * be decoded back to a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
30
+ * For cwd GUARD checks (list/read), use `jsonlContainsCwd` which accepts
31
+ * any matching cwd field (required for relocated sessions whose first cwd
32
+ * is the pre-relocate path). The directory name under ~/.claude/projects/
33
+ * is lossy (encodes `/` AND `_` as `-`), so it cannot be decoded back to
34
+ * a real path. See regression 2026-06-21 /resume & /cd 路径错乱.
33
35
  */
34
36
  export function readCwdFromJsonl(filePath) {
35
37
  const line = findJsonlLine(filePath, (l) => {
@@ -81,7 +83,7 @@ function defaultProjectsDir() {
81
83
  * sorted, first match wins; the cwd guard in `readSessionContent` still
82
84
  * validates the file afterwards.
83
85
  */
84
- function findSessionFileInProjects(sessionId, projectsDir) {
86
+ function findSessionFileInProjects(sessionId, projectsDir, cwd) {
85
87
  let entries;
86
88
  try {
87
89
  entries = fs.readdirSync(projectsDir);
@@ -90,12 +92,27 @@ function findSessionFileInProjects(sessionId, projectsDir) {
90
92
  return undefined;
91
93
  }
92
94
  const name = `${sessionId}.jsonl`;
95
+ const candidates = [];
93
96
  for (const entry of entries.sort()) {
94
97
  const candidate = path.join(projectsDir, entry, name);
95
- if (fs.existsSync(candidate))
96
- return candidate;
98
+ if (fs.existsSync(candidate)) {
99
+ candidates.push(candidate);
100
+ }
101
+ }
102
+ if (candidates.length === 0)
103
+ return undefined;
104
+ if (candidates.length === 1)
105
+ return candidates[0];
106
+ // Multiple copies: prefer the one whose jsonl contains the requested cwd
107
+ // (handles EnterWorktree relocate where the same sessionId exists in
108
+ // multiple project dirs). Fall back to the first sorted candidate.
109
+ if (cwd) {
110
+ for (const candidate of candidates) {
111
+ if (jsonlContainsCwd(candidate, cwd))
112
+ return candidate;
113
+ }
97
114
  }
98
- return undefined;
115
+ return candidates[0];
99
116
  }
100
117
  /**
101
118
  * P1-19: TTL cache for full project listings (mirror codex getSessionIndex's
@@ -123,7 +140,11 @@ function summarizeSession(filePath) {
123
140
  const line = findJsonlLine(filePath, (l) => {
124
141
  try {
125
142
  const obj = JSON.parse(l);
126
- return obj.type === 'user' && !!obj.message?.content && !!extractText(obj.message.content);
143
+ if (obj.type === 'user' && !!obj.message?.content) {
144
+ const text = extractText(obj.message.content);
145
+ return !!text && !isTaskNotificationText(text);
146
+ }
147
+ return false;
127
148
  }
128
149
  catch {
129
150
  return false;
@@ -146,6 +167,19 @@ const CLAUDE_MAPPING = {
146
167
  toolInputField: 'input',
147
168
  toolErrorField: 'is_error',
148
169
  };
170
+ /**
171
+ * Detect Claude CLI sub-agent completion notification text.
172
+ * Claude CLI injects a `type:"user"` record with string content starting
173
+ * with `<task-notification>` when a sub-agent (Agent tool) finishes.
174
+ * These must be excluded from displayTitle/summary — they are machine
175
+ * injections, not real user input. Primary criterion: extracted text
176
+ * starts with `<task-notification` (works for both string and array
177
+ * content since `extractText` is called first). Secondary (optional):
178
+ * `origin.kind === 'task-notification'` (not all CLI versions write it).
179
+ */
180
+ function isTaskNotificationText(text) {
181
+ return text.trimStart().startsWith('<task-notification');
182
+ }
149
183
  /**
150
184
  * Extract first text from a user message.
151
185
  * Used by readFirstUserMessage.
@@ -170,7 +204,7 @@ function extractText(content) {
170
204
  *
171
205
  * We locate the directory via `projectDirForCwd` (lossy but OK for locating
172
206
  * a known cwd) and then **verify** each jsonl's cwd field matches. This
173
- * prevents collision: `my_disk/foo` and `my-disk/foo` both encode to the same
207
+ * prevents collision: `disk_d/foo` and `disk-d/foo` both encode to the same
174
208
  * directory name, but only the matching cwd field is kept.
175
209
  */
176
210
  export function listClaudeSessions(cwd, opts = {}) {
@@ -185,7 +219,7 @@ export function listClaudeSessions(cwd, opts = {}) {
185
219
  sessions = cached.sessions;
186
220
  }
187
221
  else {
188
- sessions = scanClaudeSessions(cwd, dir);
222
+ sessions = scanClaudeSessions(cwd, dir, opts.projectsDir);
189
223
  claudeListCache.set(cacheKey, { builtAt: Date.now(), sessions });
190
224
  // Bound the cache (map iteration order = insertion order).
191
225
  if (claudeListCache.size > LIST_CACHE_MAX_ENTRIES) {
@@ -196,8 +230,13 @@ export function listClaudeSessions(cwd, opts = {}) {
196
230
  }
197
231
  return limit === undefined ? sessions : sessions.slice(0, limit);
198
232
  }
199
- /** Uncached scan of a claude project dir (the expensive part being cached). */
200
- function scanClaudeSessions(cwd, dir) {
233
+ /**
234
+ * Scan a single project directory for jsonl files whose cwd field matches
235
+ * the requested cwd. Uses `jsonlContainsCwd` (any cwd field matches) rather
236
+ * than `readCwdFromJsonl` (first cwd only) so that relocated sessions whose
237
+ * first cwd is the pre-relocate path are still found. P1 fix 2026-08-10.
238
+ */
239
+ function scanDirForSessions(cwd, dir) {
201
240
  let files;
202
241
  try {
203
242
  files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
@@ -210,11 +249,10 @@ function scanClaudeSessions(cwd, dir) {
210
249
  const full = path.join(dir, f);
211
250
  try {
212
251
  const st = fs.statSync(full);
213
- // Verify the jsonl's cwd field actually matches. Defends against
214
- // directory-name collisions where a different cwd encoded to the same
215
- // directory name.
216
- const fileCwd = readCwdFromJsonl(full);
217
- if (fileCwd !== cwd)
252
+ // P1 fix: use jsonlContainsCwd (any match) instead of readCwdFromJsonl
253
+ // (first only). Defends against directory-name collisions AND includes
254
+ // relocated sessions whose first cwd is the pre-relocate path.
255
+ if (!jsonlContainsCwd(full, cwd))
218
256
  continue;
219
257
  const sessionId = f.slice(0, -'.jsonl'.length);
220
258
  sessions.push({
@@ -227,6 +265,111 @@ function scanClaudeSessions(cwd, dir) {
227
265
  getLogger().warn(`[session] skip ${f}: ${err.message}`);
228
266
  }
229
267
  }
268
+ return sessions;
269
+ }
270
+ /**
271
+ * Scan OTHER project subdirectories for jsonl files whose first cwd matches
272
+ * the requested cwd. Used to find sessions that were MOVED OUT of the primary
273
+ * dir by EnterWorktree (which relocates the transcript to the target worktree's
274
+ * project dir). Such sessions always have firstCwd == queryCwd because
275
+ * EnterWorktree happens after session start.
276
+ *
277
+ * Uses `readCwdFromJsonl` (first cwd only) instead of `jsonlContainsCwd`
278
+ * (full-file scan) because:
279
+ * - Relocated-out sessions always have firstCwd == queryCwd → readCwdFromJsonl
280
+ * is sufficient and ~10× faster (no full-file streaming).
281
+ * - Relocated-IN sessions are found by Phase 1's jsonlContainsCwd in the
282
+ * primary dir — no need for Phase 2 to find them again.
283
+ *
284
+ * Skips the primary dir (already scanned) and any sessionIds already found
285
+ * (avoids duplicates). S2 fix 2026-08-10.
286
+ */
287
+ function scanRelocatedSessions(cwd, primaryDir, projectsDir, seenIds) {
288
+ let entries;
289
+ try {
290
+ entries = fs.readdirSync(projectsDir);
291
+ }
292
+ catch {
293
+ return [];
294
+ }
295
+ const sessions = [];
296
+ for (const entry of entries.sort()) {
297
+ const subdir = path.join(projectsDir, entry);
298
+ // Skip the primary dir (already scanned) and non-directories
299
+ if (subdir === primaryDir)
300
+ continue;
301
+ try {
302
+ if (!fs.statSync(subdir).isDirectory())
303
+ continue;
304
+ }
305
+ catch {
306
+ continue;
307
+ }
308
+ let files;
309
+ try {
310
+ files = fs
311
+ .readdirSync(subdir)
312
+ .filter((f) => f.endsWith('.jsonl'))
313
+ .sort();
314
+ }
315
+ catch {
316
+ continue;
317
+ }
318
+ for (const f of files) {
319
+ const full = path.join(subdir, f);
320
+ const sessionId = f.slice(0, -'.jsonl'.length);
321
+ // Skip sessions already found in the primary dir scan
322
+ if (seenIds.has(sessionId))
323
+ continue;
324
+ try {
325
+ const st = fs.statSync(full);
326
+ // S2: use readCwdFromJsonl (first cwd only) instead of
327
+ // jsonlContainsCwd (full-file scan). Relocated-out sessions
328
+ // always have firstCwd == queryCwd.
329
+ const firstCwd = readCwdFromJsonl(full);
330
+ if (firstCwd === undefined || firstCwd !== cwd)
331
+ continue;
332
+ sessions.push({
333
+ sessionId,
334
+ summary: truncate(summarizeSession(full), 60, { normalizeWhitespace: true }),
335
+ mtime: st.mtimeMs,
336
+ });
337
+ // Prevent duplicates if the same sessionId exists in multiple dirs
338
+ seenIds.add(sessionId);
339
+ }
340
+ catch (err) {
341
+ getLogger().warn(`[session] skip ${f}: ${err.message}`);
342
+ }
343
+ }
344
+ }
345
+ return sessions;
346
+ }
347
+ /** Uncached scan of a claude project dir (the expensive part being cached).
348
+ * Phase 1 scans the primary project dir using jsonlContainsCwd (any cwd
349
+ * match) to find sessions including relocated-in ones.
350
+ * Phase 2 scans all OTHER project dirs using readCwdFromJsonl (first cwd
351
+ * only) to find relocated-out sessions (always firstCwd == queryCwd).
352
+ * S2 fix 2026-08-10: removed the primarySessions.length === 0 guard so
353
+ * relocated-out sessions are visible even when the primary dir has other
354
+ * sessions. readCwdFromJsonl is ~10× cheaper than jsonlContainsCwd for
355
+ * cross-dir scanning.
356
+ */
357
+ function scanClaudeSessions(cwd, dir, projectsDir) {
358
+ const sessions = [];
359
+ // Phase 1: scan the primary project dir (fast path)
360
+ const primarySessions = scanDirForSessions(cwd, dir);
361
+ sessions.push(...primarySessions);
362
+ // Phase 2: cross-dir fallback for relocated sessions whose transcript
363
+ // was moved away from the primary dir by EnterWorktree.
364
+ // Always executed (S2 fix): relocated-out sessions have firstCwd == queryCwd
365
+ // but are invisible to Phase 1 (file moved away). seenIds prevents
366
+ // double-counting sessions already found by Phase 1.
367
+ const effectiveProjectsDir = projectsDir ?? defaultProjectsDir();
368
+ if (effectiveProjectsDir && dir.startsWith(effectiveProjectsDir)) {
369
+ const seenIds = new Set(primarySessions.map((s) => s.sessionId));
370
+ const crossDirSessions = scanRelocatedSessions(cwd, dir, effectiveProjectsDir, seenIds);
371
+ sessions.push(...crossDirSessions);
372
+ }
230
373
  // Same-mtime ties: secondary key keeps the full order deterministic.
231
374
  sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
232
375
  return sessions;
@@ -283,9 +426,18 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
283
426
  * used for session-active detection. Returns false if the file doesn't exist.
284
427
  */
285
428
  export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
286
- const dir = projectDirForCwd(cwd, opts.projectsDir ?? defaultProjectsDir());
287
- const filePath = path.join(dir, `${sessionId}.jsonl`);
288
- if (!fs.existsSync(filePath))
429
+ const projectsDir = opts.projectsDir ?? defaultProjectsDir();
430
+ const dir = projectDirForCwd(cwd, projectsDir);
431
+ let filePath = path.join(dir, `${sessionId}.jsonl`);
432
+ if (!fs.existsSync(filePath)) {
433
+ // EnterWorktree relocate: transcript moved to the new cwd's project dir.
434
+ const relocated = findSessionFileInProjects(sessionId, projectsDir, cwd);
435
+ if (!relocated)
436
+ return false;
437
+ filePath = relocated;
438
+ }
439
+ // Verify the jsonl belongs to the requested cwd (any cwd field may match).
440
+ if (!jsonlContainsCwd(filePath, cwd))
289
441
  return false;
290
442
  try {
291
443
  const st = fs.statSync(filePath);
@@ -379,7 +531,7 @@ function scalarScan(filePath) {
379
531
  content[0]?.type === 'tool_result';
380
532
  if (!isToolResult) {
381
533
  const userContent = extractText(content);
382
- if (userContent) {
534
+ if (userContent && !isTaskNotificationText(userContent)) {
383
535
  lastUserMessage = userContent;
384
536
  }
385
537
  }
@@ -477,7 +629,7 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
477
629
  let filePath = path.join(dir, `${sessionId}.jsonl`);
478
630
  if (!fs.existsSync(filePath)) {
479
631
  // EnterWorktree relocate: transcript moved to the new cwd's project dir.
480
- const relocated = findSessionFileInProjects(sessionId, projectsDir);
632
+ const relocated = findSessionFileInProjects(sessionId, projectsDir, cwd);
481
633
  if (!relocated)
482
634
  return { events: [] };
483
635
  filePath = relocated;
@@ -70,6 +70,7 @@ export declare function listCodexRollouts(opts?: ListCodexRolloutsOptions): {
70
70
  export declare function readCodexSessionContent(sessionId: string, opts?: {
71
71
  codexHome?: string;
72
72
  maxEvents?: number;
73
+ cwd?: string;
73
74
  }): SessionContent;
74
75
  /**
75
76
  * Check if a session is still active (recently updated).
@@ -51,6 +51,9 @@ export function readCodexRollout(filePath) {
51
51
  // pi/opencode /resume "last turn" display semantics). When
52
52
  // last_token_usage is absent we derive it as total - previous_total.
53
53
  let lastRawUsage;
54
+ // Context window limit from the same token_count event as lastRawUsage
55
+ // (codex reports info.model_context_window per turn; absent on old data).
56
+ let lastContextLimit;
54
57
  // Final total_token_usage (cumulative across the whole session) for the
55
58
  // Run card's "累计" display. Codex emits this alongside last_token_usage.
56
59
  let lastTotalUsage;
@@ -95,6 +98,8 @@ export function readCodexRollout(filePath) {
95
98
  }
96
99
  if (raw && (raw.input_tokens || raw.cached_input_tokens || raw.output_tokens)) {
97
100
  lastRawUsage = raw;
101
+ const ctxWindow = info?.model_context_window;
102
+ lastContextLimit = typeof ctxWindow === 'number' ? ctxWindow : undefined;
98
103
  }
99
104
  }
100
105
  }
@@ -150,6 +155,7 @@ export function readCodexRollout(filePath) {
150
155
  inputTokens: r.input_tokens - cached,
151
156
  outputTokens: r.output_tokens,
152
157
  contextLength: r.input_tokens,
158
+ contextLimit: lastContextLimit,
153
159
  cacheReadTokens: cached,
154
160
  cacheCreationTokens: 0,
155
161
  totalTokens: r.total_tokens,
@@ -253,6 +259,15 @@ export function readCodexSessionContent(sessionId, opts = {}) {
253
259
  if (!entry) {
254
260
  return { events: [] };
255
261
  }
262
+ // Cwd guard: when a cwd is provided, the session's working directory must
263
+ // match. Without this, /resume <id> finds sessions by global ID lookup and
264
+ // allows resuming a session from workspace B while the user is in workspace A.
265
+ // Codex has no relocation (no EnterWorktree equivalent), so a simple equality
266
+ // check suffices — unlike claude which needs jsonlContainsCwd to handle
267
+ // relocated sessions with multiple cwd values.
268
+ if (opts.cwd && entry.cwd !== opts.cwd) {
269
+ return { events: [] };
270
+ }
256
271
  const rollout = readCodexRollout(entry.filePath);
257
272
  if (!rollout || rollout.threadId !== sessionId) {
258
273
  return { events: [] };
@@ -13,7 +13,7 @@ export declare class CodexSessionReader implements AgentSessionReader {
13
13
  total: number;
14
14
  };
15
15
  getNewestSession(cwd: string): AgentSession | null;
16
- readSessionContent(sessionId: string, _cwd: string, opts?: {
16
+ readSessionContent(sessionId: string, cwd: string, opts?: {
17
17
  maxEvents?: number;
18
18
  }): SessionContent;
19
19
  isSessionActive(sessionId: string, _cwd: string): boolean;