@cjhyy/code-shell-capability-coding 0.9.39 → 0.9.41

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.
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  /** Availability of one external coding CLI. */
2
3
  export interface CCAvailability {
3
4
  available: boolean;
@@ -12,6 +13,12 @@ export type ProbeRunner = (command: string) => Promise<{
12
13
  ok: boolean;
13
14
  stdout: string;
14
15
  }>;
16
+ /** Bounded `<command> --version` runner. Process injection keeps tests isolated
17
+ * from installed user CLIs; concurrent probes share only the current attempt. */
18
+ export declare function createProbeRunner(options?: {
19
+ timeoutMs?: number;
20
+ spawn?: typeof spawn;
21
+ }): ProbeRunner;
15
22
  /** Probe a CLI's availability. Injectable runner for tests. */
16
23
  export declare function probeCli(command: string, runner?: ProbeRunner): Promise<CCAvailability>;
17
24
  /** Cached probe; pass force=true to re-detect (user installed CLI mid-session). */
@@ -11,17 +11,75 @@ export function pathWithCommonBins(env = process.env) {
11
11
  merged.push(dir);
12
12
  return merged.join(delimiter);
13
13
  }
14
- /** Default runner: `<command> --version` with PATH fix. */
15
- const defaultRunner = (command) => new Promise((resolve, reject) => {
16
- const child = spawn(command, ["--version"], {
17
- env: { ...process.env, PATH: pathWithCommonBins() },
18
- stdio: ["ignore", "pipe", "pipe"],
19
- });
20
- let stdout = "";
21
- child.stdout?.on("data", (c) => (stdout += String(c)));
22
- child.on("error", reject);
23
- child.on("exit", (code) => resolve({ ok: code === 0, stdout }));
24
- });
14
+ const PROBE_TIMEOUT_MS = 3_000;
15
+ const MAX_PROBE_STDOUT_BYTES = 16 * 1024;
16
+ /** Bounded `<command> --version` runner. Process injection keeps tests isolated
17
+ * from installed user CLIs; concurrent probes share only the current attempt. */
18
+ export function createProbeRunner(options = {}) {
19
+ const spawnProcess = options.spawn ?? spawn;
20
+ const inFlight = new Map();
21
+ return (command) => {
22
+ const existing = inFlight.get(command);
23
+ if (existing)
24
+ return existing;
25
+ const pending = new Promise((resolve, reject) => {
26
+ const child = spawnProcess(command, ["--version"], {
27
+ env: { ...process.env, PATH: pathWithCommonBins() },
28
+ stdio: ["ignore", "pipe", "pipe"],
29
+ });
30
+ const chunks = [];
31
+ let stdoutBytes = 0;
32
+ let settled = false;
33
+ const onData = (chunk) => {
34
+ const remaining = MAX_PROBE_STDOUT_BYTES - stdoutBytes;
35
+ if (remaining <= 0)
36
+ return;
37
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
38
+ // Copy the retained prefix so a large chunk cannot stay alive via a view.
39
+ const retained = Buffer.from(bytes.subarray(0, remaining));
40
+ chunks.push(retained);
41
+ stdoutBytes += retained.length;
42
+ };
43
+ const finish = (ok, error, terminate = false) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ clearTimeout(timeout);
48
+ child.stdout?.removeListener("data", onData);
49
+ if (terminate) {
50
+ // A hung version check must not hold the availability request or host
51
+ // open, even if the process never emits exit/close after termination.
52
+ try {
53
+ child.kill("SIGKILL");
54
+ }
55
+ catch {
56
+ // The process may already have exited between the timer and kill.
57
+ }
58
+ child.stdout?.destroy();
59
+ child.stderr?.destroy();
60
+ child.unref();
61
+ }
62
+ if (error)
63
+ reject(error);
64
+ else
65
+ resolve({ ok, stdout: Buffer.concat(chunks).toString("utf8") });
66
+ };
67
+ const timeout = setTimeout(() => finish(false, undefined, true), options.timeoutMs ?? PROBE_TIMEOUT_MS);
68
+ child.stdout?.on("data", onData);
69
+ // Version checks can write diagnostics before returning. Always consume
70
+ // stderr so a full pipe cannot prevent an otherwise healthy CLI exiting.
71
+ child.stderr?.resume();
72
+ // Keep the error listener after timeout to consume late process errors.
73
+ child.on("error", (error) => finish(false, error, true));
74
+ child.on("close", (code) => finish(code === 0));
75
+ }).finally(() => {
76
+ inFlight.delete(command);
77
+ });
78
+ inFlight.set(command, pending);
79
+ return pending;
80
+ };
81
+ }
82
+ const defaultRunner = createProbeRunner();
25
83
  /** Probe a CLI's availability. Injectable runner for tests. */
26
84
  export async function probeCli(command, runner = defaultRunner) {
27
85
  try {
@@ -1,4 +1,15 @@
1
1
  import type { DiscoveredSession, DiscoverOptions, ExternalSessionDiscoveryScope, RecentExternalSession } from "./session-discovery.js";
2
+ export interface CodexSessionList {
3
+ sessions: DiscoveredSession[];
4
+ total: number;
5
+ }
6
+ /** Interactive room lists must not run the synchronous rollout walk on the
7
+ * host event loop. Read identities once for both the list and its total, then
8
+ * read titles only for the selected, deduplicated threads. */
9
+ export declare function listCodexSessionsForCwds(cwds: readonly string[], codexHome?: string, opts?: DiscoverOptions, accept?: (session: {
10
+ sessionId: string;
11
+ cwd: string;
12
+ }) => boolean): Promise<CodexSessionList>;
2
13
  /**
3
14
  * Discover codex CLI sessions for a given `cwd`, mirroring the claude-side
4
15
  * `discoverSessions` but for codex's storage layout, which is fundamentally
@@ -1,8 +1,125 @@
1
1
  import { readdirSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
2
+ import { open, readdir, stat } from "node:fs/promises";
2
3
  import { join } from "node:path";
3
4
  import { homedir } from "node:os";
4
5
  import { externalSessionCwdEnabled, selectRecentStats } from "./session-discovery.js";
5
6
  import { codexUserText } from "./codex-user-text.js";
7
+ /** Interactive room lists must not run the synchronous rollout walk on the
8
+ * host event loop. Read identities once for both the list and its total, then
9
+ * read titles only for the selected, deduplicated threads. */
10
+ export async function listCodexSessionsForCwds(cwds, codexHome = join(homedir(), ".codex"), opts = {}, accept) {
11
+ const cwdSet = new Set(cwds);
12
+ if (cwdSet.size === 0)
13
+ return { sessions: [], total: 0 };
14
+ const files = await listRolloutsAsync(join(codexHome, "sessions"));
15
+ const matches = await mapConcurrent(files, async (file) => {
16
+ try {
17
+ const [info, firstLine] = await Promise.all([stat(file), readFirstLineAsync(file)]);
18
+ const meta = parseSessionMeta(firstLine);
19
+ if (!meta?.id || !meta.cwd || !cwdSet.has(meta.cwd))
20
+ return undefined;
21
+ if (accept && !accept({ sessionId: meta.id, cwd: meta.cwd }))
22
+ return undefined;
23
+ return { file, mtimeMs: info.mtimeMs, sessionId: meta.id, cwd: meta.cwd };
24
+ }
25
+ catch {
26
+ // Rollouts can disappear or still have a partial first line during a scan.
27
+ return undefined;
28
+ }
29
+ });
30
+ const byThread = new Map();
31
+ for (const match of matches) {
32
+ if (!match)
33
+ continue;
34
+ const prior = byThread.get(match.sessionId);
35
+ if (!prior || match.mtimeMs > prior.mtimeMs)
36
+ byThread.set(match.sessionId, match);
37
+ }
38
+ const selected = selectRecentStats([...byThread.values()], opts);
39
+ const sessions = await mapConcurrent(selected, async (session) => {
40
+ let firstMessage = "";
41
+ try {
42
+ firstMessage = firstUserMessageFromPrefix(await readPrefixAsync(session.file, 1 << 20));
43
+ }
44
+ catch {
45
+ // Retain the session identity if its title cannot be read.
46
+ }
47
+ return {
48
+ sessionId: session.sessionId,
49
+ cwd: session.cwd,
50
+ firstMessage,
51
+ lastModified: session.mtimeMs,
52
+ messageCount: 0,
53
+ };
54
+ });
55
+ return { sessions, total: byThread.size };
56
+ }
57
+ /** Dirents avoid statting every directory and every file twice. Do not follow
58
+ * directory symlinks: a cyclic link must not make interactive discovery hang. */
59
+ async function listRolloutsAsync(root) {
60
+ let entries;
61
+ try {
62
+ entries = await readdir(root, { withFileTypes: true });
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ const files = [];
68
+ for (const entry of entries) {
69
+ const file = join(root, entry.name);
70
+ if (entry.isDirectory())
71
+ files.push(...(await listRolloutsAsync(file)));
72
+ else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
73
+ files.push(file);
74
+ }
75
+ }
76
+ return files;
77
+ }
78
+ /** Bound open file handles and prefix buffers even for a large history. */
79
+ async function mapConcurrent(items, map) {
80
+ const results = new Array(items.length);
81
+ let next = 0;
82
+ await Promise.all(Array.from({ length: Math.min(16, items.length) }, async () => {
83
+ while (next < items.length) {
84
+ const index = next++;
85
+ results[index] = await map(items[index]);
86
+ }
87
+ }));
88
+ return results;
89
+ }
90
+ async function readPrefixAsync(file, maxBytes) {
91
+ const handle = await open(file, "r");
92
+ try {
93
+ const buffer = Buffer.alloc(maxBytes);
94
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
95
+ return buffer.toString("utf-8", 0, bytesRead);
96
+ }
97
+ finally {
98
+ await handle.close();
99
+ }
100
+ }
101
+ /** Most metadata fits in one 4 KiB read. Grow only for a longer first line,
102
+ * preserving the existing 64 KiB identity bound without reading transcript
103
+ * prefixes merely to count threads. */
104
+ async function readFirstLineAsync(file) {
105
+ const handle = await open(file, "r");
106
+ try {
107
+ const chunks = [];
108
+ for (let position = 0; position < 1 << 16; position += 4096) {
109
+ const buffer = Buffer.alloc(4096);
110
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
111
+ const chunk = buffer.subarray(0, bytesRead);
112
+ const newline = chunk.indexOf(10);
113
+ chunks.push(newline === -1 ? chunk : chunk.subarray(0, newline));
114
+ if (newline !== -1 || bytesRead < buffer.length)
115
+ break;
116
+ }
117
+ return Buffer.concat(chunks).toString("utf-8");
118
+ }
119
+ finally {
120
+ await handle.close();
121
+ }
122
+ }
6
123
  /**
7
124
  * Discover codex CLI sessions for a given `cwd`, mirroring the claude-side
8
125
  * `discoverSessions` but for codex's storage layout, which is fundamentally
@@ -193,7 +310,10 @@ function readFirstLine(file, maxBytes = 1 << 16) {
193
310
  }
194
311
  /** Parse the first-line `session_meta` event → `{ id, cwd }`. */
195
312
  function readSessionMeta(file) {
196
- const first = readFirstLine(file).trim();
313
+ return parseSessionMeta(readFirstLine(file));
314
+ }
315
+ function parseSessionMeta(line) {
316
+ const first = line.trim();
197
317
  if (!first)
198
318
  return undefined;
199
319
  const d = JSON.parse(first);
@@ -228,6 +348,9 @@ function readFirstUserMessage(file, maxBytes = 1 << 20) {
228
348
  catch {
229
349
  return "";
230
350
  }
351
+ return firstUserMessageFromPrefix(chunk);
352
+ }
353
+ function firstUserMessageFromPrefix(chunk) {
231
354
  for (const line of chunk.split("\n")) {
232
355
  if (!line.trim())
233
356
  continue;
@@ -1,11 +1,15 @@
1
1
  import { type ExternalAgentSessionBinding, type ExternalAgentCli } from "./external-agent-session-store.js";
2
2
  import { type DiscoveredSession, type DiscoverOptions } from "./session-discovery.js";
3
+ import { type CodexSessionList } from "./codex-session-discovery.js";
3
4
  export interface RelatedSessionDiscoveryOptions {
4
5
  claudeHome?: string;
5
6
  codexHome?: string;
6
7
  /** Test seam; production reads DriveAgent's durable binding store. */
7
8
  bindings?: readonly ExternalAgentSessionBinding[];
8
9
  }
10
+ /** Nonblocking Codex room list and exact total from the same identity scan.
11
+ * Only worktree sessions actually delegated by this project are included. */
12
+ export declare function listRelatedCodexSessions(cwd: string, opts?: DiscoverOptions, discovery?: RelatedSessionDiscoveryOptions): Promise<CodexSessionList>;
9
13
  /**
10
14
  * List sessions that belong to the selected cwd plus DriveAgent sessions
11
15
  * launched in worktrees associated with it. The external CLI's own recorded
@@ -2,19 +2,20 @@ import { homedir } from "node:os";
2
2
  import { join, resolve } from "node:path";
3
3
  import { externalAgentSessionStore, } from "./external-agent-session-store.js";
4
4
  import { countSessions, discoverSessions, } from "./session-discovery.js";
5
- import { countCodexSessions, discoverCodexSessionsForCwds } from "./codex-session-discovery.js";
6
- /**
7
- * List sessions that belong to the selected cwd plus DriveAgent sessions
8
- * launched in worktrees associated with it. The external CLI's own recorded
9
- * cwd is retained on every row and becomes the authoritative path when opening
10
- * the session.
11
- */
12
- export function discoverRelatedSessions(cli, cwd, opts = {}, discovery = {}) {
5
+ import { countCodexSessions, discoverCodexSessionsForCwds, listCodexSessionsForCwds, } from "./codex-session-discovery.js";
6
+ /** Nonblocking Codex room list and exact total from the same identity scan.
7
+ * Only worktree sessions actually delegated by this project are included. */
8
+ export async function listRelatedCodexSessions(cwd, opts = {}, discovery = {}) {
13
9
  const root = resolve(cwd);
14
- const bindings = discovery.bindings ?? externalAgentSessionStore.list();
15
- const expectedIdsByCwd = new Map();
16
- expectedIdsByCwd.set(root, new Set());
17
- for (const binding of bindings) {
10
+ const expectedIdsByCwd = relatedSessionScope("codex", root, discovery);
11
+ return listCodexSessionsForCwds([...expectedIdsByCwd.keys()], discovery.codexHome ?? join(homedir(), ".codex"), opts, ({ sessionId, cwd: sessionCwd }) => {
12
+ const normalizedCwd = resolve(sessionCwd);
13
+ return normalizedCwd === root || expectedIdsByCwd.get(normalizedCwd)?.has(sessionId) === true;
14
+ });
15
+ }
16
+ function relatedSessionScope(cli, root, discovery) {
17
+ const expectedIdsByCwd = new Map([[root, new Set()]]);
18
+ for (const binding of discovery.bindings ?? externalAgentSessionStore.list()) {
18
19
  if (binding.cli !== cli || !isRelatedBinding(binding, root))
19
20
  continue;
20
21
  const bindingCwd = resolve(binding.cwd);
@@ -22,6 +23,17 @@ export function discoverRelatedSessions(cli, cwd, opts = {}, discovery = {}) {
22
23
  ids.add(binding.sessionId);
23
24
  expectedIdsByCwd.set(bindingCwd, ids);
24
25
  }
26
+ return expectedIdsByCwd;
27
+ }
28
+ /**
29
+ * List sessions that belong to the selected cwd plus DriveAgent sessions
30
+ * launched in worktrees associated with it. The external CLI's own recorded
31
+ * cwd is retained on every row and becomes the authoritative path when opening
32
+ * the session.
33
+ */
34
+ export function discoverRelatedSessions(cli, cwd, opts = {}, discovery = {}) {
35
+ const root = resolve(cwd);
36
+ const expectedIdsByCwd = relatedSessionScope(cli, root, discovery);
25
37
  const candidateCwds = [...expectedIdsByCwd.keys()];
26
38
  const candidates = cli === "codex"
27
39
  ? discoverCodexSessionsForCwds(candidateCwds, discovery.codexHome ?? join(homedir(), ".codex"), opts, ({ sessionId, cwd: sessionCwd }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-capability-coding",
3
- "version": "0.9.39",
3
+ "version": "0.9.41",
4
4
  "description": "Coding capability pack for the generic code-shell agent core.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -39,7 +39,7 @@
39
39
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
40
40
  },
41
41
  "dependencies": {
42
- "@cjhyy/code-shell-core": "0.9.39"
42
+ "@cjhyy/code-shell-core": "0.9.41"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=20.10"