@rynx-ai/runtime 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 (69) hide show
  1. package/dist/claude/executor.d.ts +17 -0
  2. package/dist/claude/executor.js +28 -0
  3. package/dist/claude/models.d.ts +10 -0
  4. package/dist/claude/models.js +33 -0
  5. package/dist/claude/native-bridge.d.ts +133 -0
  6. package/dist/claude/native-bridge.js +299 -0
  7. package/dist/claude/native-hook-main.d.ts +2 -0
  8. package/dist/claude/native-hook-main.js +74 -0
  9. package/dist/claude/native-hooks.d.ts +41 -0
  10. package/dist/claude/native-hooks.js +73 -0
  11. package/dist/claude/native-integration.d.ts +213 -0
  12. package/dist/claude/native-integration.js +665 -0
  13. package/dist/claude/native-message-display-main.d.ts +2 -0
  14. package/dist/claude/native-message-display-main.js +51 -0
  15. package/dist/claude/native-status-main.d.ts +2 -0
  16. package/dist/claude/native-status-main.js +105 -0
  17. package/dist/claude/status.d.ts +23 -0
  18. package/dist/claude/status.js +118 -0
  19. package/dist/claude/transcript.d.ts +79 -0
  20. package/dist/claude/transcript.js +272 -0
  21. package/dist/claude/trust.d.ts +6 -0
  22. package/dist/claude/trust.js +85 -0
  23. package/dist/codex/rollout-synth.d.ts +37 -0
  24. package/dist/codex/rollout-synth.js +212 -0
  25. package/dist/codex-app-server/client.d.ts +138 -0
  26. package/dist/codex-app-server/client.js +341 -0
  27. package/dist/codex-app-server/forwarder.d.ts +92 -0
  28. package/dist/codex-app-server/forwarder.js +188 -0
  29. package/dist/codex-app-server/mapping.d.ts +19 -0
  30. package/dist/codex-app-server/mapping.js +189 -0
  31. package/dist/codex-app-server/protocol.d.ts +472 -0
  32. package/dist/codex-app-server/protocol.js +12 -0
  33. package/dist/codex-app-server/transport.d.ts +139 -0
  34. package/dist/codex-app-server/transport.js +422 -0
  35. package/dist/codex-app-server/ws-channel.d.ts +72 -0
  36. package/dist/codex-app-server/ws-channel.js +233 -0
  37. package/dist/codex-child-env.d.ts +1 -0
  38. package/dist/codex-child-env.js +27 -0
  39. package/dist/codex-home.d.ts +47 -0
  40. package/dist/codex-home.js +135 -0
  41. package/dist/codex-session-store.d.ts +42 -0
  42. package/dist/codex-session-store.js +126 -0
  43. package/dist/host.d.ts +324 -0
  44. package/dist/host.js +1323 -0
  45. package/dist/index.d.ts +18 -0
  46. package/dist/index.js +17 -0
  47. package/dist/models-catalog.d.ts +18 -0
  48. package/dist/models-catalog.js +27 -0
  49. package/dist/runner/child.d.ts +58 -0
  50. package/dist/runner/child.js +268 -0
  51. package/dist/runner/manager.d.ts +175 -0
  52. package/dist/runner/manager.js +458 -0
  53. package/dist/runner/protocol.d.ts +195 -0
  54. package/dist/runner/protocol.js +41 -0
  55. package/dist/runner/transport.d.ts +36 -0
  56. package/dist/runner/transport.js +72 -0
  57. package/dist/runner-main.d.ts +2 -0
  58. package/dist/runner-main.js +61 -0
  59. package/dist/runtime-status.d.ts +16 -0
  60. package/dist/runtime-status.js +80 -0
  61. package/dist/terminal/claude-tui.d.ts +27 -0
  62. package/dist/terminal/claude-tui.js +13 -0
  63. package/dist/terminal/codex-tui.d.ts +54 -0
  64. package/dist/terminal/codex-tui.js +26 -0
  65. package/dist/terminal/registry.d.ts +42 -0
  66. package/dist/terminal/registry.js +70 -0
  67. package/dist/terminal/tmux.d.ts +150 -0
  68. package/dist/terminal/tmux.js +364 -0
  69. package/package.json +32 -0
@@ -0,0 +1,665 @@
1
+ /**
2
+ * ClaudeLiveSession — the claude-native analogue of the codex
3
+ * {@link ../codex-app-server/forwarder.ts CodexSessionForwarder}. claude has no
4
+ * app-server, so instead of subscribing to RPC notifications it tails two files
5
+ * the Claude Code TUI feeds through its hooks + transcript:
6
+ *
7
+ * - `hooks.jsonl` (bridge) — `SessionStart` reveals the transcript path + claude
8
+ * session id (discovery); `Stop`/`StopFailure` CLOSE the turn (authoritative).
9
+ * - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
10
+ * assistant/tool records become {@link AgentEvent}s. (Turn framing is
11
+ * transcript-open + hook-close, matching the omnigent re-audit; `running` is
12
+ * implicit between open and close, so no PTY watcher is needed.)
13
+ *
14
+ * The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
15
+ * the codex forwarder uses (see the host), so mirroring is identical downstream.
16
+ * A turn with no `Stop` (a missed/absent hook) is closed by an inactivity
17
+ * backstop — but only when no tool call is still open, so a long Bash never trips
18
+ * a false turn-end.
19
+ */
20
+ import { statSync } from "node:fs";
21
+ import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
22
+ import { jsonlCursorFingerprint, readClaudeStatus, readForwardState, readHookEventsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, writeForwardState, } from "./native-bridge.js";
23
+ /** The trimmed string content of a `role:user` record, or undefined for
24
+ * tool_result (array-content) records. Keeps XML-marker bookkeeping records
25
+ * (`<command-name>…`, `<bash-input>…`, `<caveat>…`) — the caller classifies each. */
26
+ function userStringContent(rec) {
27
+ if (rec.type !== "user" || rec.message?.role !== "user")
28
+ return undefined;
29
+ const content = rec.message.content;
30
+ if (typeof content !== "string")
31
+ return undefined; // tool_result records are arrays
32
+ return content.trim() || undefined;
33
+ }
34
+ function isRecord(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function readId(value) {
38
+ return isRecord(value) && typeof value.id === "string" && value.id ? value.id : undefined;
39
+ }
40
+ /** Byte size of a file, or 0 if it can't be stat'd (used to start a fork tail at EOF). */
41
+ function fileSize(path) {
42
+ try {
43
+ return statSync(path).size;
44
+ }
45
+ catch {
46
+ return 0;
47
+ }
48
+ }
49
+ function readStr(value) {
50
+ if (typeof value === "string" && value)
51
+ return value;
52
+ if (typeof value === "number")
53
+ return String(value);
54
+ return undefined;
55
+ }
56
+ /** A `TaskCreate` tool_result record carries `toolUseResult.task = {id, subject}`. */
57
+ function readTaskCreate(rec) {
58
+ const result = rec.toolUseResult;
59
+ if (!isRecord(result) || !isRecord(result.task))
60
+ return undefined;
61
+ const id = readStr(result.task.id);
62
+ if (!id)
63
+ return undefined;
64
+ return { id, subject: readStr(result.task.subject) ?? "" };
65
+ }
66
+ /** `TaskUpdate` tool_use blocks in an assistant record: `{ taskId, status?, subject? }`. */
67
+ function readTaskUpdates(rec) {
68
+ if (rec.type !== "assistant")
69
+ return [];
70
+ const content = rec.message?.content;
71
+ if (!Array.isArray(content))
72
+ return [];
73
+ const out = [];
74
+ for (const block of content) {
75
+ if (isRecord(block) && block.type === "tool_use" && block.name === "TaskUpdate" && isRecord(block.input)) {
76
+ const taskId = readStr(block.input.taskId);
77
+ if (taskId)
78
+ out.push({ taskId, status: readStr(block.input.status), subject: readStr(block.input.subject) });
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+ function normalizeTodoStatus(status) {
84
+ return status === "pending" || status === "in_progress" || status === "completed" ? status : undefined;
85
+ }
86
+ /** The `tool_use_id` of a record's tool_result block (the parent call it answers). */
87
+ function toolResultCallId(rec) {
88
+ const content = rec.message?.content;
89
+ if (!Array.isArray(content))
90
+ return undefined;
91
+ for (const block of content) {
92
+ if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
93
+ return block.tool_use_id;
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ export class ClaudeLiveSession {
99
+ bridgeDir;
100
+ sink;
101
+ pollMs;
102
+ idleCloseMs;
103
+ stopGraceMs;
104
+ now;
105
+ started = false;
106
+ stopped = false;
107
+ hooksOffset = 0;
108
+ transcriptOffset = 0;
109
+ /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
110
+ * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
111
+ * forwarder state; bounded there. */
112
+ seenSourceIds = new Set();
113
+ transcriptPath;
114
+ discovered = false;
115
+ /** claude's current session uuid (changes on `/clear`·`/fork`·resume). */
116
+ currentClaudeSessionId;
117
+ /** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
118
+ * marker signals a `/fork` (vs. resuming a known branch). */
119
+ seenClaudeSessionIds = new Set();
120
+ turnOpen = false;
121
+ currentTurnId;
122
+ lastActivityAt = 0;
123
+ /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
124
+ stopPendingAt = null;
125
+ /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
126
+ openToolIds = new Set();
127
+ /** Sub-agent (Task) ids already forwarded — a Task tool_result is processed once. */
128
+ seenSubagents = new Set();
129
+ /** The agent's task list, keyed by task id in creation order (claude
130
+ * `TaskCreate`/`TaskUpdate`). Emitted whole as a snapshot on any change. */
131
+ todos = new Map();
132
+ /** Latest statusLine context/cost snapshot (context_window + cost); attached to
133
+ * a turn's usage on close. undefined until the statusLine hook first fires. */
134
+ latestStatus;
135
+ deltasOffset = 0;
136
+ /** MessageDisplay message ids in first-seen order, FIFO-mapped onto the
137
+ * transcript's assistant-text records so a streamed message and its final
138
+ * item share an itemId (message_id is absent from the transcript). */
139
+ messageIdQueue = [];
140
+ seenMessageIds = new Set();
141
+ constructor(opts) {
142
+ this.bridgeDir = opts.bridgeDir;
143
+ this.sink = opts.sink;
144
+ this.pollMs = opts.pollMs ?? 200;
145
+ this.idleCloseMs = opts.idleCloseMs ?? 30_000;
146
+ this.stopGraceMs = opts.stopGraceMs ?? 4_000;
147
+ this.now = opts.now ?? (() => Date.now());
148
+ this.transcriptPath = opts.transcriptPath;
149
+ // Resume path: bind the session id + restore the persisted forwarder cursor so
150
+ // we continue from where a prior forwarder left off (no re-mirror on relaunch).
151
+ if (opts.transcriptPath) {
152
+ if (opts.claudeSessionId) {
153
+ this.currentClaudeSessionId = opts.claudeSessionId;
154
+ this.seenClaudeSessionIds.add(opts.claudeSessionId);
155
+ }
156
+ this.restoreForwardState(opts.transcriptPath);
157
+ }
158
+ }
159
+ /**
160
+ * Restore the durable forwarder cursor for `transcriptPath` (omnigent's
161
+ * `_validated_transcript_state`). A cursor for a DIFFERENT file is ignored (a new
162
+ * session starts at 0). A matching cursor whose fingerprint still validates
163
+ * resumes at its `byteOffset`; a MISMATCH (the file was truncated/replaced) skips
164
+ * to the current EOF — never seek into a stale offset — while preserving
165
+ * `seenSourceIds` so nothing re-emits.
166
+ */
167
+ restoreForwardState(transcriptPath) {
168
+ const state = readForwardState(this.bridgeDir);
169
+ if (!state || state.transcriptPath !== transcriptPath)
170
+ return;
171
+ for (const id of state.seenSourceIds)
172
+ this.seenSourceIds.add(id);
173
+ const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
174
+ this.transcriptOffset =
175
+ fingerprint !== undefined && fingerprint === state.cursorFingerprint
176
+ ? state.byteOffset // cursor valid → resume here
177
+ : fileSize(transcriptPath); // truncated/replaced → skip to EOF
178
+ }
179
+ /** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
180
+ persistForwardState() {
181
+ if (!this.transcriptPath)
182
+ return;
183
+ const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.transcriptOffset);
184
+ writeForwardState(this.bridgeDir, {
185
+ transcriptPath: this.transcriptPath,
186
+ byteOffset: this.transcriptOffset,
187
+ seenSourceIds: [...this.seenSourceIds],
188
+ ...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
189
+ });
190
+ }
191
+ /** True once SessionStart bound the transcript (or a resume path was given). */
192
+ isReady() {
193
+ return this.transcriptPath !== undefined;
194
+ }
195
+ start() {
196
+ if (this.started)
197
+ return;
198
+ this.started = true;
199
+ void this.loop();
200
+ }
201
+ stop() {
202
+ this.stopped = true;
203
+ }
204
+ /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
205
+ * drive deterministically; the async {@link loop} just calls it on an interval. */
206
+ tick() {
207
+ // Order matters: pollDeltas PUSHes a message_id onto the FIFO queue before
208
+ // pollTranscript SHIFTs it for the final item — else a same-tick delta+record
209
+ // (claude flushing a short message as one chunk) would miss the correlation.
210
+ this.pollHooks();
211
+ this.pollDeltas();
212
+ this.pollTranscript();
213
+ this.pollStatus();
214
+ this.maybeIdleClose();
215
+ }
216
+ async loop() {
217
+ while (!this.stopped) {
218
+ try {
219
+ this.tick();
220
+ }
221
+ catch (err) {
222
+ // A single failing poll must NOT kill the whole forwarder — that would
223
+ // desync chat↔terminal permanently. Log + continue, per omnigent's
224
+ // per-iteration `except Exception` in the transcript forwarder loop.
225
+ console.error("[claude-forwarder] tick failed; continuing:", err);
226
+ }
227
+ await new Promise((r) => setTimeout(r, this.pollMs));
228
+ }
229
+ if (this.turnOpen) {
230
+ this.turnOpen = false;
231
+ this.sink.onTurnEnd(this.statusUsage());
232
+ }
233
+ }
234
+ pollHooks() {
235
+ const { events, nextOffset } = readHookEventsFrom(this.bridgeDir, this.hooksOffset);
236
+ this.hooksOffset = nextOffset;
237
+ for (const ev of events)
238
+ this.handleHook(ev);
239
+ }
240
+ handleHook(ev) {
241
+ if (ev.eventName === "SessionStart") {
242
+ this.handleSessionStart(ev);
243
+ return;
244
+ }
245
+ if (ev.eventName === "Stop" || ev.eventName === "StopFailure") {
246
+ // Subagent stops (transcript_path under `subagents/`) don't end the parent turn.
247
+ if (ev.transcriptPath?.includes("/subagents/"))
248
+ return;
249
+ // Stop only signals idle — it does NOT close the turn (see onIdle): closing
250
+ // here would split a late-flushing assistant record into its own turn.
251
+ if (ev.eventName === "StopFailure")
252
+ this.closeTurnError(new Error("claude turn failed"));
253
+ else {
254
+ this.sink.onIdle();
255
+ this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
256
+ }
257
+ }
258
+ }
259
+ /** SessionStart drives discovery (first) and rotation (a later one with a NEW
260
+ * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
261
+ * into an unseen id with a forkedFrom marker. Any other new-transcript
262
+ * SessionStart (compact/resume/startup) is followed WITHOUT rotating. */
263
+ handleSessionStart(ev) {
264
+ if (!ev.transcriptPath)
265
+ return;
266
+ // First discovery: bind the transcript + reveal the session id.
267
+ if (!this.transcriptPath) {
268
+ this.transcriptPath = ev.transcriptPath;
269
+ this.transcriptOffset = 0;
270
+ this.currentClaudeSessionId = ev.sessionId;
271
+ if (ev.sessionId)
272
+ this.seenClaudeSessionIds.add(ev.sessionId);
273
+ // Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
274
+ // already-logged turns.
275
+ this.restoreForwardState(ev.transcriptPath);
276
+ if (!this.discovered && ev.sessionId) {
277
+ this.discovered = true;
278
+ this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
279
+ }
280
+ return;
281
+ }
282
+ // Same session id (a plain re-announce) → nothing to do.
283
+ if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
284
+ return;
285
+ const wasSeen = this.seenClaudeSessionIds.has(ev.sessionId);
286
+ this.seenClaudeSessionIds.add(ev.sessionId);
287
+ let kind;
288
+ if (ev.source === "clear")
289
+ kind = "clear";
290
+ else if (ev.source === "resume" && !wasSeen && transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId)) {
291
+ kind = "fork";
292
+ }
293
+ // A fork copies the source history into the new transcript; start at its END
294
+ // so those records aren't re-mirrored. A clear starts fresh at byte 0.
295
+ this.repointTranscript(ev.transcriptPath, kind === "fork");
296
+ this.currentClaudeSessionId = ev.sessionId;
297
+ if (kind)
298
+ this.sink.onSessionRotated?.(kind, ev.sessionId, ev.transcriptPath);
299
+ }
300
+ /** Switch the tailed transcript and reset per-session accumulators (the bridge
301
+ * files — hooks/deltas/status — are shared by the same claude process across a
302
+ * rotation, so their cursors are NOT reset). */
303
+ repointTranscript(newPath, atEof) {
304
+ this.closeTurn(); // finalize the prior session's last turn before switching
305
+ this.transcriptPath = newPath;
306
+ this.transcriptOffset = atEof ? fileSize(newPath) : 0;
307
+ this.seenSubagents.clear();
308
+ this.todos.clear();
309
+ this.seenMessageIds.clear();
310
+ this.messageIdQueue.length = 0;
311
+ this.latestStatus = undefined;
312
+ // Fresh transcript → drop the old cursor + seen ids (a new session has fresh
313
+ // record uuids, so no collision); the next poll seeds a new forwarder state.
314
+ this.seenSourceIds.clear();
315
+ resetForwardState(this.bridgeDir);
316
+ this.persistForwardState();
317
+ }
318
+ pollTranscript() {
319
+ if (!this.transcriptPath)
320
+ return;
321
+ const { records, nextOffset } = readJsonlFrom(this.transcriptPath, this.transcriptOffset);
322
+ this.transcriptOffset = nextOffset;
323
+ for (const rec of records)
324
+ this.handleRecord(rec);
325
+ // Persist the durable cursor after consuming records so a relaunch resumes here.
326
+ if (records.length > 0)
327
+ this.persistForwardState();
328
+ }
329
+ handleRecord(rec) {
330
+ if (rec.isSidechain === true)
331
+ return; // sub-agent turns (Phase 9 forwards them)
332
+ // Secondary dedup: skip a record whose source id was already forwarded (a
333
+ // re-read after a fingerprint reset). rynx emits a record's items atomically,
334
+ // so the record uuid is the source key (vs omnigent's per-block key for its
335
+ // per-item POST). Records without a uuid fall through undeduped (rare).
336
+ const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
337
+ if (sourceId) {
338
+ if (this.seenSourceIds.has(sourceId))
339
+ return;
340
+ this.seenSourceIds.add(sourceId);
341
+ }
342
+ const content = userStringContent(rec);
343
+ if (content !== undefined) {
344
+ // A local `!` command records its input+output as `<bash-*>` markers — mirror
345
+ // it as a self-contained terminal_command turn (before the prompt check, as
346
+ // it too is `<`-prefixed).
347
+ const term = parseTerminalCommand(content);
348
+ if (term) {
349
+ this.emitTerminalCommand(rec, term);
350
+ return;
351
+ }
352
+ // A real prompt (not an XML-marker bookkeeping record) opens a new turn.
353
+ if (!content.startsWith("<")) {
354
+ this.closeTurn();
355
+ this.currentTurnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
356
+ this.turnOpen = true;
357
+ this.openToolIds.clear();
358
+ this.stopPendingAt = null;
359
+ this.sink.onTurnStart(this.currentTurnId);
360
+ this.sink.onUserMessage(content);
361
+ this.lastActivityAt = this.now();
362
+ }
363
+ return; // string-content user record: prompt, terminal_command, or skipped marker
364
+ }
365
+ const events = parseTranscriptRecord(rec);
366
+ if (events.length === 0)
367
+ return;
368
+ this.ensureTurn();
369
+ for (const event of events) {
370
+ const mapped = this.remapMessageItem(event);
371
+ this.trackTool(mapped);
372
+ this.sink.onEvent(mapped);
373
+ }
374
+ // A Task tool_result carries `toolUseResult.agentId` — replay the (now
375
+ // complete) sub-agent transcript nested under this parent Task call.
376
+ this.maybeForwardSubagent(rec);
377
+ // TaskCreate/TaskUpdate records also refresh the task-list snapshot.
378
+ this.maybeUpdateTodos(rec);
379
+ this.lastActivityAt = this.now();
380
+ }
381
+ /** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
382
+ * record into the task list; on any change emit the whole list as a snapshot. */
383
+ maybeUpdateTodos(rec) {
384
+ let changed = false;
385
+ const created = readTaskCreate(rec);
386
+ if (created) {
387
+ this.todos.set(created.id, { id: created.id, subject: created.subject, status: "pending" });
388
+ changed = true;
389
+ }
390
+ for (const update of readTaskUpdates(rec)) {
391
+ const existing = this.todos.get(update.taskId);
392
+ if (update.status === "deleted") {
393
+ if (this.todos.delete(update.taskId))
394
+ changed = true;
395
+ continue;
396
+ }
397
+ this.todos.set(update.taskId, {
398
+ id: update.taskId,
399
+ subject: update.subject ?? existing?.subject ?? "",
400
+ status: normalizeTodoStatus(update.status) ?? existing?.status ?? "pending",
401
+ });
402
+ changed = true;
403
+ }
404
+ if (changed)
405
+ this.sink.onTodos([...this.todos.values()]);
406
+ }
407
+ /** On a `Task` tool_result, replay the sub-agent's own transcript
408
+ * (`subagents/agent-<agentId>.jsonl`) as events tagged with the parent Task
409
+ * tool-use id, so the canonical layer nests them under that call. Fired once
410
+ * per sub-agent (at tool_result time the file is complete — no live race). */
411
+ maybeForwardSubagent(rec) {
412
+ if (!this.transcriptPath)
413
+ return;
414
+ const result = rec.toolUseResult;
415
+ const agentId = isRecord(result) && typeof result.agentId === "string" ? result.agentId : undefined;
416
+ if (!agentId || this.seenSubagents.has(agentId))
417
+ return;
418
+ const parentToolUseId = toolResultCallId(rec);
419
+ if (!parentToolUseId)
420
+ return;
421
+ this.seenSubagents.add(agentId);
422
+ const file = subagentTranscriptPath(this.transcriptPath, agentId);
423
+ // Emit raw (no remap/track): sub-agent messages have their own itemIds and
424
+ // must not consume the parent's MessageDisplay FIFO, and their tool pairs are
425
+ // already complete so they need no open-tool tracking.
426
+ for (const event of readSubagentEvents(file, parentToolUseId))
427
+ this.sink.onEvent(event);
428
+ }
429
+ /** Tail streamed assistant-text chunks (MessageDisplay) into live `token`
430
+ * events. Guarded on an open turn — deltas belong to the turn the user record
431
+ * opened; the offset advances only once processed. */
432
+ pollDeltas() {
433
+ if (!this.turnOpen)
434
+ return;
435
+ const { deltas, nextOffset } = readMessageDeltasFrom(this.bridgeDir, this.deltasOffset);
436
+ this.deltasOffset = nextOffset;
437
+ for (const d of deltas) {
438
+ if (!this.seenMessageIds.has(d.messageId)) {
439
+ this.seenMessageIds.add(d.messageId);
440
+ this.messageIdQueue.push(d.messageId);
441
+ }
442
+ this.sink.onEvent({ type: "token", text: d.delta, metadata: { itemId: d.messageId } });
443
+ }
444
+ if (deltas.length)
445
+ this.lastActivityAt = this.now();
446
+ }
447
+ /** Refresh the latest statusLine context/cost snapshot (a last-writer-wins file
448
+ * the statusLine hook overwrites on every TUI render). */
449
+ pollStatus() {
450
+ const status = readClaudeStatus(this.bridgeDir);
451
+ if (status)
452
+ this.latestStatus = status;
453
+ }
454
+ /** A usage record from the latest statusLine snapshot (snake_case, the keys the
455
+ * normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
456
+ * if the statusLine hook has not fired yet. */
457
+ statusUsage() {
458
+ const s = this.latestStatus;
459
+ if (!s)
460
+ return undefined;
461
+ const usage = {};
462
+ if (s.inputTokens !== undefined)
463
+ usage.input_tokens = s.inputTokens;
464
+ if (s.outputTokens !== undefined)
465
+ usage.output_tokens = s.outputTokens;
466
+ if (s.totalTokens !== undefined)
467
+ usage.total_tokens = s.totalTokens;
468
+ if (s.cacheReadTokens !== undefined)
469
+ usage.cache_read_input_tokens = s.cacheReadTokens;
470
+ if (s.cacheWriteTokens !== undefined)
471
+ usage.cache_creation_input_tokens = s.cacheWriteTokens;
472
+ if (s.contextWindowSize !== undefined)
473
+ usage.context_window = s.contextWindowSize;
474
+ if (s.usedPercentage !== undefined)
475
+ usage.used_percentage = s.usedPercentage;
476
+ if (s.costUsd !== undefined)
477
+ usage.total_cost_usd = s.costUsd;
478
+ return Object.keys(usage).length ? usage : undefined;
479
+ }
480
+ /** Remap an assistant-text `message_completed` onto the FIFO-matched
481
+ * MessageDisplay message_id, so its streamed deltas and this final item share
482
+ * an itemId. No queued delta (MessageDisplay didn't fire) → keep the transcript id. */
483
+ remapMessageItem(event) {
484
+ if (event.type !== "message_completed")
485
+ return event;
486
+ const messageId = this.messageIdQueue.shift();
487
+ return messageId ? { ...event, itemId: messageId } : event;
488
+ }
489
+ trackTool(event) {
490
+ if (event.type !== "tool")
491
+ return;
492
+ if (event.event === "on_tool_start") {
493
+ const id = readId(event.input) ?? readId(event.data);
494
+ if (id)
495
+ this.openToolIds.add(id);
496
+ }
497
+ else if (event.event === "on_tool_end") {
498
+ const data = isRecord(event.data) ? event.data : {};
499
+ const id = readId(event.output) ?? (typeof data.tool_use_id === "string" ? data.tool_use_id : undefined);
500
+ if (id)
501
+ this.openToolIds.delete(id);
502
+ }
503
+ }
504
+ ensureTurn() {
505
+ if (this.turnOpen)
506
+ return;
507
+ this.turnOpen = true;
508
+ this.sink.onTurnStart(this.currentTurnId);
509
+ }
510
+ /** Mirror a local `!` command as its own mini-turn: close any open turn, then
511
+ * open→emit→close so it groups as one response with a stable id (the record
512
+ * uuid) and never lingers "running" (input+output are complete in one record). */
513
+ emitTerminalCommand(rec, cmd) {
514
+ this.closeTurn();
515
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
516
+ this.sink.onTurnStart(turnId);
517
+ this.sink.onTerminalCommand(cmd);
518
+ this.sink.onTurnEnd();
519
+ }
520
+ /** The web Stop button interrupted this session (host sent Escape). claude
521
+ * records the interrupt in its own transcript but may not fire a Stop hook when
522
+ * the response was cancelled before it produced output — leaving the turn open
523
+ * and the UI spinner stuck until the long inactivity backstop. Treat the
524
+ * interrupt like a Stop: surface idle now (clears the spinner) and schedule the
525
+ * turn's close via the same short grace. The Escape has stopped claude, so this
526
+ * does not race a still-running response. */
527
+ noteInterrupted() {
528
+ if (!this.turnOpen)
529
+ return;
530
+ this.sink.onIdle();
531
+ this.stopPendingAt = this.now();
532
+ }
533
+ /** Whether a claude turn is currently open (a response is running / pending).
534
+ * The web Stop button gates its interrupt on this so an idle pane is never
535
+ * sent an Escape — an Escape into idle claude submits an empty turn, which
536
+ * claude answers with a stray "No response requested." bubble. */
537
+ isTurnOpen() {
538
+ return this.turnOpen;
539
+ }
540
+ closeTurn() {
541
+ if (!this.turnOpen)
542
+ return;
543
+ this.turnOpen = false;
544
+ this.currentTurnId = undefined;
545
+ this.openToolIds.clear();
546
+ this.stopPendingAt = null;
547
+ this.sink.onTurnEnd(this.statusUsage());
548
+ }
549
+ closeTurnError(error) {
550
+ if (!this.turnOpen)
551
+ return;
552
+ this.turnOpen = false;
553
+ this.currentTurnId = undefined;
554
+ this.openToolIds.clear();
555
+ this.stopPendingAt = null;
556
+ this.sink.onTurnError(error);
557
+ }
558
+ maybeIdleClose() {
559
+ if (!this.turnOpen || this.openToolIds.size > 0)
560
+ return;
561
+ const now = this.now();
562
+ // Primary close: a short grace after the Stop hook, so a late assistant record
563
+ // flush still joins the turn. Fallback: a long inactivity backstop closes a
564
+ // turn whose Stop never fired (so it can't hang open forever).
565
+ if (this.stopPendingAt !== null) {
566
+ if (now - this.stopPendingAt >= this.stopGraceMs)
567
+ this.closeTurn();
568
+ }
569
+ else if (now - this.lastActivityAt >= this.idleCloseMs) {
570
+ this.closeTurn();
571
+ }
572
+ }
573
+ }
574
+ /** Claude Code renders this glyph once the input box is mounted (ready-gate). */
575
+ const CLAUDE_PROMPT_GLYPH = "❯";
576
+ const DRAFT_NEEDLE_MAX = 24;
577
+ function submitNeedle(text) {
578
+ return (text.split("\n")[0] ?? "").slice(0, DRAFT_NEEDLE_MAX);
579
+ }
580
+ /** Whether the pasted draft is visibly sitting in the input box (the last line
581
+ * carrying the prompt glyph). Mirrors omnigent's `_draft_in_input_box`. */
582
+ function draftInBox(pane, glyph, needle) {
583
+ const glyphLines = pane.split("\n").filter((l) => l.includes(glyph));
584
+ if (glyphLines.length === 0)
585
+ return false;
586
+ const tail = glyphLines[glyphLines.length - 1].split(glyph).pop() ?? "";
587
+ if (tail.includes("[Pasted text"))
588
+ return true; // claude's large-paste placeholder
589
+ return needle.length > 0 && tail.includes(needle);
590
+ }
591
+ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
592
+ const deadline = now() + timeoutMs;
593
+ while (now() < deadline) {
594
+ if (signal?.aborted)
595
+ return false;
596
+ if (pred())
597
+ return true;
598
+ await sleep(pollMs);
599
+ }
600
+ return pred();
601
+ }
602
+ /**
603
+ * Deliver `text` into a claude TUI pane, the omnigent recipe:
604
+ * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
605
+ * newline) → wait for the draft to land → settle → submit Enter → verify the
606
+ * draft left the box (re-send Enter while it hasn't).
607
+ *
608
+ * THROWS if the prompt never appears within the ready-gate window (omnigent
609
+ * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
610
+ * caller reports, NOT a signal to fall through to a second output path. Returns
611
+ * `true` once the message is submitted (best-effort even if submit-verify times out).
612
+ */
613
+ export async function injectViaTerminal(injector, text, opts = {}) {
614
+ const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
615
+ const pollMs = opts.pollMs ?? 150;
616
+ const now = opts.now ?? (() => Date.now());
617
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
618
+ const signal = opts.signal;
619
+ // Cancelled by the web Stop button (interruptLive.abort). Clear the half-typed
620
+ // draft so the message isn't left stuck in the input box, and bail without
621
+ // submitting — the caller (host) also sends Escape for any active response.
622
+ const cancelled = () => {
623
+ if (!signal?.aborted)
624
+ return false;
625
+ injector.clearInputLine();
626
+ return true;
627
+ };
628
+ // 1. Ready-gate. No prompt within the window → THROW (omnigent RAISE): a
629
+ // not-ready pane is a hard error, never a fall-through-to-run signal.
630
+ const ready = await pollUntil(() => injector.capturePane().includes(glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
631
+ if (cancelled())
632
+ return false;
633
+ if (!ready) {
634
+ const tail = injector.capturePane().split("\n").slice(-5).join("\n");
635
+ throw new Error(`claude prompt not ready (no "${glyph}" within timeout)\npane tail:\n${tail}`);
636
+ }
637
+ // 2. Clear leftover, then bracketed-paste the draft (+\n absorbs a trailing "\").
638
+ injector.clearInputLine();
639
+ injector.paste(`${text}\n`);
640
+ // 3. Wait for the draft to visibly land, then a short settle.
641
+ const needle = submitNeedle(text);
642
+ const draftSeen = await pollUntil(() => draftInBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? 5_000, pollMs, now, sleep, signal);
643
+ await sleep(opts.settleMs ?? 100);
644
+ if (cancelled())
645
+ return false; // Stop pressed mid-paste → don't submit
646
+ // 4. Submit.
647
+ injector.sendEnter();
648
+ if (!draftSeen)
649
+ return true; // draft never identifiable → submit blind (omnigent)
650
+ // 5. Verify the submit took; re-send Enter while the draft is still boxed.
651
+ const deadline = now() + (opts.submitVerifyMs ?? 10_000);
652
+ let lastEnter = now();
653
+ while (now() < deadline) {
654
+ if (cancelled())
655
+ return false; // Stop pressed → stop re-submitting, clear the box
656
+ await sleep(pollMs);
657
+ if (!draftInBox(injector.capturePane(), glyph, needle))
658
+ return true;
659
+ if (now() - lastEnter >= (opts.submitRetryMs ?? 1_000)) {
660
+ injector.sendEnter();
661
+ lastEnter = now();
662
+ }
663
+ }
664
+ return true;
665
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};