@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.
Files changed (56) 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 +327 -38
  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 +123 -16
  9. package/dist/claude/native-integration.js +624 -81
  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 +14 -3
  14. package/dist/codex/rollout-synth.d.ts +8 -3
  15. package/dist/codex/rollout-synth.js +65 -32
  16. package/dist/codex-app-server/client.d.ts +27 -40
  17. package/dist/codex-app-server/client.js +1134 -99
  18. package/dist/codex-app-server/forwarder.d.ts +36 -10
  19. package/dist/codex-app-server/forwarder.js +146 -28
  20. package/dist/codex-app-server/mapping.d.ts +1 -1
  21. package/dist/codex-app-server/mapping.js +64 -5
  22. package/dist/codex-app-server/protocol.d.ts +269 -4
  23. package/dist/codex-app-server/transport.d.ts +20 -5
  24. package/dist/codex-app-server/transport.js +93 -40
  25. package/dist/codex-app-server/ws-channel.d.ts +3 -3
  26. package/dist/codex-app-server/ws-channel.js +23 -7
  27. package/dist/codex-child-env.js +33 -0
  28. package/dist/codex-home.d.ts +16 -6
  29. package/dist/codex-home.js +46 -15
  30. package/dist/codex-session-store.d.ts +2 -1
  31. package/dist/host.d.ts +38 -38
  32. package/dist/host.js +626 -121
  33. package/dist/index.d.ts +4 -3
  34. package/dist/index.js +1 -1
  35. package/dist/input-resources.d.ts +13 -0
  36. package/dist/input-resources.js +67 -0
  37. package/dist/interactions.d.ts +61 -0
  38. package/dist/interactions.js +236 -0
  39. package/dist/models-catalog.d.ts +5 -13
  40. package/dist/models-catalog.js +60 -9
  41. package/dist/runner/child.d.ts +9 -1
  42. package/dist/runner/child.js +100 -19
  43. package/dist/runner/manager.d.ts +79 -11
  44. package/dist/runner/manager.js +423 -43
  45. package/dist/runner/protocol.d.ts +30 -11
  46. package/dist/runner-main.js +9 -6
  47. package/dist/runtime-status.js +1 -1
  48. package/dist/terminal/claude-tui.d.ts +8 -3
  49. package/dist/terminal/claude-tui.js +6 -2
  50. package/dist/terminal/codex-tui.d.ts +3 -3
  51. package/dist/terminal/codex-tui.js +1 -1
  52. package/dist/terminal/registry.d.ts +1 -1
  53. package/dist/terminal/registry.js +1 -1
  54. package/dist/terminal/tmux.d.ts +6 -6
  55. package/dist/terminal/tmux.js +10 -10
  56. package/package.json +8 -3
@@ -1,20 +1,10 @@
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 { 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
- 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()));
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
- // Server unreachable / hung → empty stdout → claude falls back to its TUI prompt.
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 isPermission = argv[0] === "permission-request";
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 && typeof parsed === "object" ? parsed : {};
297
+ payload = asRecord(parsed) ?? {};
59
298
  }
60
299
  catch {
61
- return; // malformed hook payload — don't block claude
300
+ return;
62
301
  }
63
- if (isPermission) {
64
- await relayPermission(bridgeDir, payload);
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
- recordHookEvent(bridgeDir, payload);
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
- // Best-effort: a failed append must not fail the hook (would wedge claude).
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
- /** 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 }] },