@termfleet/core 0.1.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.
Files changed (67) hide show
  1. package/dist/agent-launch.d.ts +78 -0
  2. package/dist/agent-launch.js +247 -0
  3. package/dist/agent-session-id.d.ts +10 -0
  4. package/dist/agent-session-id.js +36 -0
  5. package/dist/agent-session-index-client.d.ts +7 -0
  6. package/dist/agent-session-index-client.js +86 -0
  7. package/dist/agent-session-index-worker.d.ts +1 -0
  8. package/dist/agent-session-index-worker.js +20 -0
  9. package/dist/agent-session-index.d.ts +34 -0
  10. package/dist/agent-session-index.js +527 -0
  11. package/dist/agent-session-tail.d.ts +33 -0
  12. package/dist/agent-session-tail.js +184 -0
  13. package/dist/agent-session-watcher.d.ts +36 -0
  14. package/dist/agent-session-watcher.js +194 -0
  15. package/dist/agent-session.d.ts +380 -0
  16. package/dist/agent-session.js +1688 -0
  17. package/dist/background-runner.d.ts +3 -0
  18. package/dist/background-runner.js +55 -0
  19. package/dist/boot-queue.d.ts +35 -0
  20. package/dist/boot-queue.js +66 -0
  21. package/dist/build-info.d.ts +5 -0
  22. package/dist/build-info.js +38 -0
  23. package/dist/collab/canvas-doc.d.ts +47 -0
  24. package/dist/collab/canvas-doc.js +83 -0
  25. package/dist/contracts/auth.d.ts +77 -0
  26. package/dist/contracts/auth.js +1 -0
  27. package/dist/contracts/canvas.d.ts +34 -0
  28. package/dist/contracts/canvas.js +76 -0
  29. package/dist/contracts/console-layout.d.ts +39 -0
  30. package/dist/contracts/console-layout.js +135 -0
  31. package/dist/contracts/files.d.ts +38 -0
  32. package/dist/contracts/files.js +37 -0
  33. package/dist/contracts/provider-url.d.ts +3 -0
  34. package/dist/contracts/provider-url.js +49 -0
  35. package/dist/contracts/registry.d.ts +58 -0
  36. package/dist/contracts/registry.js +285 -0
  37. package/dist/launch-trace.d.ts +6 -0
  38. package/dist/launch-trace.js +33 -0
  39. package/dist/lib/errors.d.ts +1 -0
  40. package/dist/lib/errors.js +5 -0
  41. package/dist/lib/exec.d.ts +13 -0
  42. package/dist/lib/exec.js +134 -0
  43. package/dist/local-providers.d.ts +32 -0
  44. package/dist/local-providers.js +184 -0
  45. package/dist/local-tunnel.d.ts +6 -0
  46. package/dist/local-tunnel.js +258 -0
  47. package/dist/provider-access-token.d.ts +11 -0
  48. package/dist/provider-access-token.js +77 -0
  49. package/dist/provider-client.d.ts +152 -0
  50. package/dist/provider-client.js +666 -0
  51. package/dist/provider-url-resolver.d.ts +16 -0
  52. package/dist/provider-url-resolver.js +37 -0
  53. package/dist/registry-client.d.ts +93 -0
  54. package/dist/registry-client.js +170 -0
  55. package/dist/registry.d.ts +56 -0
  56. package/dist/registry.js +406 -0
  57. package/dist/session-attention.d.ts +24 -0
  58. package/dist/session-attention.js +54 -0
  59. package/dist/session-lifecycle.d.ts +83 -0
  60. package/dist/session-lifecycle.js +658 -0
  61. package/dist/session-window.d.ts +3 -0
  62. package/dist/session-window.js +20 -0
  63. package/dist/terminal-client.d.ts +49 -0
  64. package/dist/terminal-client.js +89 -0
  65. package/dist/types.d.ts +155 -0
  66. package/dist/types.js +21 -0
  67. package/package.json +26 -0
@@ -0,0 +1,184 @@
1
+ import { open, readFile, stat } from "node:fs/promises";
2
+ import { setImmediate as yieldToEventLoop } from "node:timers/promises";
3
+ import { codexTranscriptPath, createClaudeSessionParser, emptyClaudeSession, listLocalAgentSubagents, parseCodexSessionJsonl, parseSubagentSessionId, resolveClaudeSubagentTranscriptPath, resolveClaudeTranscriptPath } from "./agent-session.js";
4
+ // How many transcript lines are parsed between yields back to the event
5
+ // loop. Keeps a multi-megabyte initial catch-up from starving sockets.
6
+ const linesPerSlice = 1_000;
7
+ const newlineByte = 0x0a;
8
+ // How many trailing consumed bytes are remembered to detect in-place
9
+ // rewrites: if the bytes immediately before our offset no longer match, the
10
+ // file is not the one we parsed and the tail replays from the start. JSONL
11
+ // lines share structural suffixes, so the anchor must outspan them.
12
+ const anchorBytes = 64;
13
+ // Incrementally tails one Claude transcript: each read() consumes only the
14
+ // bytes appended since the previous read and feeds complete lines into a
15
+ // persistent parser. Truncation, rotation, or an in-place rewrite resets the
16
+ // parser and replays from the start. Reads must not run concurrently for the
17
+ // same tail; the session watcher's read queue serializes them.
18
+ export class ClaudeTranscriptTail {
19
+ options;
20
+ offset = 0;
21
+ pendingTail = Buffer.alloc(0);
22
+ anchor = Buffer.alloc(0);
23
+ parser;
24
+ totalLines = 0;
25
+ cached;
26
+ lastIno;
27
+ lastMtimeMs;
28
+ constructor(options) {
29
+ this.options = options;
30
+ this.parser = createClaudeSessionParser(options);
31
+ }
32
+ get transcriptPath() {
33
+ return this.options.transcriptPath;
34
+ }
35
+ reset() {
36
+ this.offset = 0;
37
+ this.pendingTail = Buffer.alloc(0);
38
+ this.anchor = Buffer.alloc(0);
39
+ this.parser = createClaudeSessionParser(this.options);
40
+ this.totalLines = 0;
41
+ this.cached = undefined;
42
+ }
43
+ async read() {
44
+ const fileStat = await stat(this.options.transcriptPath);
45
+ // Three rewrite/rotation signals, cheapest first: the file shrank, the
46
+ // inode changed (file replaced at the same path), or the mtime moved
47
+ // without the file growing (in-place rewrite to the same or smaller
48
+ // size). Appends grow the file, so they never trip the third check.
49
+ const rotated = this.lastIno !== undefined && fileStat.ino !== this.lastIno;
50
+ const rewrittenInPlace = this.lastMtimeMs !== undefined
51
+ && fileStat.mtimeMs !== this.lastMtimeMs
52
+ && fileStat.size <= this.offset;
53
+ if (fileStat.size < this.offset || rotated || rewrittenInPlace) {
54
+ this.reset();
55
+ }
56
+ let consumedLines = 0;
57
+ let handle;
58
+ try {
59
+ if (this.offset > 0 || fileStat.size > 0) {
60
+ handle = await open(this.options.transcriptPath, "r");
61
+ }
62
+ if (handle && this.offset > 0 && this.anchor.length > 0) {
63
+ const check = await readAt(handle, this.offset - this.anchor.length, this.anchor.length);
64
+ if (!check.equals(this.anchor)) {
65
+ // The file at this path no longer contains what we parsed
66
+ // (rotation or in-place rewrite that did not shrink it).
67
+ this.reset();
68
+ }
69
+ }
70
+ if (handle && fileStat.size > this.offset) {
71
+ const appended = await readAt(handle, this.offset, fileStat.size - this.offset);
72
+ this.offset += appended.length;
73
+ const combined = this.pendingTail.length > 0 ? Buffer.concat([this.pendingTail, appended]) : appended;
74
+ const lastNewline = combined.lastIndexOf(newlineByte);
75
+ // Lines are only decoded once complete: a chunk may end mid-codepoint,
76
+ // so the bytes after the final newline stay buffered as bytes.
77
+ this.pendingTail = lastNewline === -1 ? combined : Buffer.from(combined.subarray(lastNewline + 1));
78
+ this.anchor = Buffer.from(combined.subarray(Math.max(0, combined.length - anchorBytes)));
79
+ if (lastNewline !== -1) {
80
+ const lines = combined.subarray(0, lastNewline).toString("utf8").split("\n");
81
+ for (const line of lines) {
82
+ this.parser.feedLine(line);
83
+ consumedLines += 1;
84
+ if (consumedLines % linesPerSlice === 0) {
85
+ await yieldToEventLoop();
86
+ }
87
+ }
88
+ this.totalLines += consumedLines;
89
+ }
90
+ }
91
+ }
92
+ finally {
93
+ await handle?.close();
94
+ }
95
+ this.lastIno = fileStat.ino;
96
+ this.lastMtimeMs = fileStat.mtimeMs;
97
+ if (consumedLines === 0 && this.cached) {
98
+ return { consumedLines, ...this.cached };
99
+ }
100
+ const details = this.parser.finalize();
101
+ const signature = [
102
+ "tail",
103
+ this.totalLines,
104
+ details.messages.length,
105
+ details.timeline.length,
106
+ details.lastMessage?.timestamp ?? ""
107
+ ].join(":");
108
+ this.cached = { details, signature };
109
+ return { consumedLines, details, signature };
110
+ }
111
+ }
112
+ async function readAt(handle, offset, length) {
113
+ const buffer = Buffer.allocUnsafe(length);
114
+ let filled = 0;
115
+ while (filled < length) {
116
+ const { bytesRead } = await handle.read(buffer, filled, length - filled, offset + filled);
117
+ if (bytesRead === 0) {
118
+ break;
119
+ }
120
+ filled += bytesRead;
121
+ }
122
+ return filled === length ? buffer : buffer.subarray(0, filled);
123
+ }
124
+ // Tails are cached per session+transcript so repeated reads stay O(appended
125
+ // bytes). The cache is bounded: least-recently-used tails are dropped and
126
+ // simply re-parse from the start if read again — the deterministic signature
127
+ // keeps that replay from re-publishing identical content.
128
+ const maxCachedTails = 64;
129
+ const tailCache = new Map();
130
+ function cachedTail(options) {
131
+ const key = `${options.sessionId}\0${options.transcriptPath}`;
132
+ const existing = tailCache.get(key);
133
+ if (existing) {
134
+ tailCache.delete(key);
135
+ tailCache.set(key, existing);
136
+ return existing;
137
+ }
138
+ const tail = new ClaudeTranscriptTail(options);
139
+ tailCache.set(key, tail);
140
+ if (tailCache.size > maxCachedTails) {
141
+ const oldest = tailCache.keys().next().value;
142
+ if (oldest !== undefined) {
143
+ tailCache.delete(oldest);
144
+ }
145
+ }
146
+ return tail;
147
+ }
148
+ export function clearAgentSessionTailCache() {
149
+ tailCache.clear();
150
+ }
151
+ export async function readLocalAgentSessionTailed(options) {
152
+ if (options.sessionId.startsWith("codex:")) {
153
+ const transcriptPath = codexTranscriptPath(options);
154
+ if (!transcriptPath) {
155
+ throw new Error(`Codex session transcript was not found for ${options.sessionId} in ${options.cwd}`);
156
+ }
157
+ const jsonl = await readFile(transcriptPath, "utf8");
158
+ return { details: parseCodexSessionJsonl({ jsonl, sessionId: options.sessionId, transcriptPath }) };
159
+ }
160
+ // A subagent sidechain, addressed by its composite id: tail its file directly
161
+ // (no nested subagents to list).
162
+ const subagentRef = parseSubagentSessionId(options.sessionId);
163
+ if (subagentRef) {
164
+ const subagentPath = resolveClaudeSubagentTranscriptPath({ ...(options.home ? { home: options.home } : {}), ...subagentRef });
165
+ const subagentTail = cachedTail({ sessionId: options.sessionId, transcriptPath: subagentPath });
166
+ const { details, signature } = await subagentTail.read();
167
+ return { details, signature };
168
+ }
169
+ // Resolution runs on every read: if the transcript moved, the stale tail is
170
+ // abandoned and a fresh one replays the new file.
171
+ const transcriptPath = resolveClaudeTranscriptPath(options);
172
+ if (!transcriptPath) {
173
+ // A just-launched agent with no transcript yet is a live, empty chat — not a
174
+ // failure. Once it writes messages, the next tailed read resolves and replays.
175
+ return { details: emptyClaudeSession(options) };
176
+ }
177
+ const tail = cachedTail({ sessionId: options.sessionId, transcriptPath });
178
+ const { details, signature } = await tail.read();
179
+ // Re-list subagents on every read (metadata only) so a live chat surfaces
180
+ // subagents as they spawn — the tailed message signature wouldn't otherwise
181
+ // change when only a new sidechain file appears.
182
+ const subagents = listLocalAgentSubagents({ ...(options.home ? { home: options.home } : {}), sessionId: options.sessionId, transcriptPath });
183
+ return { details: subagents.length ? { ...details, subagents } : details, signature };
184
+ }
@@ -0,0 +1,36 @@
1
+ import type { AgentSessionReadResult } from "./agent-session-tail.js";
2
+ import type { AgentSessionDetails } from "./agent-session.js";
3
+ export type AgentSessionWatchOptions = {
4
+ cwd?: string;
5
+ sessionId: string;
6
+ };
7
+ export type AgentSessionWatchEvent = {
8
+ details: AgentSessionDetails;
9
+ sessionId: string;
10
+ type: "update";
11
+ } | {
12
+ error: string;
13
+ sessionId: string;
14
+ type: "error";
15
+ };
16
+ export type AgentSessionSubscription = {
17
+ unsubscribe(): void;
18
+ };
19
+ type AgentSessionListener = (event: AgentSessionWatchEvent) => void;
20
+ type AgentSessionReader = (options: AgentSessionWatchOptions) => AgentSessionDetails | AgentSessionReadResult | Promise<AgentSessionDetails | AgentSessionReadResult>;
21
+ export declare class AgentSessionWatcher {
22
+ private readonly readSession;
23
+ private readonly entries;
24
+ constructor(readSession: AgentSessionReader);
25
+ subscribe(options: AgentSessionWatchOptions, listener: AgentSessionListener): AgentSessionSubscription;
26
+ close(): void;
27
+ private unsubscribe;
28
+ private scheduleRead;
29
+ private scheduleRetry;
30
+ private readAndPublish;
31
+ private watchTranscript;
32
+ private publish;
33
+ private closeEntry;
34
+ private resetWatcher;
35
+ }
36
+ export {};
@@ -0,0 +1,194 @@
1
+ import { asError } from "@termfleet/core/lib/errors.js";
2
+ import { watch } from "node:fs";
3
+ const initialRetryDelayMs = 1_000;
4
+ const maxRetryDelayMs = 10_000;
5
+ // Transcript reads run one at a time: tailed reads are incremental and yield
6
+ // to the event loop internally, and serializing them keeps two reads from
7
+ // racing on the same transcript tail.
8
+ const pendingReads = [];
9
+ let drainScheduled = false;
10
+ function enqueueSessionRead(task) {
11
+ pendingReads.push(task);
12
+ if (!drainScheduled) {
13
+ drainScheduled = true;
14
+ setImmediate(() => void drainSessionReads());
15
+ }
16
+ }
17
+ async function drainSessionReads() {
18
+ const task = pendingReads.shift();
19
+ try {
20
+ await task?.();
21
+ }
22
+ finally {
23
+ if (pendingReads.length > 0) {
24
+ setImmediate(() => void drainSessionReads());
25
+ }
26
+ else {
27
+ drainScheduled = false;
28
+ }
29
+ }
30
+ }
31
+ export class AgentSessionWatcher {
32
+ readSession;
33
+ entries = new Map();
34
+ constructor(readSession) {
35
+ this.readSession = readSession;
36
+ }
37
+ subscribe(options, listener) {
38
+ const key = watchKey(options);
39
+ let entry = this.entries.get(key);
40
+ if (!entry) {
41
+ entry = {
42
+ key,
43
+ listeners: new Set(),
44
+ options,
45
+ retryDelayMs: initialRetryDelayMs
46
+ };
47
+ this.entries.set(key, entry);
48
+ }
49
+ entry.listeners.add(listener);
50
+ this.scheduleRead(entry, 0);
51
+ return {
52
+ unsubscribe: () => {
53
+ this.unsubscribe(key, listener);
54
+ }
55
+ };
56
+ }
57
+ close() {
58
+ for (const entry of this.entries.values()) {
59
+ this.closeEntry(entry);
60
+ }
61
+ this.entries.clear();
62
+ }
63
+ unsubscribe(key, listener) {
64
+ const entry = this.entries.get(key);
65
+ if (!entry) {
66
+ return;
67
+ }
68
+ entry.listeners.delete(listener);
69
+ if (entry.listeners.size === 0) {
70
+ this.closeEntry(entry);
71
+ this.entries.delete(key);
72
+ }
73
+ }
74
+ scheduleRead(entry, delayMs) {
75
+ if (entry.listeners.size === 0) {
76
+ return;
77
+ }
78
+ if (entry.debounceTimer) {
79
+ clearTimeout(entry.debounceTimer);
80
+ }
81
+ entry.debounceTimer = setTimeout(() => {
82
+ entry.debounceTimer = undefined;
83
+ enqueueSessionRead(() => {
84
+ if (entry.listeners.size > 0) {
85
+ return this.readAndPublish(entry);
86
+ }
87
+ return undefined;
88
+ });
89
+ }, delayMs);
90
+ }
91
+ scheduleRetry(entry) {
92
+ if (entry.listeners.size === 0 || entry.retryTimer) {
93
+ return;
94
+ }
95
+ const delayMs = entry.retryDelayMs;
96
+ entry.retryDelayMs = Math.min(entry.retryDelayMs * 2, maxRetryDelayMs);
97
+ entry.retryTimer = setTimeout(() => {
98
+ entry.retryTimer = undefined;
99
+ this.scheduleRead(entry, 0);
100
+ }, delayMs);
101
+ }
102
+ async readAndPublish(entry) {
103
+ let result;
104
+ try {
105
+ const raw = await this.readSession(entry.options);
106
+ result = "details" in raw ? raw : { details: raw };
107
+ }
108
+ catch (error) {
109
+ const message = asError(error).message;
110
+ this.resetWatcher(entry);
111
+ if (entry.error !== message) {
112
+ entry.error = message;
113
+ this.publish(entry, { error: message, sessionId: entry.options.sessionId, type: "error" });
114
+ }
115
+ this.scheduleRetry(entry);
116
+ return;
117
+ }
118
+ entry.error = undefined;
119
+ entry.retryDelayMs = initialRetryDelayMs;
120
+ this.watchTranscript(entry, result.details.transcriptPath);
121
+ // Tailed reads supply a cheap deterministic content signature; full
122
+ // re-parse paths fall back to stringifying the details. Either way the
123
+ // signature is tracked per entry, so entries sharing one tail dedupe
124
+ // independently instead of starving each other.
125
+ const signature = result.signature ?? JSON.stringify(result.details);
126
+ if (entry.lastSignature === signature) {
127
+ return;
128
+ }
129
+ entry.lastSignature = signature;
130
+ this.publish(entry, { details: result.details, sessionId: entry.options.sessionId, type: "update" });
131
+ }
132
+ watchTranscript(entry, transcriptPath) {
133
+ if (!transcriptPath || entry.transcriptPath === transcriptPath) {
134
+ return;
135
+ }
136
+ entry.watcher?.close();
137
+ entry.watcher = undefined;
138
+ try {
139
+ entry.watcher = watch(transcriptPath, { persistent: false }, (eventType) => {
140
+ if (eventType === "rename") {
141
+ this.resetWatcher(entry);
142
+ }
143
+ this.scheduleRead(entry, 100);
144
+ });
145
+ entry.watcher.on("error", (error) => {
146
+ const message = asError(error).message;
147
+ this.resetWatcher(entry);
148
+ this.publish(entry, { error: message, sessionId: entry.options.sessionId, type: "error" });
149
+ this.scheduleRetry(entry);
150
+ });
151
+ entry.transcriptPath = transcriptPath;
152
+ }
153
+ catch (error) {
154
+ if (isFileMissing(error)) {
155
+ // A freshly launched, message-less chat has no transcript file yet, so
156
+ // watching it directly throws ENOENT — not a user-facing error. Leave
157
+ // transcriptPath unset and keep polling: the next read resolves the
158
+ // transcript by id once it is written (which may be under a different
159
+ // project dir than this caller's cwd implies) and installs the watcher.
160
+ this.scheduleRetry(entry);
161
+ return;
162
+ }
163
+ const message = asError(error).message;
164
+ this.resetWatcher(entry);
165
+ this.publish(entry, { error: message, sessionId: entry.options.sessionId, type: "error" });
166
+ this.scheduleRetry(entry);
167
+ }
168
+ }
169
+ publish(entry, event) {
170
+ for (const listener of entry.listeners) {
171
+ listener(event);
172
+ }
173
+ }
174
+ closeEntry(entry) {
175
+ if (entry.debounceTimer) {
176
+ clearTimeout(entry.debounceTimer);
177
+ }
178
+ if (entry.retryTimer) {
179
+ clearTimeout(entry.retryTimer);
180
+ }
181
+ entry.watcher?.close();
182
+ }
183
+ resetWatcher(entry) {
184
+ entry.watcher?.close();
185
+ entry.watcher = undefined;
186
+ entry.transcriptPath = undefined;
187
+ }
188
+ }
189
+ function isFileMissing(error) {
190
+ return typeof error === "object" && error !== null && error.code === "ENOENT";
191
+ }
192
+ function watchKey(options) {
193
+ return `${options.sessionId}\0${options.cwd ?? ""}`;
194
+ }