@termfleet/core 0.1.3 → 0.2.0

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
 
@@ -102,10 +102,18 @@ const off = client.onSnapshot((snapshot) => { /* … */ });
102
102
 
103
103
  // create a fresh agent window (boot a claude/codex/gemini session)
104
104
  await client.createAgentWindow({ /* AgentWindowCreateOptions */ });
105
+
106
+ // terminals + files: capture/send, and read/write through the provider filesystem
107
+ const { content } = await client.captureTerminal("term-1", 40);
108
+ await client.sendTerminalInput("term-1", "echo hi\n", { breakGlass: true });
109
+ const bytes = await client.readFile("term-1", "/path/file"); // Uint8Array (browser-safe)
110
+ const text = new TextDecoder().decode(bytes); // node: or Buffer.from(bytes)
105
111
  ```
106
112
 
107
113
  Pass `{ authToken }` for an authenticated/shared provider, or a `urlResolver`
108
- when proxying through a console; omit both for a direct connection.
114
+ when proxying through a console; omit both for a direct connection. `readFile`
115
+ returns a `Uint8Array` (not a node `Buffer`, since this client also runs in the
116
+ browser) — decode with `new TextDecoder().decode(bytes)` or `Buffer.from(bytes)`.
109
117
 
110
118
  ## What's inside
111
119
 
@@ -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).
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) {
@@ -120,15 +120,15 @@ export declare class ProviderClient {
120
120
  }): Promise<CommandAck<T>>;
121
121
  listDirectory(terminalId: string, path: string): Promise<ProviderDirectoryList>;
122
122
  listDirectoryByFilesystem(filesystemId: string, path: string): Promise<ProviderDirectoryList>;
123
- readFile(terminalId: string, path: string): Promise<Buffer>;
124
- readFileByFilesystem(filesystemId: string, path: string): Promise<Buffer>;
123
+ readFile(terminalId: string, path: string): Promise<Uint8Array>;
124
+ readFileByFilesystem(filesystemId: string, path: string): Promise<Uint8Array>;
125
125
  statFile(terminalId: string, path: string): Promise<ProviderFileStat>;
126
126
  statFileByFilesystem(filesystemId: string, path: string): Promise<ProviderFileStat>;
127
- writeFile(terminalId: string, path: string, data: Buffer, options?: {
127
+ writeFile(terminalId: string, path: string, data: Uint8Array, options?: {
128
128
  mkdirs?: boolean;
129
129
  mode?: string;
130
130
  }): Promise<ProviderFileWriteResult>;
131
- writeFileByFilesystem(filesystemId: string, path: string, data: Buffer, options?: {
131
+ writeFileByFilesystem(filesystemId: string, path: string, data: Uint8Array, options?: {
132
132
  mkdirs?: boolean;
133
133
  mode?: string;
134
134
  }): Promise<ProviderFileWriteResult>;
@@ -385,6 +385,9 @@ export class ProviderClient {
385
385
  }
386
386
  return await response.json();
387
387
  }
388
+ // Returns raw bytes as a Uint8Array (browser-safe — this client also runs in the
389
+ // browser, where node's Buffer is absent). Node consumers decode with
390
+ // `Buffer.from(bytes)` or `new TextDecoder().decode(bytes)`.
388
391
  async readFile(terminalId, path) {
389
392
  const url = new URL(`${this.apiBase}/api/files`);
390
393
  url.searchParams.set("terminalId", terminalId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termfleet/core",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
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",
@@ -37,7 +37,10 @@
37
37
  ],
38
38
  "scripts": {
39
39
  "build": "tsc -p tsconfig.json",
40
- "prepare": "tsc -p tsconfig.json"
40
+ "prepack": "tsc -p tsconfig.json"
41
+ },
42
+ "engines": {
43
+ "node": ">=20"
41
44
  },
42
45
  "dependencies": {
43
46
  "socket.io-client": "^4.8.3",