@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.
- package/dist/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- 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
|
+
}
|