@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.
- package/dist/claude/executor.d.ts +3 -5
- package/dist/claude/executor.js +3 -5
- package/dist/claude/native-bridge.d.ts +74 -17
- package/dist/claude/native-bridge.js +225 -30
- package/dist/claude/native-hook-main.js +291 -39
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +78 -5
- package/dist/claude/native-integration.js +417 -26
- package/dist/claude/settings.d.ts +8 -0
- package/dist/claude/settings.js +50 -0
- package/dist/claude/transcript.d.ts +2 -2
- package/dist/claude/transcript.js +3 -3
- package/dist/codex/rollout-synth.js +1 -1
- package/dist/codex-app-server/client.d.ts +26 -40
- package/dist/codex-app-server/client.js +1128 -99
- package/dist/codex-app-server/forwarder.d.ts +7 -7
- package/dist/codex-app-server/forwarder.js +11 -5
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +27 -2
- package/dist/codex-app-server/protocol.d.ts +238 -4
- package/dist/codex-app-server/transport.d.ts +20 -5
- package/dist/codex-app-server/transport.js +93 -40
- package/dist/codex-app-server/ws-channel.d.ts +3 -3
- package/dist/codex-app-server/ws-channel.js +23 -7
- package/dist/codex-child-env.js +33 -0
- package/dist/codex-home.d.ts +6 -6
- package/dist/codex-home.js +8 -9
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +34 -33
- package/dist/host.js +531 -91
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +1 -1
- package/dist/models-catalog.js +1 -1
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +93 -15
- package/dist/runner/manager.d.ts +59 -10
- package/dist/runner/manager.js +385 -41
- package/dist/runner/protocol.d.ts +18 -7
- package/dist/runner-main.js +9 -6
- package/dist/runtime-status.js +1 -1
- package/dist/terminal/claude-tui.d.ts +8 -3
- package/dist/terminal/claude-tui.js +6 -2
- package/dist/terminal/codex-tui.d.ts +3 -3
- package/dist/terminal/codex-tui.js +1 -1
- package/dist/terminal/registry.d.ts +1 -1
- package/dist/terminal/registry.js +1 -1
- package/dist/terminal/tmux.d.ts +6 -6
- package/dist/terminal/tmux.js +10 -10
- package/package.json +3 -3
|
@@ -1,20 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
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
|
|
261
|
+
payload = asRecord(parsed) ?? {};
|
|
59
262
|
}
|
|
60
263
|
catch {
|
|
61
|
-
return;
|
|
264
|
+
return;
|
|
62
265
|
}
|
|
63
|
-
if (
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
|
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` →
|
|
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
|
-
*
|
|
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.
|
|
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` (
|
|
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 (
|
|
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
|
|
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 (
|
|
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).
|