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.
- package/README.en.md +2 -5
- package/README.md +1 -4
- package/dist/bridge/index.d.ts +1 -3
- package/dist/bridge/index.js +11 -12
- package/dist/card/card-budget.js +1 -2
- package/dist/card/card-shared.d.ts +8 -0
- package/dist/card/card-shared.js +14 -0
- package/dist/card/run-renderer.js +1 -0
- package/dist/card/run-state.d.ts +4 -0
- package/dist/config/codex-config.d.ts +3 -5
- package/dist/config/codex-config.js +7 -7
- package/dist/config/dir.d.ts +1 -0
- package/dist/config/dir.js +4 -0
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -11
- package/dist/connector/index.js +28 -0
- package/dist/index.js +89 -41
- package/dist/router/config/codex.js +8 -12
- package/dist/router/index.d.ts +15 -2
- package/dist/router/index.js +314 -44
- package/dist/router/utils.d.ts +3 -0
- package/dist/router/utils.js +15 -3
- package/dist/runner/claude/runner.d.ts +0 -1
- package/dist/runner/claude/runner.js +1 -1
- package/dist/runner/codex/runner.d.ts +0 -2
- package/dist/runner/codex/runner.js +1 -1
- package/dist/runner/common/spawning-runner.d.ts +0 -1
- package/dist/runner/common/spawning-runner.js +2 -4
- package/dist/runner/kimi/runner.d.ts +0 -1
- package/dist/runner/kimi/runner.js +1 -1
- package/dist/runner/opencode/runner.d.ts +0 -2
- package/dist/runner/opencode/runner.js +1 -1
- package/dist/runner/pi/runner.d.ts +0 -1
- package/dist/runner/pi/runner.js +1 -1
- package/dist/runner/probe.d.ts +45 -0
- package/dist/runner/probe.js +87 -0
- package/dist/runner/types.d.ts +3 -0
- package/dist/session/claude/sessions.d.ts +8 -6
- package/dist/session/claude/sessions.js +178 -26
- package/dist/session/codex/rollout-reader.d.ts +1 -0
- package/dist/session/codex/rollout-reader.js +15 -0
- package/dist/session/codex/sessions.d.ts +1 -1
- package/dist/session/codex/sessions.js +2 -1
- package/dist/session/kimi/sessions.d.ts +3 -1
- package/dist/session/kimi/sessions.js +42 -6
- package/dist/session/opencode/sessions.d.ts +0 -2
- package/dist/session/opencode/sessions.js +1 -1
- package/dist/startup-contact.d.ts +4 -2
- package/dist/startup-contact.js +7 -4
- 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;
|
|
@@ -111,10 +111,8 @@ export class SpawningRunner {
|
|
|
111
111
|
*/
|
|
112
112
|
stoppedByUser = false;
|
|
113
113
|
constructor(opts) {
|
|
114
|
-
//
|
|
115
|
-
|
|
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;
|
package/dist/runner/pi/runner.js
CHANGED
|
@@ -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
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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: `
|
|
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 (`
|
|
16
|
-
* `
|
|
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
|
|
26
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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: `
|
|
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
|
-
/**
|
|
200
|
-
|
|
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
|
-
//
|
|
214
|
-
// directory-name collisions
|
|
215
|
-
//
|
|
216
|
-
|
|
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
|
|
287
|
-
const
|
|
288
|
-
|
|
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,
|
|
16
|
+
readSessionContent(sessionId: string, cwd: string, opts?: {
|
|
17
17
|
maxEvents?: number;
|
|
18
18
|
}): SessionContent;
|
|
19
19
|
isSessionActive(sessionId: string, _cwd: string): boolean;
|