@termfleet/core 0.1.4 → 0.2.1

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.md CHANGED
@@ -22,7 +22,7 @@ this package does that standalone.
22
22
  npm install @termfleet/core
23
23
  ```
24
24
 
25
- Requires **Node 18+** and ESM (`"type": "module"`, or `import()`).
25
+ Requires **Node 20+** and ESM (`"type": "module"`, or `import()`).
26
26
 
27
27
  ## Imports are explicit, per module
28
28
 
@@ -1,6 +1,6 @@
1
1
  import { open, readFile, stat } from "node:fs/promises";
2
2
  import { setImmediate as yieldToEventLoop } from "node:timers/promises";
3
- import { codexTranscriptPath, createClaudeSessionParser, emptyClaudeSession, listLocalAgentSubagents, parseCodexSessionJsonl, parseSubagentSessionId, resolveClaudeSubagentTranscriptPath, resolveClaudeTranscriptPath } from "./agent-session.js";
3
+ import { codexTranscriptPath, createClaudeSessionParser, emptyClaudeSession, listLocalAgentSubagents, parseCodexSessionJsonl, parseGeminiSessionJsonl, parseSubagentSessionId, resolveClaudeSubagentTranscriptPath, resolveClaudeTranscriptPath, resolveGeminiTranscriptPath } from "./agent-session.js";
4
4
  // How many transcript lines are parsed between yields back to the event
5
5
  // loop. Keeps a multi-megabyte initial catch-up from starving sockets.
6
6
  const linesPerSlice = 1_000;
@@ -145,8 +145,34 @@ function cachedTail(options) {
145
145
  }
146
146
  return tail;
147
147
  }
148
+ const maxCachedFullParses = 64;
149
+ const fullParseCache = new Map();
150
+ async function readStatCachedFullParse(options) {
151
+ const fileStat = await stat(options.transcriptPath);
152
+ const signature = `${options.kind}:${fileStat.size}:${fileStat.mtimeMs}`;
153
+ const key = `${options.sessionId}\0${options.transcriptPath}`;
154
+ const cached = fullParseCache.get(key);
155
+ if (cached && cached.signature === signature) {
156
+ // Unchanged file: no re-read, no re-parse. Refresh LRU order.
157
+ fullParseCache.delete(key);
158
+ fullParseCache.set(key, cached);
159
+ return { details: cached.details, signature };
160
+ }
161
+ const jsonl = await readFile(options.transcriptPath, "utf8");
162
+ const entry = { details: options.parse(jsonl), signature };
163
+ fullParseCache.delete(key);
164
+ fullParseCache.set(key, entry);
165
+ if (fullParseCache.size > maxCachedFullParses) {
166
+ const oldest = fullParseCache.keys().next().value;
167
+ if (oldest !== undefined) {
168
+ fullParseCache.delete(oldest);
169
+ }
170
+ }
171
+ return { details: entry.details, signature };
172
+ }
148
173
  export function clearAgentSessionTailCache() {
149
174
  tailCache.clear();
175
+ fullParseCache.clear();
150
176
  }
151
177
  export async function readLocalAgentSessionTailed(options) {
152
178
  if (options.sessionId.startsWith("codex:")) {
@@ -154,8 +180,37 @@ export async function readLocalAgentSessionTailed(options) {
154
180
  if (!transcriptPath) {
155
181
  throw new Error(`Codex session transcript was not found for ${options.sessionId} in ${options.cwd}`);
156
182
  }
157
- const jsonl = await readFile(transcriptPath, "utf8");
158
- return { details: parseCodexSessionJsonl({ jsonl, sessionId: options.sessionId, transcriptPath }) };
183
+ return readStatCachedFullParse({
184
+ kind: "codex",
185
+ parse: (jsonl) => parseCodexSessionJsonl({ jsonl, sessionId: options.sessionId, transcriptPath }),
186
+ sessionId: options.sessionId,
187
+ transcriptPath
188
+ });
189
+ }
190
+ if (options.sessionId.startsWith("gemini:")) {
191
+ const transcriptPath = resolveGeminiTranscriptPath(options);
192
+ if (!transcriptPath) {
193
+ // A just-launched Gemini chat with no transcript yet is live but empty —
194
+ // not a failure (mirrors readLocalGeminiSession's empty shape). The next
195
+ // tailed read resolves and parses once it writes messages.
196
+ return {
197
+ details: {
198
+ agentSessionId: options.sessionId,
199
+ endOfTurn: false,
200
+ lastAssistantText: "",
201
+ messages: [],
202
+ provider: "gemini",
203
+ sessionId: options.sessionId,
204
+ timeline: []
205
+ }
206
+ };
207
+ }
208
+ return readStatCachedFullParse({
209
+ kind: "gemini",
210
+ parse: (jsonl) => parseGeminiSessionJsonl({ jsonl, sessionId: options.sessionId, transcriptPath }),
211
+ sessionId: options.sessionId,
212
+ transcriptPath
213
+ });
159
214
  }
160
215
  // A subagent sidechain, addressed by its composite id: tail its file directly
161
216
  // (no nested subagents to list).
@@ -302,6 +302,21 @@ export declare function resolveClaudeTranscriptPath(options: {
302
302
  home?: string;
303
303
  sessionId: string;
304
304
  }): string | undefined;
305
+ export declare function findLocalClaudeSession(sessionId: string, options?: {
306
+ home?: string;
307
+ }): {
308
+ sessionId: string;
309
+ cwd: string | undefined;
310
+ transcriptPath: string;
311
+ } | undefined;
312
+ export declare function findLocalAgentSession(agentSessionId: string, options?: {
313
+ home?: string;
314
+ }): {
315
+ agent: "claude" | "codex" | "gemini";
316
+ sessionId: string;
317
+ cwd: string | undefined;
318
+ transcriptPath: string;
319
+ } | undefined;
305
320
  export declare function emptyClaudeSession(options: {
306
321
  cwd: string;
307
322
  home?: string;
@@ -74,6 +74,59 @@ export function resolveClaudeTranscriptPath(options) {
74
74
  const found = findClaudeTranscriptById(join(options.home ?? agentHome(), ".claude", "projects"), assertSafeAgentSessionId(normalizeClaudeSessionId(options.sessionId)));
75
75
  return existsSync(found) ? found : undefined;
76
76
  }
77
+ // Read the working dir a Claude session ran in, straight from its transcript. The `cwd` is on the message
78
+ // records, not always the first header line, so scan a bounded prefix (headers are small). undefined if absent.
79
+ function readClaudeSessionCwd(transcriptPath, maxBytes = 65_536) {
80
+ let head;
81
+ try {
82
+ const fd = openSync(transcriptPath, "r");
83
+ try {
84
+ const buffer = Buffer.alloc(maxBytes);
85
+ const bytesRead = readSync(fd, buffer, 0, maxBytes, 0);
86
+ head = buffer.toString("utf8", 0, bytesRead);
87
+ }
88
+ finally {
89
+ closeSync(fd);
90
+ }
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ for (const line of head.split("\n")) {
96
+ if (!line)
97
+ continue;
98
+ try {
99
+ const row = JSON.parse(line);
100
+ if (typeof row.cwd === "string" && row.cwd)
101
+ return row.cwd;
102
+ }
103
+ catch { /* a truncated final line in the bounded read — ignore */ }
104
+ }
105
+ return undefined;
106
+ }
107
+ // Resolve a Claude session from its id ALONE (no cwd needed): find its transcript across every project dir and
108
+ // read the session's cwd from it. Local + provider-free — the primitive any tool needs when it has a session
109
+ // id but not its working dir (a session mirror, a resumer, a cross-project browser). undefined when the
110
+ // transcript doesn't exist yet (a just-launched session writes it only once it produces messages).
111
+ export function findLocalClaudeSession(sessionId, options = {}) {
112
+ const bareId = assertSafeAgentSessionId(normalizeClaudeSessionId(sessionId));
113
+ const transcriptPath = findClaudeTranscriptById(join(options.home ?? agentHome(), ".claude", "projects"), bareId);
114
+ if (!existsSync(transcriptPath))
115
+ return undefined;
116
+ return { sessionId: bareId, cwd: readClaudeSessionCwd(transcriptPath), transcriptPath };
117
+ }
118
+ // The agent-generic form: resolve a (prefixed or bare) agentSessionId → its provider, cwd, and transcript
119
+ // path, locally. A bare id is treated as Claude. (Codex/Gemini keep transcripts elsewhere; extend here when
120
+ // needed — non-Claude returns undefined for now.)
121
+ export function findLocalAgentSession(agentSessionId, options = {}) {
122
+ const separator = agentSessionId.indexOf(":");
123
+ const prefix = separator > 0 ? agentSessionId.slice(0, separator) : "";
124
+ const agent = prefix === "codex" ? "codex" : prefix === "gemini" ? "gemini" : "claude";
125
+ if (agent !== "claude")
126
+ return undefined;
127
+ const resolved = findLocalClaudeSession(agentSessionId, options);
128
+ return resolved ? { agent, ...resolved } : undefined;
129
+ }
77
130
  // A real, openable chat for a session that has no transcript yet — just an empty
78
131
  // conversation. Parsing empty JSONL yields a well-formed session with zero
79
132
  // messages, so a just-launched agent displays as an empty chat instead of 500ing.
package/dist/lib/exec.js CHANGED
@@ -105,10 +105,27 @@ async function spawnAsync(command, args, options, input) {
105
105
  });
106
106
  const stdout = [];
107
107
  const stderr = [];
108
+ // Honor timeoutMs without blocking the loop (the sync `run` relied on
109
+ // spawnSync's timeout): SIGKILL the child and reject if it overruns.
110
+ const timer = options.timeoutMs === undefined
111
+ ? undefined
112
+ : setTimeout(() => {
113
+ child.kill("SIGKILL");
114
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms.`));
115
+ }, options.timeoutMs);
116
+ timer?.unref?.();
117
+ const clear = () => {
118
+ if (timer)
119
+ clearTimeout(timer);
120
+ };
108
121
  child.stdout?.on("data", (chunk) => stdout.push(chunk));
109
122
  child.stderr?.on("data", (chunk) => stderr.push(chunk));
110
- child.on("error", reject);
123
+ child.on("error", (error) => {
124
+ clear();
125
+ reject(error);
126
+ });
111
127
  child.on("close", (code) => {
128
+ clear();
112
129
  if (code !== 0) {
113
130
  const detail = Buffer.concat(stderr).toString("utf8").trim();
114
131
  reject(new Error(`${command} ${args.join(" ")} failed with exit ${code}.${detail ? `\n${detail}` : ""}`));
@@ -100,7 +100,7 @@ function startLocalTunnel(src, options) {
100
100
  "--host", tunnelServerUrl,
101
101
  // The tunnel layer stays in passthrough (no tunnel-level auth); the console's
102
102
  // own remote-access gate is what requires a session for non-loopback requests.
103
- // volter-tunnel's flag is --auth-not-required (there is no --no-auth).
103
+ // The reference tunnel client exposes this as --auth-not-required.
104
104
  "--auth-not-required"
105
105
  ];
106
106
  if (options.tunnelId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termfleet/core",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Termfleet core: contracts, the provider SDK, and the agent-transcript/session library (Claude Code / Codex / Gemini). The reusable layer, usable beyond the console.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -39,6 +39,9 @@
39
39
  "build": "tsc -p tsconfig.json",
40
40
  "prepack": "tsc -p tsconfig.json"
41
41
  },
42
+ "engines": {
43
+ "node": ">=20"
44
+ },
42
45
  "dependencies": {
43
46
  "socket.io-client": "^4.8.3",
44
47
  "yjs": "^13.6.31"