@rynx-ai/runtime 0.1.0 → 0.1.10-beta.2
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 +327 -38
- package/dist/claude/native-hooks.d.ts +3 -2
- package/dist/claude/native-hooks.js +15 -6
- package/dist/claude/native-integration.d.ts +123 -16
- package/dist/claude/native-integration.js +624 -81
- 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 +14 -3
- package/dist/codex/rollout-synth.d.ts +8 -3
- package/dist/codex/rollout-synth.js +65 -32
- package/dist/codex-app-server/client.d.ts +27 -40
- package/dist/codex-app-server/client.js +1134 -99
- package/dist/codex-app-server/forwarder.d.ts +36 -10
- package/dist/codex-app-server/forwarder.js +146 -28
- package/dist/codex-app-server/mapping.d.ts +1 -1
- package/dist/codex-app-server/mapping.js +64 -5
- package/dist/codex-app-server/protocol.d.ts +269 -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 +16 -6
- package/dist/codex-home.js +46 -15
- package/dist/codex-session-store.d.ts +2 -1
- package/dist/host.d.ts +38 -38
- package/dist/host.js +626 -121
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/input-resources.d.ts +13 -0
- package/dist/input-resources.js +67 -0
- package/dist/interactions.d.ts +61 -0
- package/dist/interactions.js +236 -0
- package/dist/models-catalog.d.ts +5 -13
- package/dist/models-catalog.js +60 -9
- package/dist/runner/child.d.ts +9 -1
- package/dist/runner/child.js +100 -19
- package/dist/runner/manager.d.ts +79 -11
- package/dist/runner/manager.js +423 -43
- package/dist/runner/protocol.d.ts +30 -11
- 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 +8 -3
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 { realpathSync } from "node:fs";
|
|
6
|
+
import { claimInteractionResult, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
|
|
7
|
+
import { boundInteractionRequest, redactInteractionResolution, } from "../interactions.js";
|
|
18
8
|
function argValue(argv, flag) {
|
|
19
9
|
const i = argv.indexOf(flag);
|
|
20
10
|
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
@@ -25,29 +15,278 @@ async function readStdin() {
|
|
|
25
15
|
chunks.push(chunk);
|
|
26
16
|
return Buffer.concat(chunks).toString("utf8");
|
|
27
17
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
18
|
+
function asRecord(value) {
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? value
|
|
21
|
+
: undefined;
|
|
22
|
+
}
|
|
23
|
+
function asString(value) {
|
|
24
|
+
return typeof value === "string" && value ? value : undefined;
|
|
25
|
+
}
|
|
26
|
+
function interactionId(payload) {
|
|
27
|
+
// PermissionRequest does not carry a tool_use_id. A per-process nonce keeps
|
|
28
|
+
// repeated or concurrent identical prompts from collapsing into one bridge id.
|
|
29
|
+
const invocationId = asString(payload.tool_use_id) ?? randomUUID();
|
|
30
|
+
const identity = JSON.stringify([
|
|
31
|
+
payload.session_id,
|
|
32
|
+
payload.transcript_path,
|
|
33
|
+
payload.hook_event_name,
|
|
34
|
+
invocationId,
|
|
35
|
+
payload.tool_name,
|
|
36
|
+
payload.tool_input,
|
|
37
|
+
]);
|
|
38
|
+
return `claude_${createHash("sha256").update(identity).digest("hex").slice(0, 32)}`;
|
|
39
|
+
}
|
|
40
|
+
function questionRequest(id, toolInput) {
|
|
41
|
+
const questions = Array.isArray(toolInput.questions)
|
|
42
|
+
? toolInput.questions.map(asRecord).filter((question) => Boolean(question))
|
|
43
|
+
: [];
|
|
44
|
+
const fields = questions.map((question, index) => {
|
|
45
|
+
const options = Array.isArray(question.options)
|
|
46
|
+
? question.options.map(asRecord).filter((option) => Boolean(option))
|
|
47
|
+
: [];
|
|
48
|
+
const label = asString(question.question) ?? `Question ${index + 1}`;
|
|
49
|
+
const description = asString(question.header);
|
|
50
|
+
if (options.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
id: `q${index}`,
|
|
53
|
+
type: "select",
|
|
54
|
+
label,
|
|
55
|
+
...(description ? { description } : {}),
|
|
56
|
+
required: true,
|
|
57
|
+
multiple: question.multiSelect === true,
|
|
58
|
+
allowOther: true,
|
|
59
|
+
options: options.map((option) => {
|
|
60
|
+
const value = asString(option.label) ?? "";
|
|
61
|
+
const optionDescription = asString(option.description);
|
|
62
|
+
return {
|
|
63
|
+
value,
|
|
64
|
+
label: value,
|
|
65
|
+
...(optionDescription ? { description: optionDescription } : {}),
|
|
66
|
+
};
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
42
69
|
}
|
|
70
|
+
return {
|
|
71
|
+
id: `q${index}`,
|
|
72
|
+
type: "text",
|
|
73
|
+
label,
|
|
74
|
+
...(description ? { description } : {}),
|
|
75
|
+
required: true,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
interactionId: id,
|
|
80
|
+
kind: "question",
|
|
81
|
+
title: questions.length === 1
|
|
82
|
+
? asString(questions[0]?.header) ?? "Input required"
|
|
83
|
+
: "Input required",
|
|
84
|
+
fields,
|
|
85
|
+
actions: [{ id: "submit", label: "Submit", style: "primary", requiresAnswers: true }],
|
|
86
|
+
context: { toolName: "AskUserQuestion" },
|
|
87
|
+
createdAt: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function permissionSuggestions(payload) {
|
|
91
|
+
const suggestions = Array.isArray(payload.permission_suggestions)
|
|
92
|
+
? payload.permission_suggestions
|
|
93
|
+
.map(asRecord)
|
|
94
|
+
.filter((suggestion) => Boolean(suggestion))
|
|
95
|
+
: [];
|
|
96
|
+
const seenFilesystemTargets = new Set();
|
|
97
|
+
return suggestions.filter((suggestion) => {
|
|
98
|
+
const key = filesystemPermissionSuggestionKey(suggestion);
|
|
99
|
+
if (!key)
|
|
100
|
+
return true;
|
|
101
|
+
if (seenFilesystemTargets.has(key))
|
|
102
|
+
return false;
|
|
103
|
+
seenFilesystemTargets.add(key);
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function filesystemPermissionSuggestionKey(suggestion) {
|
|
108
|
+
const rules = Array.isArray(suggestion.rules)
|
|
109
|
+
? suggestion.rules.map(asRecord).filter((rule) => Boolean(rule))
|
|
110
|
+
: [];
|
|
111
|
+
if (rules.length !== 1)
|
|
112
|
+
return undefined;
|
|
113
|
+
const toolName = asString(rules[0]?.toolName);
|
|
114
|
+
if (toolName !== "Read" && toolName !== "Write" && toolName !== "Edit")
|
|
115
|
+
return undefined;
|
|
116
|
+
const ruleContent = asString(rules[0]?.ruleContent);
|
|
117
|
+
if (!ruleContent?.endsWith("/**"))
|
|
118
|
+
return undefined;
|
|
119
|
+
try {
|
|
120
|
+
const target = realpathSync(ruleContent.slice(0, -3));
|
|
121
|
+
return JSON.stringify([
|
|
122
|
+
asString(suggestion.type),
|
|
123
|
+
asString(suggestion.behavior),
|
|
124
|
+
asString(suggestion.destination),
|
|
125
|
+
asString(suggestion.mode),
|
|
126
|
+
toolName,
|
|
127
|
+
target,
|
|
128
|
+
]);
|
|
43
129
|
}
|
|
44
130
|
catch {
|
|
45
|
-
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function permissionSuggestionLabel(suggestion, index, total) {
|
|
135
|
+
const rules = Array.isArray(suggestion.rules)
|
|
136
|
+
? suggestion.rules.map(asRecord).filter((rule) => Boolean(rule))
|
|
137
|
+
: [];
|
|
138
|
+
const firstRule = rules[0];
|
|
139
|
+
const toolName = asString(firstRule?.toolName);
|
|
140
|
+
const ruleContent = asString(firstRule?.ruleContent);
|
|
141
|
+
const directories = Array.isArray(suggestion.directories)
|
|
142
|
+
? suggestion.directories.filter((value) => typeof value === "string" && value.length > 0)
|
|
143
|
+
: [];
|
|
144
|
+
const mode = asString(suggestion.mode);
|
|
145
|
+
let target;
|
|
146
|
+
if (toolName === "Read")
|
|
147
|
+
target = ruleContent ? `reading from ${ruleContent}` : "reading files";
|
|
148
|
+
else if (toolName === "Write")
|
|
149
|
+
target = ruleContent ? `writing to ${ruleContent}` : "writing files";
|
|
150
|
+
else if (toolName === "Edit")
|
|
151
|
+
target = ruleContent ? `editing ${ruleContent}` : "editing files";
|
|
152
|
+
else if (toolName === "Bash")
|
|
153
|
+
target = ruleContent ? `running ${ruleContent}` : "running Bash commands";
|
|
154
|
+
else if (toolName)
|
|
155
|
+
target = ruleContent ? `using ${toolName}(${ruleContent})` : `using ${toolName}`;
|
|
156
|
+
else if (directories.length > 0)
|
|
157
|
+
target = `accessing ${directories[0]}`;
|
|
158
|
+
else if (mode)
|
|
159
|
+
target = `using ${mode} permission mode`;
|
|
160
|
+
else
|
|
161
|
+
target = "this permission";
|
|
162
|
+
const extraCount = Math.max(0, rules.length - 1) + Math.max(0, directories.length - 1);
|
|
163
|
+
const suffix = extraCount > 0 ? ` and ${extraCount} more` : "";
|
|
164
|
+
const compactTarget = [...`${target}${suffix}`].length > 120
|
|
165
|
+
? `${[...`${target}${suffix}`].slice(0, 119).join("")}…`
|
|
166
|
+
: `${target}${suffix}`;
|
|
167
|
+
const destination = asString(suggestion.destination);
|
|
168
|
+
const scope = destination === "session" ? " in this session" : "";
|
|
169
|
+
const label = `Allow, and don’t ask again for ${compactTarget}${scope}`;
|
|
170
|
+
return total > 1 ? `${label} (${index + 1})` : label;
|
|
171
|
+
}
|
|
172
|
+
function permissionSuggestionActionId(index) {
|
|
173
|
+
return `allow_suggestion_${index}`;
|
|
174
|
+
}
|
|
175
|
+
function permissionRequest(id, payload, toolInput, suggestions) {
|
|
176
|
+
const toolName = asString(payload.tool_name) ?? "tool";
|
|
177
|
+
const command = asString(toolInput.command) ?? asString(toolInput.file_path);
|
|
178
|
+
const cwd = asString(payload.cwd);
|
|
179
|
+
return {
|
|
180
|
+
interactionId: id,
|
|
181
|
+
kind: "permission",
|
|
182
|
+
title: `Allow ${toolName}?`,
|
|
183
|
+
fields: [],
|
|
184
|
+
actions: [
|
|
185
|
+
{ id: "allow", label: "Allow once", style: "primary", requiresAnswers: false },
|
|
186
|
+
...suggestions.map((suggestion, index) => ({
|
|
187
|
+
id: permissionSuggestionActionId(index),
|
|
188
|
+
label: permissionSuggestionLabel(suggestion, index, suggestions.length),
|
|
189
|
+
requiresAnswers: false,
|
|
190
|
+
})),
|
|
191
|
+
{ id: "deny", label: "Deny", style: "danger", requiresAnswers: false },
|
|
192
|
+
],
|
|
193
|
+
context: {
|
|
194
|
+
toolName,
|
|
195
|
+
...(command ? { command } : {}),
|
|
196
|
+
...(cwd ? { cwd } : {}),
|
|
197
|
+
},
|
|
198
|
+
createdAt: Date.now(),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
async function waitForResult(bridgeDir, id) {
|
|
202
|
+
let lastLeaseAt = 0;
|
|
203
|
+
for (;;) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (now - lastLeaseAt >= 1_000) {
|
|
206
|
+
touchInteractionLease(bridgeDir, id);
|
|
207
|
+
lastLeaseAt = now;
|
|
208
|
+
}
|
|
209
|
+
const result = claimInteractionResult(bridgeDir, id);
|
|
210
|
+
if (result)
|
|
211
|
+
return result;
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function answerText(value) {
|
|
216
|
+
return Array.isArray(value) ? value.join(", ") : value ?? "";
|
|
217
|
+
}
|
|
218
|
+
function nativeVerdict(hookKind, payload, result, suggestions) {
|
|
219
|
+
const toolInput = asRecord(payload.tool_input) ?? {};
|
|
220
|
+
const toolName = asString(payload.tool_name) ?? "tool";
|
|
221
|
+
if (result.status === "cancelled") {
|
|
222
|
+
const reason = result.reason ?? "Interaction cancelled";
|
|
223
|
+
return hookKind === "pre_tool_use"
|
|
224
|
+
? {
|
|
225
|
+
hookSpecificOutput: {
|
|
226
|
+
hookEventName: "PreToolUse",
|
|
227
|
+
permissionDecision: "deny",
|
|
228
|
+
permissionDecisionReason: reason,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
: {
|
|
232
|
+
hookSpecificOutput: {
|
|
233
|
+
hookEventName: "PermissionRequest",
|
|
234
|
+
decision: { behavior: "deny", message: reason },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
46
237
|
}
|
|
238
|
+
const resolution = result.resolution;
|
|
239
|
+
if (toolName === "AskUserQuestion") {
|
|
240
|
+
const questions = Array.isArray(toolInput.questions)
|
|
241
|
+
? toolInput.questions.map(asRecord).filter((question) => Boolean(question))
|
|
242
|
+
: [];
|
|
243
|
+
const answers = {};
|
|
244
|
+
questions.forEach((question, index) => {
|
|
245
|
+
const text = asString(question.question) ?? `Question ${index + 1}`;
|
|
246
|
+
answers[text] = answerText(resolution.answers?.[`q${index}`]);
|
|
247
|
+
});
|
|
248
|
+
const updatedInput = { ...toolInput, answers };
|
|
249
|
+
return hookKind === "pre_tool_use"
|
|
250
|
+
? {
|
|
251
|
+
hookSpecificOutput: {
|
|
252
|
+
hookEventName: "PreToolUse",
|
|
253
|
+
permissionDecision: "allow",
|
|
254
|
+
updatedInput,
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
: {
|
|
258
|
+
hookSpecificOutput: {
|
|
259
|
+
hookEventName: "PermissionRequest",
|
|
260
|
+
decision: { behavior: "allow", updatedInput },
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const suggestionMatch = /^allow_suggestion_(\d+)$/.exec(resolution.actionId);
|
|
265
|
+
const suggestionIndex = suggestionMatch ? Number(suggestionMatch[1]) : -1;
|
|
266
|
+
const selectedSuggestion = Number.isSafeInteger(suggestionIndex)
|
|
267
|
+
? suggestions[suggestionIndex]
|
|
268
|
+
: undefined;
|
|
269
|
+
return {
|
|
270
|
+
hookSpecificOutput: {
|
|
271
|
+
hookEventName: "PermissionRequest",
|
|
272
|
+
decision: {
|
|
273
|
+
behavior: resolution.actionId === "allow" || selectedSuggestion ? "allow" : "deny",
|
|
274
|
+
...(selectedSuggestion ? { updatedPermissions: [selectedSuggestion] } : {}),
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
async function writeStdout(value) {
|
|
280
|
+
await new Promise((resolve) => process.stdout.write(JSON.stringify(value), () => resolve()));
|
|
47
281
|
}
|
|
48
282
|
async function main() {
|
|
49
283
|
const argv = process.argv.slice(2);
|
|
50
|
-
const
|
|
284
|
+
const mode = argv[0];
|
|
285
|
+
const hookKind = mode === "permission-request"
|
|
286
|
+
? "permission"
|
|
287
|
+
: mode === "ask-user-question"
|
|
288
|
+
? "pre_tool_use"
|
|
289
|
+
: undefined;
|
|
51
290
|
const bridgeDir = argValue(argv, "--bridge-dir");
|
|
52
291
|
if (!bridgeDir)
|
|
53
292
|
return;
|
|
@@ -55,20 +294,70 @@ async function main() {
|
|
|
55
294
|
let payload;
|
|
56
295
|
try {
|
|
57
296
|
const parsed = JSON.parse(raw || "{}");
|
|
58
|
-
payload = parsed
|
|
297
|
+
payload = asRecord(parsed) ?? {};
|
|
59
298
|
}
|
|
60
299
|
catch {
|
|
61
|
-
return;
|
|
300
|
+
return;
|
|
62
301
|
}
|
|
63
|
-
if (
|
|
64
|
-
|
|
302
|
+
if (!hookKind) {
|
|
303
|
+
try {
|
|
304
|
+
recordHookEvent(bridgeDir, payload);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// Observer hooks are best effort.
|
|
308
|
+
}
|
|
65
309
|
return;
|
|
66
310
|
}
|
|
311
|
+
const id = interactionId(payload);
|
|
312
|
+
const toolInput = asRecord(payload.tool_input) ?? {};
|
|
313
|
+
const suggestions = permissionSuggestions(payload);
|
|
314
|
+
const request = asString(payload.tool_name) === "AskUserQuestion"
|
|
315
|
+
? questionRequest(id, toolInput)
|
|
316
|
+
: permissionRequest(id, payload, toolInput, suggestions);
|
|
67
317
|
try {
|
|
68
|
-
|
|
318
|
+
const bounded = boundInteractionRequest(request);
|
|
319
|
+
if (!bounded.ok) {
|
|
320
|
+
await writeStdout(nativeVerdict(hookKind, payload, {
|
|
321
|
+
status: "cancelled",
|
|
322
|
+
reason: bounded.reason,
|
|
323
|
+
}, suggestions));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
touchInteractionLease(bridgeDir, id);
|
|
327
|
+
try {
|
|
328
|
+
recordInteractionRequest(bridgeDir, {
|
|
329
|
+
interactionId: id,
|
|
330
|
+
hookKind,
|
|
331
|
+
waiterPid: process.pid,
|
|
332
|
+
request: bounded.request,
|
|
333
|
+
});
|
|
334
|
+
const result = await waitForResult(bridgeDir, id);
|
|
335
|
+
const verdict = nativeVerdict(hookKind, payload, result, suggestions);
|
|
336
|
+
// Claiming the response is the interaction commit point: it records what
|
|
337
|
+
// the user answered, while the later tool/Turn events record whether Claude
|
|
338
|
+
// could act on it. ACK before stdout is required because Claude may reap a
|
|
339
|
+
// command hook immediately after consuming its verdict.
|
|
340
|
+
recordInteractionAck(bridgeDir, result.status === "resolved"
|
|
341
|
+
? {
|
|
342
|
+
interactionId: id,
|
|
343
|
+
status: "resolved",
|
|
344
|
+
resolution: redactInteractionResolution(bounded.request, result.resolution),
|
|
345
|
+
}
|
|
346
|
+
: {
|
|
347
|
+
interactionId: id,
|
|
348
|
+
status: "cancelled",
|
|
349
|
+
...(result.reason ? { reason: result.reason } : {}),
|
|
350
|
+
});
|
|
351
|
+
removeClaimedInteractionResult(bridgeDir, id);
|
|
352
|
+
await writeStdout(verdict);
|
|
353
|
+
}
|
|
354
|
+
finally {
|
|
355
|
+
removeInteractionLease(bridgeDir, id);
|
|
356
|
+
removeClaimedInteractionResult(bridgeDir, id);
|
|
357
|
+
}
|
|
69
358
|
}
|
|
70
359
|
catch {
|
|
71
|
-
//
|
|
360
|
+
// Empty stdout lets Claude fall back to its native UI instead of wedging.
|
|
72
361
|
}
|
|
73
362
|
}
|
|
74
363
|
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 }] },
|