@rynx-ai/runtime 0.1.0 → 0.1.9

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 (53) hide show
  1. package/dist/claude/executor.d.ts +3 -5
  2. package/dist/claude/executor.js +3 -5
  3. package/dist/claude/native-bridge.d.ts +74 -17
  4. package/dist/claude/native-bridge.js +225 -30
  5. package/dist/claude/native-hook-main.js +291 -39
  6. package/dist/claude/native-hooks.d.ts +3 -2
  7. package/dist/claude/native-hooks.js +15 -6
  8. package/dist/claude/native-integration.d.ts +78 -5
  9. package/dist/claude/native-integration.js +417 -26
  10. package/dist/claude/settings.d.ts +8 -0
  11. package/dist/claude/settings.js +50 -0
  12. package/dist/claude/transcript.d.ts +2 -2
  13. package/dist/claude/transcript.js +3 -3
  14. package/dist/codex/rollout-synth.js +1 -1
  15. package/dist/codex-app-server/client.d.ts +26 -40
  16. package/dist/codex-app-server/client.js +1128 -99
  17. package/dist/codex-app-server/forwarder.d.ts +7 -7
  18. package/dist/codex-app-server/forwarder.js +11 -5
  19. package/dist/codex-app-server/mapping.d.ts +1 -1
  20. package/dist/codex-app-server/mapping.js +27 -2
  21. package/dist/codex-app-server/protocol.d.ts +238 -4
  22. package/dist/codex-app-server/transport.d.ts +20 -5
  23. package/dist/codex-app-server/transport.js +93 -40
  24. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  25. package/dist/codex-app-server/ws-channel.js +23 -7
  26. package/dist/codex-child-env.js +33 -0
  27. package/dist/codex-home.d.ts +6 -6
  28. package/dist/codex-home.js +8 -9
  29. package/dist/codex-session-store.d.ts +2 -1
  30. package/dist/host.d.ts +34 -33
  31. package/dist/host.js +531 -91
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.js +1 -1
  34. package/dist/interactions.d.ts +61 -0
  35. package/dist/interactions.js +236 -0
  36. package/dist/models-catalog.d.ts +1 -1
  37. package/dist/models-catalog.js +1 -1
  38. package/dist/runner/child.d.ts +9 -1
  39. package/dist/runner/child.js +93 -15
  40. package/dist/runner/manager.d.ts +59 -10
  41. package/dist/runner/manager.js +385 -41
  42. package/dist/runner/protocol.d.ts +18 -7
  43. package/dist/runner-main.js +9 -6
  44. package/dist/runtime-status.js +1 -1
  45. package/dist/terminal/claude-tui.d.ts +8 -3
  46. package/dist/terminal/claude-tui.js +6 -2
  47. package/dist/terminal/codex-tui.d.ts +3 -3
  48. package/dist/terminal/codex-tui.js +1 -1
  49. package/dist/terminal/registry.d.ts +1 -1
  50. package/dist/terminal/registry.js +1 -1
  51. package/dist/terminal/tmux.d.ts +6 -6
  52. package/dist/terminal/tmux.js +10 -10
  53. package/package.json +3 -3
@@ -1,20 +1,9 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * Standalone Claude Code hook entrypoint (built to `dist/claude/native-hook-main.js`,
4
- * invoked as `node native-hook-main.js --bridge-dir <dir> [permission-request]`).
5
- * Claude spawns it per hook event; it reads the hook payload on stdin and:
6
- * - default (observer: SessionStart / Stop / StopFailure) → append it to the
7
- * session's `hooks.jsonl` for the forwarder to tail.
8
- * - `permission-request` → POST the payload to the daemon and RELAY the
9
- * server's verdict JSON to stdout (the `{hookSpecificOutput:{decision:{…}}}`
10
- * Claude reads to allow/deny). Blocks until the web user answers (Claude's
11
- * hook `timeout` is a day). On any failure it writes nothing → Claude falls
12
- * back to its own TUI permission prompt.
13
- *
14
- * Dependency-light (native-bridge + node builtins) so the per-event subprocess
15
- * stays cheap. Never exits non-zero — a crashing hook must not wedge Claude.
16
- */
17
- import { readPermissionHookConfig, recordHookEvent } from "./native-bridge.js";
2
+ /** Standalone Claude hook adapter. Observer hooks append framing events; blocking
3
+ * interaction hooks rendezvous with the runner through the session bridge. */
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { claimInteractionResult, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
6
+ import { boundInteractionRequest, redactInteractionResolution, } from "../interactions.js";
18
7
  function argValue(argv, flag) {
19
8
  const i = argv.indexOf(flag);
20
9
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
@@ -25,29 +14,243 @@ async function readStdin() {
25
14
  chunks.push(chunk);
26
15
  return Buffer.concat(chunks).toString("utf8");
27
16
  }
28
- async function relayPermission(bridgeDir, payload) {
29
- const cfg = readPermissionHookConfig(bridgeDir);
30
- if (!cfg)
31
- return; // no server configured → empty stdout → claude's own TUI prompt
32
- const url = `${cfg.serverUrl.replace(/\/+$/, "")}/api/sessions/${encodeURIComponent(cfg.sessionId)}/hooks/permission-request`;
33
- try {
34
- const resp = await fetch(url, {
35
- method: "POST",
36
- headers: { "content-type": "application/json" },
37
- body: JSON.stringify(payload),
38
- });
39
- const text = await resp.text();
40
- if (resp.ok && text) {
41
- await new Promise((resolve) => process.stdout.write(text, () => resolve()));
17
+ function asRecord(value) {
18
+ return value && typeof value === "object" && !Array.isArray(value)
19
+ ? value
20
+ : undefined;
21
+ }
22
+ function asString(value) {
23
+ return typeof value === "string" && value ? value : undefined;
24
+ }
25
+ function interactionId(payload) {
26
+ // PermissionRequest does not carry a tool_use_id. A per-process nonce keeps
27
+ // repeated or concurrent identical prompts from collapsing into one bridge id.
28
+ const invocationId = asString(payload.tool_use_id) ?? randomUUID();
29
+ const identity = JSON.stringify([
30
+ payload.session_id,
31
+ payload.transcript_path,
32
+ payload.hook_event_name,
33
+ invocationId,
34
+ payload.tool_name,
35
+ payload.tool_input,
36
+ ]);
37
+ return `claude_${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
38
+ }
39
+ function questionRequest(id, toolInput) {
40
+ const questions = Array.isArray(toolInput.questions)
41
+ ? toolInput.questions.map(asRecord).filter((question) => Boolean(question))
42
+ : [];
43
+ const fields = questions.map((question, index) => {
44
+ const options = Array.isArray(question.options)
45
+ ? question.options.map(asRecord).filter((option) => Boolean(option))
46
+ : [];
47
+ const label = asString(question.question) ?? `Question ${index + 1}`;
48
+ const description = asString(question.header);
49
+ if (options.length > 0) {
50
+ return {
51
+ id: `q${index}`,
52
+ type: "select",
53
+ label,
54
+ ...(description ? { description } : {}),
55
+ required: true,
56
+ multiple: question.multiSelect === true,
57
+ allowOther: true,
58
+ options: options.map((option) => {
59
+ const value = asString(option.label) ?? "";
60
+ const optionDescription = asString(option.description);
61
+ return {
62
+ value,
63
+ label: value,
64
+ ...(optionDescription ? { description: optionDescription } : {}),
65
+ };
66
+ }),
67
+ };
68
+ }
69
+ return {
70
+ id: `q${index}`,
71
+ type: "text",
72
+ label,
73
+ ...(description ? { description } : {}),
74
+ required: true,
75
+ };
76
+ });
77
+ return {
78
+ interactionId: id,
79
+ kind: "question",
80
+ title: questions.length === 1
81
+ ? asString(questions[0]?.header) ?? "Input required"
82
+ : "Input required",
83
+ fields,
84
+ actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
85
+ context: { toolName: "AskUserQuestion" },
86
+ createdAt: Date.now(),
87
+ };
88
+ }
89
+ function permissionSuggestions(payload) {
90
+ return Array.isArray(payload.permission_suggestions)
91
+ ? payload.permission_suggestions
92
+ .map(asRecord)
93
+ .filter((suggestion) => Boolean(suggestion))
94
+ : [];
95
+ }
96
+ function permissionSuggestionLabel(suggestion, index, total) {
97
+ const rules = Array.isArray(suggestion.rules)
98
+ ? suggestion.rules.map(asRecord).filter((rule) => Boolean(rule))
99
+ : [];
100
+ const firstRule = rules[0];
101
+ const toolName = asString(firstRule?.toolName);
102
+ const ruleContent = asString(firstRule?.ruleContent);
103
+ const directories = Array.isArray(suggestion.directories)
104
+ ? suggestion.directories.filter((value) => typeof value === "string" && value.length > 0)
105
+ : [];
106
+ const mode = asString(suggestion.mode);
107
+ let target;
108
+ if (toolName === "Read")
109
+ target = ruleContent ? `reading from ${ruleContent}` : "reading files";
110
+ else if (toolName === "Write")
111
+ target = ruleContent ? `writing to ${ruleContent}` : "writing files";
112
+ else if (toolName === "Edit")
113
+ target = ruleContent ? `editing ${ruleContent}` : "editing files";
114
+ else if (toolName === "Bash")
115
+ target = ruleContent ? `running ${ruleContent}` : "running Bash commands";
116
+ else if (toolName)
117
+ target = ruleContent ? `using ${toolName}(${ruleContent})` : `using ${toolName}`;
118
+ else if (directories.length > 0)
119
+ target = `accessing ${directories[0]}`;
120
+ else if (mode)
121
+ target = `using ${mode} permission mode`;
122
+ else
123
+ target = "this permission";
124
+ const extraCount = Math.max(0, rules.length - 1) + Math.max(0, directories.length - 1);
125
+ const suffix = extraCount > 0 ? ` and ${extraCount} more` : "";
126
+ const compactTarget = [...`${target}${suffix}`].length > 120
127
+ ? `${[...`${target}${suffix}`].slice(0, 119).join("")}…`
128
+ : `${target}${suffix}`;
129
+ const destination = asString(suggestion.destination);
130
+ const scope = destination === "session" ? " in this session" : "";
131
+ const label = `Allow, and don’t ask again for ${compactTarget}${scope}`;
132
+ return total > 1 ? `${label} (${index + 1})` : label;
133
+ }
134
+ function permissionSuggestionActionId(index) {
135
+ return `allow_suggestion_${index}`;
136
+ }
137
+ function permissionRequest(id, payload, toolInput) {
138
+ const toolName = asString(payload.tool_name) ?? "tool";
139
+ const command = asString(toolInput.command) ?? asString(toolInput.file_path);
140
+ const cwd = asString(payload.cwd);
141
+ const suggestions = permissionSuggestions(payload);
142
+ return {
143
+ interactionId: id,
144
+ kind: "permission",
145
+ title: `Allow ${toolName}?`,
146
+ fields: [],
147
+ actions: [
148
+ { id: "allow", label: "Allow once", style: "primary", requiresAnswers: false },
149
+ ...suggestions.map((suggestion, index) => ({
150
+ id: permissionSuggestionActionId(index),
151
+ label: permissionSuggestionLabel(suggestion, index, suggestions.length),
152
+ requiresAnswers: false,
153
+ })),
154
+ { id: "deny", label: "Deny", style: "danger", requiresAnswers: false },
155
+ ],
156
+ context: {
157
+ toolName,
158
+ ...(command ? { command } : {}),
159
+ ...(cwd ? { cwd } : {}),
160
+ },
161
+ createdAt: Date.now(),
162
+ };
163
+ }
164
+ async function waitForResult(bridgeDir, id) {
165
+ let lastLeaseAt = 0;
166
+ for (;;) {
167
+ const now = Date.now();
168
+ if (now - lastLeaseAt >= 1_000) {
169
+ touchInteractionLease(bridgeDir, id);
170
+ lastLeaseAt = now;
42
171
  }
172
+ const result = claimInteractionResult(bridgeDir, id);
173
+ if (result)
174
+ return result;
175
+ await new Promise((resolve) => setTimeout(resolve, 100));
43
176
  }
44
- catch {
45
- // Server unreachable / hung → empty stdout → claude falls back to its TUI prompt.
177
+ }
178
+ function answerText(value) {
179
+ return Array.isArray(value) ? value.join(", ") : value ?? "";
180
+ }
181
+ function nativeVerdict(hookKind, payload, result) {
182
+ const toolInput = asRecord(payload.tool_input) ?? {};
183
+ const toolName = asString(payload.tool_name) ?? "tool";
184
+ if (result.status === "cancelled") {
185
+ const reason = result.reason ?? "Interaction cancelled";
186
+ return hookKind === "pre_tool_use"
187
+ ? {
188
+ hookSpecificOutput: {
189
+ hookEventName: "PreToolUse",
190
+ permissionDecision: "deny",
191
+ permissionDecisionReason: reason,
192
+ },
193
+ }
194
+ : {
195
+ hookSpecificOutput: {
196
+ hookEventName: "PermissionRequest",
197
+ decision: { behavior: "deny", message: reason },
198
+ },
199
+ };
200
+ }
201
+ const resolution = result.resolution;
202
+ if (toolName === "AskUserQuestion") {
203
+ const questions = Array.isArray(toolInput.questions)
204
+ ? toolInput.questions.map(asRecord).filter((question) => Boolean(question))
205
+ : [];
206
+ const answers = {};
207
+ questions.forEach((question, index) => {
208
+ const text = asString(question.question) ?? `Question ${index + 1}`;
209
+ answers[text] = answerText(resolution.answers?.[`q${index}`]);
210
+ });
211
+ const updatedInput = { ...toolInput, answers };
212
+ return hookKind === "pre_tool_use"
213
+ ? {
214
+ hookSpecificOutput: {
215
+ hookEventName: "PreToolUse",
216
+ permissionDecision: "allow",
217
+ updatedInput,
218
+ },
219
+ }
220
+ : {
221
+ hookSpecificOutput: {
222
+ hookEventName: "PermissionRequest",
223
+ decision: { behavior: "allow", updatedInput },
224
+ },
225
+ };
46
226
  }
227
+ const suggestions = permissionSuggestions(payload);
228
+ const suggestionMatch = /^allow_suggestion_(\d+)$/.exec(resolution.actionId);
229
+ const suggestionIndex = suggestionMatch ? Number(suggestionMatch[1]) : -1;
230
+ const selectedSuggestion = Number.isSafeInteger(suggestionIndex)
231
+ ? suggestions[suggestionIndex]
232
+ : undefined;
233
+ return {
234
+ hookSpecificOutput: {
235
+ hookEventName: "PermissionRequest",
236
+ decision: {
237
+ behavior: resolution.actionId === "allow" || selectedSuggestion ? "allow" : "deny",
238
+ ...(selectedSuggestion ? { updatedPermissions: [selectedSuggestion] } : {}),
239
+ },
240
+ },
241
+ };
242
+ }
243
+ async function writeStdout(value) {
244
+ await new Promise((resolve) => process.stdout.write(JSON.stringify(value), () => resolve()));
47
245
  }
48
246
  async function main() {
49
247
  const argv = process.argv.slice(2);
50
- const isPermission = argv[0] === "permission-request";
248
+ const mode = argv[0];
249
+ const hookKind = mode === "permission-request"
250
+ ? "permission"
251
+ : mode === "ask-user-question"
252
+ ? "pre_tool_use"
253
+ : undefined;
51
254
  const bridgeDir = argValue(argv, "--bridge-dir");
52
255
  if (!bridgeDir)
53
256
  return;
@@ -55,20 +258,69 @@ async function main() {
55
258
  let payload;
56
259
  try {
57
260
  const parsed = JSON.parse(raw || "{}");
58
- payload = parsed && typeof parsed === "object" ? parsed : {};
261
+ payload = asRecord(parsed) ?? {};
59
262
  }
60
263
  catch {
61
- return; // malformed hook payload — don't block claude
264
+ return;
62
265
  }
63
- if (isPermission) {
64
- await relayPermission(bridgeDir, payload);
266
+ if (!hookKind) {
267
+ try {
268
+ recordHookEvent(bridgeDir, payload);
269
+ }
270
+ catch {
271
+ // Observer hooks are best effort.
272
+ }
65
273
  return;
66
274
  }
275
+ const id = interactionId(payload);
276
+ const toolInput = asRecord(payload.tool_input) ?? {};
277
+ const request = asString(payload.tool_name) === "AskUserQuestion"
278
+ ? questionRequest(id, toolInput)
279
+ : permissionRequest(id, payload, toolInput);
67
280
  try {
68
- recordHookEvent(bridgeDir, payload);
281
+ const bounded = boundInteractionRequest(request);
282
+ if (!bounded.ok) {
283
+ await writeStdout(nativeVerdict(hookKind, payload, {
284
+ status: "cancelled",
285
+ reason: bounded.reason,
286
+ }));
287
+ return;
288
+ }
289
+ touchInteractionLease(bridgeDir, id);
290
+ try {
291
+ recordInteractionRequest(bridgeDir, {
292
+ interactionId: id,
293
+ hookKind,
294
+ waiterPid: process.pid,
295
+ request: bounded.request,
296
+ });
297
+ const result = await waitForResult(bridgeDir, id);
298
+ const verdict = nativeVerdict(hookKind, payload, result);
299
+ // Claiming the response is the interaction commit point: it records what
300
+ // the user answered, while the later tool/Turn events record whether Claude
301
+ // could act on it. ACK before stdout is required because Claude may reap a
302
+ // command hook immediately after consuming its verdict.
303
+ recordInteractionAck(bridgeDir, result.status === "resolved"
304
+ ? {
305
+ interactionId: id,
306
+ status: "resolved",
307
+ resolution: redactInteractionResolution(bounded.request, result.resolution),
308
+ }
309
+ : {
310
+ interactionId: id,
311
+ status: "cancelled",
312
+ ...(result.reason ? { reason: result.reason } : {}),
313
+ });
314
+ removeClaimedInteractionResult(bridgeDir, id);
315
+ await writeStdout(verdict);
316
+ }
317
+ finally {
318
+ removeInteractionLease(bridgeDir, id);
319
+ removeClaimedInteractionResult(bridgeDir, id);
320
+ }
69
321
  }
70
322
  catch {
71
- // Best-effort: a failed append must not fail the hook (would wedge claude).
323
+ // Empty stdout lets Claude fall back to its native UI instead of wedging.
72
324
  }
73
325
  }
74
326
  void main();
@@ -16,8 +16,9 @@ export interface ClaudeHookSettings {
16
16
  export interface BuildClaudeHookSettingsOptions {
17
17
  /** The session's bridge dir (all hook subprocesses target it). */
18
18
  bridgeDir: string;
19
- /** Register the blocking PermissionRequest hook (approvals web). */
20
- permission?: boolean;
19
+ /** Claude permission mode. bypassPermissions needs a PreToolUse question hook
20
+ * because PermissionRequest is intentionally skipped by Claude. */
21
+ permissionMode?: string;
21
22
  /** Register the MessageDisplay hook (live assistant-text streaming). */
22
23
  messageDisplay?: boolean;
23
24
  /** Register the statusLine command (context_window + cost capture). */
@@ -2,14 +2,14 @@
2
2
  * Build the Claude Code `--settings` JSON that registers rynx's claude-native
3
3
  * hooks. Each hook is a `command` that runs a standalone node entrypoint
4
4
  * ({@link ./native-hook-main.ts}) against the session's bridge dir; the
5
- * subprocess appends the hook payload to `hooks.jsonl` (observer) or POSTs to
6
- * the daemon and relays the verdict (PermissionRequest).
5
+ * subprocess appends observer events or blocks on the bridge's generic
6
+ * interaction result. It never calls or depends on the Control Plane.
7
7
  *
8
- * Registered here (the core turn-framing + approval subset; mirrors omnigent's
8
+ * Registered here (the core turn-framing + approval subset; mirrors reference implementation's
9
9
  * `build_hook_settings`):
10
10
  * - `SessionStart` → discovery (transcript_path + claude session id)
11
11
  * - `Stop` / `StopFailure` → turn close (idle / failed)
12
- * - `PermissionRequest` → approvals (only when a serverUrl is provided)
12
+ * - `PermissionRequest` → questions and approvals
13
13
  *
14
14
  * Turn OPEN is transcript-driven (the `role:user` record), so `UserPromptSubmit`
15
15
  * is intentionally NOT registered. `MessageDisplay` (streaming) and `statusLine`
@@ -18,7 +18,7 @@
18
18
  import { fileURLToPath } from "node:url";
19
19
  /** Claude waits this long (seconds) for the PermissionRequest hook's verdict — a
20
20
  * full day is effectively wait-forever for an interactive approval, matching
21
- * omnigent (its default ~60s command-hook timeout would kill the long-poll). */
21
+ * reference implementation (its default ~60s command-hook timeout would kill the long-poll). */
22
22
  const PERMISSION_HOOK_TIMEOUT_S = 86_400;
23
23
  /** Absolute path to the built standalone hook entrypoint (sibling `.js`). */
24
24
  export function resolveHookEntryPath() {
@@ -49,7 +49,16 @@ export function buildClaudeHookSettings(options) {
49
49
  Stop: [{ hooks: [observerHook] }],
50
50
  StopFailure: [{ hooks: [observerHook] }],
51
51
  };
52
- if (options.permission) {
52
+ if (options.permissionMode === "bypassPermissions") {
53
+ const ask = shJoin([node, entry, "ask-user-question", "--bridge-dir", options.bridgeDir]);
54
+ hooks.PreToolUse = [
55
+ {
56
+ matcher: "AskUserQuestion",
57
+ hooks: [{ type: "command", command: ask, timeout: PERMISSION_HOOK_TIMEOUT_S }],
58
+ },
59
+ ];
60
+ }
61
+ else {
53
62
  const permission = shJoin([node, entry, "permission-request", "--bridge-dir", options.bridgeDir]);
54
63
  hooks.PermissionRequest = [
55
64
  { hooks: [{ type: "command", command: permission, timeout: PERMISSION_HOOK_TIMEOUT_S }] },
@@ -1,4 +1,5 @@
1
- import type { AgentEvent, TerminalCommandData, TodoItem } from "@rynx-ai/core";
1
+ import type { AgentEvent, SessionInteractionResolution, TerminalCommandData, TodoItem } from "@rynx-ai/core";
2
+ import type { ResolveInteractionResult, RuntimeInteractionEvent } from "../interactions.js";
2
3
  /** Mirror sink — the same shape as `CodexForwarderSink` so the host reuses one
3
4
  * per-turn normalizer wiring for both runtimes. */
4
5
  export interface ClaudeForwarderSink {
@@ -14,6 +15,9 @@ export interface ClaudeForwarderSink {
14
15
  onTodos(todos: TodoItem[]): void;
15
16
  /** One mapped event within the current turn. */
16
17
  onEvent(event: AgentEvent): void;
18
+ /** A provider-neutral question/permission requested or settled while this
19
+ * Turn remains active. */
20
+ onInteraction(event: RuntimeInteractionEvent): void;
17
21
  /** The current turn finished (a new user prompt, or the inactivity backstop).
18
22
  * `usage` carries the latest statusLine context/cost snapshot, when captured. */
19
23
  onTurnEnd(usage?: Record<string, unknown>): void;
@@ -51,6 +55,10 @@ export interface ClaudeLiveSessionOptions {
51
55
  * flush still join the turn (Stop and the flush race). */
52
56
  stopGraceMs?: number;
53
57
  now?: () => number;
58
+ /** Process-liveness probe for the blocking native hook (test override). */
59
+ isProcessAlive?: (pid: number) => boolean;
60
+ /** Native lease timestamp reader (test override). */
61
+ interactionLeaseUpdatedAt?: (interactionId: string) => number | undefined;
54
62
  }
55
63
  export declare class ClaudeLiveSession {
56
64
  private readonly bridgeDir;
@@ -59,9 +67,13 @@ export declare class ClaudeLiveSession {
59
67
  private readonly idleCloseMs;
60
68
  private readonly stopGraceMs;
61
69
  private readonly now;
70
+ private readonly isProcessAlive;
71
+ private readonly leaseUpdatedAt;
62
72
  private started;
63
73
  private stopped;
64
74
  private hooksOffset;
75
+ private interactionsOffset;
76
+ private interactionAcksOffset;
65
77
  private transcriptOffset;
66
78
  /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
67
79
  * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
@@ -76,11 +88,29 @@ export declare class ClaudeLiveSession {
76
88
  private readonly seenClaudeSessionIds;
77
89
  private turnOpen;
78
90
  private currentTurnId?;
91
+ /** The Turn opened from a pre-transcript interaction. Its unique provisional
92
+ * id keeps responses distinct until the real prompt uuid can be adopted. */
93
+ private syntheticTurn;
79
94
  private lastActivityAt;
80
95
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
81
96
  private stopPendingAt;
82
97
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
83
98
  private readonly openToolIds;
99
+ /** Blocking native questions/permissions. Their hook subprocess is still
100
+ * executing, so the Turn remains running and cannot be idle-closed. */
101
+ private readonly pendingInteractions;
102
+ private readonly settledInteractions;
103
+ /** Claimed response files may still contain the provider-bound, unredacted
104
+ * answer. Keep tracking them after canonical settlement until ACK/hook cleanup. */
105
+ private readonly claimedInteractions;
106
+ private readonly scheduledClaimScrubs;
107
+ /** Stop is flushed after interactions in the same poll, preventing a
108
+ * request+Stop race from briefly reporting this executing Turn as idle. */
109
+ private stopSignalPending;
110
+ /** StopFailure is likewise flushed after interactions. A blocking hook can
111
+ * append its request immediately before the failure hook lands; closing here
112
+ * would let the later interaction poll reopen an already-failed Turn. */
113
+ private turnFailurePending;
84
114
  /** Sub-agent (Task) ids already forwarded — a Task tool_result is processed once. */
85
115
  private readonly seenSubagents;
86
116
  /** The agent's task list, keyed by task id in creation order (claude
@@ -97,7 +127,7 @@ export declare class ClaudeLiveSession {
97
127
  private readonly seenMessageIds;
98
128
  constructor(opts: ClaudeLiveSessionOptions);
99
129
  /**
100
- * Restore the durable forwarder cursor for `transcriptPath` (omnigent's
130
+ * Restore the durable forwarder cursor for `transcriptPath` (reference implementation's
101
131
  * `_validated_transcript_state`). A cursor for a DIFFERENT file is ignored (a new
102
132
  * session starts at 0). A matching cursor whose fingerprint still validates
103
133
  * resumes at its `byteOffset`; a MISMATCH (the file was truncated/replaced) skips
@@ -111,12 +141,22 @@ export declare class ClaudeLiveSession {
111
141
  isReady(): boolean;
112
142
  start(): void;
113
143
  stop(): void;
144
+ /** Phase two of runner shutdown. The caller must stop the Claude terminal
145
+ * first so no hook can still be opening an atomically claimed answer. This is
146
+ * synchronous because the runner process exits immediately afterwards. */
147
+ finalizeStop(): void;
114
148
  /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
115
149
  * drive deterministically; the async {@link loop} just calls it on an interval. */
116
150
  tick(): void;
117
151
  private loop;
118
152
  private pollHooks;
119
153
  private handleHook;
154
+ /** Process a provider failure only after this tick has discovered every
155
+ * blocking request already appended by its hook subprocess. */
156
+ private flushTurnFailure;
157
+ /** Emit the Stop idle signal only after this tick has discovered native
158
+ * interactions. A pending question/permission is active execution, not idle. */
159
+ private flushStopSignal;
120
160
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
121
161
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
122
162
  * into an unseen id with a forkedFrom marker. Any other new-transcript
@@ -127,6 +167,39 @@ export declare class ClaudeLiveSession {
127
167
  * rotation, so their cursors are NOT reset). */
128
168
  private repointTranscript;
129
169
  private pollTranscript;
170
+ /** Tail blocking hook requests after transcript records, so an interaction
171
+ * emitted in the same poll attaches to the user Turn that caused it. */
172
+ private pollInteractions;
173
+ private drainInteractionCommits;
174
+ /** A hook acknowledgement is the commit point: it is appended after the hook
175
+ * consumes the one-shot response and before it returns the native verdict. */
176
+ private pollInteractionAcks;
177
+ /** A claimed response is already consumed by the native hook. This fallback
178
+ * converges the interaction even if the hook exits before appending its
179
+ * redacted ACK. */
180
+ private pollInteractionClaims;
181
+ /** A request cannot remain actionable after its blocking hook exits. A short
182
+ * post-submit timeout also converges requests created by older hooks that did
183
+ * not publish a waiter pid, and scrubs any unacknowledged secret response. */
184
+ private pollAbandonedInteractions;
185
+ /** Deliver an answer to the blocked hook. The bridge file contains the real
186
+ * answer; only the canonical resolved event receives a secret-redacted copy. */
187
+ resolveInteraction(interactionId: string, resolution: SessionInteractionResolution): ResolveInteractionResult;
188
+ private cancelPendingInteractions;
189
+ /** Terminal cancellation may race the hook's atomic result claim. A claim
190
+ * always wins; otherwise the cancellation file becomes the hook's verdict. */
191
+ private cancelOrSettlePendingInteraction;
192
+ private settleClaimedInteraction;
193
+ /** A claim is the hook's private handoff file and can contain secret answers.
194
+ * ACK/finally normally removes it; process death or an expired lease is the
195
+ * crash fallback after the canonical interaction has already settled. */
196
+ private pollClaimedInteractionCleanup;
197
+ /** Once this forwarder stops it can no longer poll a later hook crash. Keep a
198
+ * short unref'd scrub deadline so unredacted claim material cannot persist for
199
+ * the lifetime of the daemon. */
200
+ private scheduleClaimedInteractionScrubs;
201
+ private settleCancelledInteraction;
202
+ private rememberSettled;
130
203
  private handleRecord;
131
204
  /** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
132
205
  * record into the task list; on any change emit the whole list as a snapshot. */
@@ -182,7 +255,7 @@ export interface TerminalInjector {
182
255
  clearInputLine(): void;
183
256
  paste(text: string): void;
184
257
  sendEnter(): void;
185
- /** Interrupt the running turn (Escape) — the web Stop button (omnigent). */
258
+ /** Interrupt the running turn (Escape) — the web Stop button (reference implementation). */
186
259
  interrupt(): void;
187
260
  }
188
261
  export interface InjectViaTerminalOptions {
@@ -200,12 +273,12 @@ export interface InjectViaTerminalOptions {
200
273
  signal?: AbortSignal;
201
274
  }
202
275
  /**
203
- * Deliver `text` into a claude TUI pane, the omnigent recipe:
276
+ * Deliver `text` into a claude TUI pane, the reference implementation recipe:
204
277
  * ready-gate (poll for `❯`) → clear leftover → bracketed paste (+ trailing
205
278
  * newline) → wait for the draft to land → settle → submit Enter → verify the
206
279
  * draft left the box (re-send Enter while it hasn't).
207
280
  *
208
- * THROWS if the prompt never appears within the ready-gate window (omnigent
281
+ * THROWS if the prompt never appears within the ready-gate window (reference implementation
209
282
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
210
283
  * caller reports, NOT a signal to fall through to a second output path. Returns
211
284
  * `true` once the message is submitted (best-effort even if submit-verify times out).