lark-remote 0.1.2 → 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 (44) hide show
  1. package/README.en.md +2 -5
  2. package/README.md +1 -4
  3. package/dist/bridge/index.js +9 -1
  4. package/dist/card/card-budget.js +1 -2
  5. package/dist/card/run-renderer.js +1 -0
  6. package/dist/card/run-state.d.ts +4 -0
  7. package/dist/config/codex-config.d.ts +3 -5
  8. package/dist/config/codex-config.js +7 -7
  9. package/dist/config/dir.d.ts +1 -0
  10. package/dist/config/dir.js +4 -0
  11. package/dist/config/index.d.ts +0 -10
  12. package/dist/config/index.js +0 -11
  13. package/dist/connector/index.js +28 -0
  14. package/dist/index.js +71 -20
  15. package/dist/router/config/codex.js +8 -12
  16. package/dist/router/index.d.ts +14 -1
  17. package/dist/router/index.js +276 -36
  18. package/dist/router/utils.d.ts +3 -0
  19. package/dist/router/utils.js +15 -3
  20. package/dist/runner/claude/runner.d.ts +0 -1
  21. package/dist/runner/claude/runner.js +1 -1
  22. package/dist/runner/codex/runner.d.ts +0 -2
  23. package/dist/runner/codex/runner.js +1 -1
  24. package/dist/runner/common/spawning-runner.d.ts +0 -1
  25. package/dist/runner/common/spawning-runner.js +2 -4
  26. package/dist/runner/kimi/runner.d.ts +0 -1
  27. package/dist/runner/kimi/runner.js +1 -1
  28. package/dist/runner/opencode/runner.d.ts +0 -2
  29. package/dist/runner/opencode/runner.js +1 -1
  30. package/dist/runner/pi/runner.d.ts +0 -1
  31. package/dist/runner/pi/runner.js +1 -1
  32. package/dist/runner/probe.d.ts +45 -0
  33. package/dist/runner/probe.js +87 -0
  34. package/dist/runner/types.d.ts +3 -0
  35. package/dist/session/claude/sessions.d.ts +1 -1
  36. package/dist/session/claude/sessions.js +77 -31
  37. package/dist/session/codex/rollout-reader.js +9 -0
  38. package/dist/session/kimi/sessions.d.ts +3 -1
  39. package/dist/session/kimi/sessions.js +9 -3
  40. package/dist/session/opencode/sessions.d.ts +0 -2
  41. package/dist/session/opencode/sessions.js +1 -1
  42. package/dist/startup-contact.d.ts +4 -2
  43. package/dist/startup-contact.js +7 -4
  44. package/package.json +39 -4
@@ -12,7 +12,6 @@ export declare class ClaudeRunner extends SpawningRunner implements IAgentRunner
12
12
  private defaultEffort;
13
13
  private defaultSettings?;
14
14
  constructor(opts: {
15
- binary?: string;
16
15
  model?: string;
17
16
  effort?: string;
18
17
  settings?: string;
@@ -19,7 +19,6 @@ export class ClaudeRunner extends SpawningRunner {
19
19
  defaultSettings;
20
20
  constructor(opts) {
21
21
  super({
22
- binary: opts.binary ?? 'claude',
23
22
  pidDir: opts.pidDir,
24
23
  workspace: opts.workspace,
25
24
  stopGraceMs: opts.stopGraceMs,
@@ -27,6 +26,7 @@ export class ClaudeRunner extends SpawningRunner {
27
26
  pidFilePrefix: 'claude',
28
27
  logTag: 'claude-runner',
29
28
  });
29
+ this.binary = 'claude';
30
30
  this.defaultModel = opts.model ?? 'claude-opus-4-8';
31
31
  this.defaultEffort = opts.effort ?? 'medium';
32
32
  this.defaultSettings = opts.settings;
@@ -26,8 +26,6 @@ import type { AgentKind, AgentSessionReader, AgentEvent, AgentRunner, AgentStatu
26
26
  import type { SpawnOptions } from '../types.js';
27
27
  import { SpawningRunner, type RunnerTranslator } from '../common/spawning-runner.js';
28
28
  interface CodexExecRunnerOptions {
29
- /** Path to codex binary. Default: 'codex' (in PATH). */
30
- binary?: string;
31
29
  /** Model override. Omitted → codex reads from its config.toml. */
32
30
  model?: string;
33
31
  /** Model provider override. Omitted → codex reads from its config.toml. */
@@ -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 数(节省的费用)。 */
@@ -20,7 +20,7 @@ export declare function readCwdFromJsonl(filePath: string): string | undefined;
20
20
  *
21
21
  * We locate the directory via `projectDirForCwd` (lossy but OK for locating
22
22
  * a known cwd) and then **verify** each jsonl's cwd field matches. This
23
- * 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
24
24
  * directory name, but only the matching cwd field is kept.
25
25
  */
26
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
  */
@@ -83,7 +83,7 @@ function defaultProjectsDir() {
83
83
  * sorted, first match wins; the cwd guard in `readSessionContent` still
84
84
  * validates the file afterwards.
85
85
  */
86
- function findSessionFileInProjects(sessionId, projectsDir) {
86
+ function findSessionFileInProjects(sessionId, projectsDir, cwd) {
87
87
  let entries;
88
88
  try {
89
89
  entries = fs.readdirSync(projectsDir);
@@ -92,12 +92,27 @@ function findSessionFileInProjects(sessionId, projectsDir) {
92
92
  return undefined;
93
93
  }
94
94
  const name = `${sessionId}.jsonl`;
95
+ const candidates = [];
95
96
  for (const entry of entries.sort()) {
96
97
  const candidate = path.join(projectsDir, entry, name);
97
- if (fs.existsSync(candidate))
98
- 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
+ }
99
114
  }
100
- return undefined;
115
+ return candidates[0];
101
116
  }
102
117
  /**
103
118
  * P1-19: TTL cache for full project listings (mirror codex getSessionIndex's
@@ -125,7 +140,11 @@ function summarizeSession(filePath) {
125
140
  const line = findJsonlLine(filePath, (l) => {
126
141
  try {
127
142
  const obj = JSON.parse(l);
128
- 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;
129
148
  }
130
149
  catch {
131
150
  return false;
@@ -148,6 +167,19 @@ const CLAUDE_MAPPING = {
148
167
  toolInputField: 'input',
149
168
  toolErrorField: 'is_error',
150
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
+ }
151
183
  /**
152
184
  * Extract first text from a user message.
153
185
  * Used by readFirstUserMessage.
@@ -172,7 +204,7 @@ function extractText(content) {
172
204
  *
173
205
  * We locate the directory via `projectDirForCwd` (lossy but OK for locating
174
206
  * a known cwd) and then **verify** each jsonl's cwd field matches. This
175
- * 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
176
208
  * directory name, but only the matching cwd field is kept.
177
209
  */
178
210
  export function listClaudeSessions(cwd, opts = {}) {
@@ -236,11 +268,21 @@ function scanDirForSessions(cwd, dir) {
236
268
  return sessions;
237
269
  }
238
270
  /**
239
- * Scan OTHER project subdirectories for jsonl files whose cwd matches the
240
- * requested cwd. Used as a fallback when the primary dir doesn't contain the
241
- * session (EnterWorktree moves the transcript to the new cwd's project dir).
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
+ *
242
284
  * Skips the primary dir (already scanned) and any sessionIds already found
243
- * (avoids duplicates). P1 fix 2026-08-10.
285
+ * (avoids duplicates). S2 fix 2026-08-10.
244
286
  */
245
287
  function scanRelocatedSessions(cwd, primaryDir, projectsDir, seenIds) {
246
288
  let entries;
@@ -281,13 +323,19 @@ function scanRelocatedSessions(cwd, primaryDir, projectsDir, seenIds) {
281
323
  continue;
282
324
  try {
283
325
  const st = fs.statSync(full);
284
- if (!jsonlContainsCwd(full, cwd))
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)
285
331
  continue;
286
332
  sessions.push({
287
333
  sessionId,
288
334
  summary: truncate(summarizeSession(full), 60, { normalizeWhitespace: true }),
289
335
  mtime: st.mtimeMs,
290
336
  });
337
+ // Prevent duplicates if the same sessionId exists in multiple dirs
338
+ seenIds.add(sessionId);
291
339
  }
292
340
  catch (err) {
293
341
  getLogger().warn(`[session] skip ${f}: ${err.message}`);
@@ -297,11 +345,14 @@ function scanRelocatedSessions(cwd, primaryDir, projectsDir, seenIds) {
297
345
  return sessions;
298
346
  }
299
347
  /** Uncached scan of a claude project dir (the expensive part being cached).
300
- * P1 fix: cwd guard now uses `jsonlContainsCwd` (any cwd field matches)
301
- * instead of `readCwdFromJsonl` (first cwd only). Relocated sessions
302
- * (EnterWorktree) contain multiple cwd values; the first one is the
303
- * pre-relocate path, which would be rejected by the old first-cwd-only
304
- * check. See regression 2026-08-10 list vs read inconsistency.
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.
305
356
  */
306
357
  function scanClaudeSessions(cwd, dir, projectsDir) {
307
358
  const sessions = [];
@@ -309,17 +360,12 @@ function scanClaudeSessions(cwd, dir, projectsDir) {
309
360
  const primarySessions = scanDirForSessions(cwd, dir);
310
361
  sessions.push(...primarySessions);
311
362
  // Phase 2: cross-dir fallback for relocated sessions whose transcript
312
- // was moved away from the primary dir. Only scan OTHER project subdirs
313
- // when the primary scan found nothing (the common case: primary dir has
314
- // all sessions, no need to scan elsewhere). When primary has results,
315
- // relocated sessions in other dirs that match this cwd are already
316
- // handled by jsonlContainsCwd on files in the primary dir (the file was
317
- // moved TO the primary dir's encoded path). Skip to avoid O(all projects)
318
- // overhead on every cached-miss list call.
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.
319
367
  const effectiveProjectsDir = projectsDir ?? defaultProjectsDir();
320
- if (primarySessions.length === 0 &&
321
- effectiveProjectsDir &&
322
- dir.startsWith(effectiveProjectsDir)) {
368
+ if (effectiveProjectsDir && dir.startsWith(effectiveProjectsDir)) {
323
369
  const seenIds = new Set(primarySessions.map((s) => s.sessionId));
324
370
  const crossDirSessions = scanRelocatedSessions(cwd, dir, effectiveProjectsDir, seenIds);
325
371
  sessions.push(...crossDirSessions);
@@ -385,7 +431,7 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
385
431
  let filePath = path.join(dir, `${sessionId}.jsonl`);
386
432
  if (!fs.existsSync(filePath)) {
387
433
  // EnterWorktree relocate: transcript moved to the new cwd's project dir.
388
- const relocated = findSessionFileInProjects(sessionId, projectsDir);
434
+ const relocated = findSessionFileInProjects(sessionId, projectsDir, cwd);
389
435
  if (!relocated)
390
436
  return false;
391
437
  filePath = relocated;
@@ -485,7 +531,7 @@ function scalarScan(filePath) {
485
531
  content[0]?.type === 'tool_result';
486
532
  if (!isToolResult) {
487
533
  const userContent = extractText(content);
488
- if (userContent) {
534
+ if (userContent && !isTaskNotificationText(userContent)) {
489
535
  lastUserMessage = userContent;
490
536
  }
491
537
  }
@@ -583,7 +629,7 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
583
629
  let filePath = path.join(dir, `${sessionId}.jsonl`);
584
630
  if (!fs.existsSync(filePath)) {
585
631
  // EnterWorktree relocate: transcript moved to the new cwd's project dir.
586
- const relocated = findSessionFileInProjects(sessionId, projectsDir);
632
+ const relocated = findSessionFileInProjects(sessionId, projectsDir, cwd);
587
633
  if (!relocated)
588
634
  return { events: [] };
589
635
  filePath = relocated;
@@ -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,
@@ -256,6 +262,9 @@ export function readCodexSessionContent(sessionId, opts = {}) {
256
262
  // Cwd guard: when a cwd is provided, the session's working directory must
257
263
  // match. Without this, /resume <id> finds sessions by global ID lookup and
258
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.
259
268
  if (opts.cwd && entry.cwd !== opts.cwd) {
260
269
  return { events: [] };
261
270
  }
@@ -24,7 +24,9 @@ export type CwdGuardResult = 'verified' | 'failed' | 'unverifiable';
24
24
  * - failed: session has a workDir but it doesn't match realCwd
25
25
  * - unverifiable: session has no workDir field (cannot verify)
26
26
  *
27
- * Unverifiable is a fail-open state — the caller should allow access and log a warning.
27
+ * Unverifiable sessions are handled by the caller: when no cwd source can
28
+ * verify the session, it is rejected (fail-closed), aligned with claude's
29
+ * cwd guard semantics.
28
30
  */
29
31
  export declare function checkCwdGuard(state: KimiSessionState, realCwd: string): CwdGuardResult;
30
32
  /**
@@ -105,7 +105,9 @@ export function extractWorkDir(state) {
105
105
  * - failed: session has a workDir but it doesn't match realCwd
106
106
  * - unverifiable: session has no workDir field (cannot verify)
107
107
  *
108
- * Unverifiable is a fail-open state — the caller should allow access and log a warning.
108
+ * Unverifiable sessions are handled by the caller: when no cwd source can
109
+ * verify the session, it is rejected (fail-closed), aligned with claude's
110
+ * cwd guard semantics.
109
111
  */
110
112
  export function checkCwdGuard(state, realCwd) {
111
113
  const sessionWorkDir = extractWorkDir(state);
@@ -330,9 +332,13 @@ export class KimiSessionReader {
330
332
  if (indexWorkDir && indexWorkDir !== realCwd) {
331
333
  return { events: [] };
332
334
  }
333
- // When index also has no workDir, fall through (unverifiable from all sources)
335
+ // v2 sessions have empty index workDir (v1-only field) and may lack
336
+ // state.json cwd. When no cwd source can verify the session belongs to
337
+ // the requested workspace, fail-closed to prevent cross-workspace access
338
+ // (aligned with claude's fail-closed cwd guard).
334
339
  if (!indexWorkDir) {
335
- getLogger().warn(`[kimi-session-reader] no cwd source (state.json + index) for session ${sessionId}, skipping cwd guard`);
340
+ getLogger().warn(`[kimi-session-reader] no cwd source (state.json + index) for session ${sessionId}, rejecting (fail-closed)`);
341
+ return { events: [] };
336
342
  }
337
343
  }
338
344
  const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
@@ -15,8 +15,6 @@
15
15
  */
16
16
  import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
17
17
  interface OpencodeSessionReaderOptions {
18
- /** Path to opencode binary. Default: 'opencode' (in PATH). */
19
- binary?: string;
20
18
  /** Cache TTL for session list (ms). Default: 10_000. */
21
19
  cacheTtlMs?: number;
22
20
  /**
@@ -28,7 +28,7 @@ export class OpencodeSessionReader {
28
28
  /** Cache keyed by cwd - each cwd has its own cache entry */
29
29
  listCache = new Map();
30
30
  constructor(opts = {}) {
31
- this.binary = opts.binary ?? 'opencode';
31
+ this.binary = 'opencode';
32
32
  this.cacheTtlMs = opts.cacheTtlMs ?? 10_000;
33
33
  this.captureExportOverride = opts.captureExport;
34
34
  }
@@ -13,6 +13,8 @@ export declare class StartupContactStore {
13
13
  save(contact: StartupContact): void;
14
14
  getContact(): StartupContact | undefined;
15
15
  }
16
- export declare function formatStartupHello(now?: Date, pid?: number): string;
17
- export declare function sendStartupHello(connector: StartupHelloChannel, store: StartupContactStore): Promise<void>;
16
+ export declare function formatStartupHello(now?: Date, pid?: number, dev?: boolean): string;
17
+ export declare function sendStartupHello(connector: StartupHelloChannel, store: StartupContactStore, opts?: {
18
+ dev?: boolean;
19
+ }): Promise<void>;
18
20
  export {};