@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
@@ -5,11 +5,9 @@ import type { SkillMeta } from "@rynx-ai/core";
5
5
  * manifest — claude's mechanism for exposing skills outside its host dirs, since
6
6
  * it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
7
7
  *
8
- * Returns `null` when there's nothing to add no selection (no opinion) OR an
9
- * empty selection (`none`). Enforcement on claude is ADDITIVE only: we never
10
- * suppress host skills, because the only lever (`--setting-sources ""`) also
11
- * drops the user's settings (and any auth env there). Codex/traex still enforce
12
- * `none`/subsets exactly via the prompt.
8
+ * Returns `null` when there's nothing to add. Rynx-managed Claude sessions load
9
+ * no host/project/local setting sources, so this explicit plugin directory is
10
+ * the only native skill channel; the selected subset is enforced exactly.
13
11
  */
14
12
  export declare function materializeSkillPlugin(selected: SkillMeta[] | null | undefined): Promise<{
15
13
  pluginDir?: string;
@@ -7,11 +7,9 @@ import path from "node:path";
7
7
  * manifest — claude's mechanism for exposing skills outside its host dirs, since
8
8
  * it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
9
9
  *
10
- * Returns `null` when there's nothing to add no selection (no opinion) OR an
11
- * empty selection (`none`). Enforcement on claude is ADDITIVE only: we never
12
- * suppress host skills, because the only lever (`--setting-sources ""`) also
13
- * drops the user's settings (and any auth env there). Codex/traex still enforce
14
- * `none`/subsets exactly via the prompt.
10
+ * Returns `null` when there's nothing to add. Rynx-managed Claude sessions load
11
+ * no host/project/local setting sources, so this explicit plugin directory is
12
+ * the only native skill channel; the selected subset is enforced exactly.
15
13
  */
16
14
  export async function materializeSkillPlugin(selected) {
17
15
  if (selected == null || selected.length === 0)
@@ -1,24 +1,82 @@
1
+ import type { SessionInteractionRequest, SessionInteractionResolution } from "@rynx-ai/core";
1
2
  export declare const HOOKS_FILE = "hooks.jsonl";
2
3
  export declare const STATE_FILE = "state.json";
3
4
  export declare const DELTAS_FILE = "message_deltas.jsonl";
4
- export declare const PERMISSION_FILE = "permission_hook.json";
5
5
  export declare const STATUS_FILE = "status.json";
6
6
  export declare const FORWARDER_STATE_FILE = "transcript_forwarder.json";
7
+ export declare const INTERACTIONS_FILE = "interactions.jsonl";
8
+ export declare const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
9
+ export declare const INTERACTION_RESULTS_DIR = "interaction-results";
10
+ export declare const INTERACTION_CLAIMS_DIR = "interaction-claims";
11
+ export declare const INTERACTION_LEASES_DIR = "interaction-leases";
12
+ export declare const MANAGED_SETTINGS_FILE = "managed-settings.json";
7
13
  /** The deterministic bridge directory for a rynx session id. */
8
14
  export declare function claudeBridgeDir(sessionId: string): string;
9
- export interface PermissionHookConfig {
10
- /** The rynx session id (localThreadId) — the hook POSTs to `/api/sessions/:id/...`. */
11
- sessionId: string;
12
- /** Daemon base URL the PermissionRequest hook POSTs to, e.g. `http://127.0.0.1:3000`. */
13
- serverUrl: string;
14
- }
15
15
  /**
16
- * Create/refresh the bridge dir: clear stale event files (a re-launched session
17
- * must not replay a prior run's hooks) and, when given, write the permission
18
- * config. `permission_hook.json` is deliberately NOT cleared elsewhere so a
19
- * reattach keeps approval routing. Returns the dir.
16
+ * Create/refresh the bridge dir and clear stale files. Native interactions use
17
+ * this local rendezvous directly; no Control Plane URL or provider-specific
18
+ * server callback is part of the runtime adapter.
20
19
  */
21
- export declare function prepareClaudeBridgeDir(sessionId: string, permission?: PermissionHookConfig): string;
20
+ export declare function prepareClaudeBridgeDir(sessionId: string): string;
21
+ /** Remove every one-shot interaction handoff after the Claude terminal and its
22
+ * hook subprocesses have stopped. Result and claim files may contain the raw
23
+ * user answer, so runner shutdown must call this synchronously before exit. */
24
+ export declare function scrubClaudeInteractionArtifacts(bridgeDir: string): void;
25
+ /** Persist the effective Claude settings outside argv (it can contain auth env
26
+ * inherited from the user's settings). The bridge directory is uid-private. */
27
+ export declare function writeManagedClaudeSettings(bridgeDir: string, settings: Record<string, unknown>): string;
28
+ export declare function removeManagedClaudeSettings(bridgeDir: string): void;
29
+ export interface ClaudeBridgeInteractionRequest {
30
+ interactionId: string;
31
+ hookKind: "permission" | "pre_tool_use";
32
+ /** PID of the blocking hook process that owns this request. The forwarder
33
+ * uses it as a lease: once the waiter exits, no response can be delivered. */
34
+ waiterPid?: number;
35
+ request: SessionInteractionRequest;
36
+ }
37
+ export type ClaudeBridgeInteractionResult = {
38
+ status: "resolved";
39
+ resolution: SessionInteractionResolution;
40
+ } | {
41
+ status: "cancelled";
42
+ reason?: string;
43
+ };
44
+ /** Public receipt written after the hook has consumed the one-shot answer and
45
+ * before it returns the native verdict. Secret answers are redacted before this
46
+ * append-only record is created. */
47
+ export type ClaudeBridgeInteractionAck = {
48
+ interactionId: string;
49
+ status: "resolved";
50
+ resolution: SessionInteractionResolution;
51
+ } | {
52
+ interactionId: string;
53
+ status: "cancelled";
54
+ reason?: string;
55
+ };
56
+ export declare function recordInteractionRequest(bridgeDir: string, request: ClaudeBridgeInteractionRequest): void;
57
+ export declare function readInteractionRequestsFrom(bridgeDir: string, byteOffset: number): {
58
+ requests: ClaudeBridgeInteractionRequest[];
59
+ nextOffset: number;
60
+ };
61
+ export declare function writeInteractionResult(bridgeDir: string, interactionId: string, result: ClaudeBridgeInteractionResult): boolean;
62
+ export declare function readInteractionResult(bridgeDir: string, interactionId: string): ClaudeBridgeInteractionResult | null;
63
+ /** Atomically transfer a one-shot response from the forwarder to its hook. Once
64
+ * this succeeds, terminal cleanup must not overwrite that in-flight decision. */
65
+ export declare function claimInteractionResult(bridgeDir: string, interactionId: string): ClaudeBridgeInteractionResult | null;
66
+ export declare function readClaimedInteractionResult(bridgeDir: string, interactionId: string): ClaudeBridgeInteractionResult | null;
67
+ export declare function removeClaimedInteractionResult(bridgeDir: string, interactionId: string): void;
68
+ /** Refresh the hook waiter's lease. Its mtime is an identity-independent
69
+ * liveness signal, so PID reuse cannot keep a dead request pending forever. */
70
+ export declare function touchInteractionLease(bridgeDir: string, interactionId: string): void;
71
+ export declare function interactionLeaseUpdatedAt(bridgeDir: string, interactionId: string): number | undefined;
72
+ export declare function removeInteractionLease(bridgeDir: string, interactionId: string): void;
73
+ /** Delete the one-shot answer after the hook has consumed it. */
74
+ export declare function removeInteractionResult(bridgeDir: string, interactionId: string): void;
75
+ export declare function recordInteractionAck(bridgeDir: string, ack: ClaudeBridgeInteractionAck): void;
76
+ export declare function readInteractionAcksFrom(bridgeDir: string, byteOffset: number): {
77
+ acks: ClaudeBridgeInteractionAck[];
78
+ nextOffset: number;
79
+ };
22
80
  export interface ClaudeBridgeState {
23
81
  /** Claude transcript JSONL path (from the SessionStart/any hook payload). */
24
82
  transcriptPath?: string;
@@ -27,14 +85,13 @@ export interface ClaudeBridgeState {
27
85
  lastHookEventName?: string;
28
86
  }
29
87
  export declare function readClaudeState(bridgeDir: string): ClaudeBridgeState;
30
- export declare function readPermissionHookConfig(bridgeDir: string): PermissionHookConfig | null;
31
88
  /**
32
89
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
33
90
  * `state.json` (transcript path + claude session id + last event). The payload
34
91
  * is Claude's own stdin JSON, so its fields are snake_case (`hook_event_name`,
35
92
  * `transcript_path`, `session_id`). state.json is a lock-free read-modify-write
36
93
  * (last-writer-wins) — the folded fields are idempotent, so a race only re-writes
37
- * the same values (matches omnigent's `record_hook_event`).
94
+ * the same values (matches reference implementation's `record_hook_event`).
38
95
  */
39
96
  export declare function recordHookEvent(bridgeDir: string, payload: Record<string, unknown>): void;
40
97
  export interface JsonlReadResult<T> {
@@ -101,7 +158,7 @@ export declare function writeClaudeStatus(bridgeDir: string, status: ClaudeStatu
101
158
  * The forwarder's durable cursor into a claude transcript. Persisted so a
102
159
  * re-launched forwarder (reconnect / daemon restart) resumes from where it left
103
160
  * off instead of re-tailing from byte 0 and re-mirroring every past turn
104
- * (duplicates). Ports omnigent's `TranscriptForwardState` / `transcript_forwarder.json`.
161
+ * (duplicates). Ports reference implementation's `TranscriptForwardState` / `transcript_forwarder.json`.
105
162
  * NOT cleared by {@link prepareClaudeBridgeDir} — it must outlive a re-launch.
106
163
  */
107
164
  export interface TranscriptForwardState {
@@ -116,11 +173,11 @@ export interface TranscriptForwardState {
116
173
  * mismatch means the file was truncated/replaced → don't seek into a stale offset. */
117
174
  cursorFingerprint?: string;
118
175
  }
119
- /** Cap on `seenSourceIds` (matches omnigent's `_MAX_SEEN_SOURCE_IDS`). */
176
+ /** Cap on `seenSourceIds` (matches reference implementation's `_MAX_SEEN_SOURCE_IDS`). */
120
177
  export declare const MAX_SEEN_SOURCE_IDS = 2000;
121
178
  /** SHA-256 over `byteOffset` (8-byte big-endian) + up to 256 bytes immediately
122
179
  * before it. Undefined if the file is shorter than `byteOffset` (truncated) or
123
- * unreadable. Mirrors omnigent's `_jsonl_cursor_fingerprint`. */
180
+ * unreadable. Mirrors reference implementation's `_jsonl_cursor_fingerprint`. */
124
181
  export declare function jsonlCursorFingerprint(path: string, byteOffset: number): string | undefined;
125
182
  /** Persist the forwarder cursor atomically (temp file + rename); `seenSourceIds`
126
183
  * is bounded to the most-recent {@link MAX_SEEN_SOURCE_IDS}. */
@@ -1,16 +1,15 @@
1
1
  /**
2
2
  * Claude-native bridge directory — the filesystem rendezvous between the
3
3
  * short-lived Claude Code hook subprocesses (which APPEND events) and the
4
- * runner-child forwarder (which TAILS them). Mirrors omnigent's
4
+ * runner-child forwarder (which TAILS them). Mirrors reference implementation's
5
5
  * `claude_native_bridge` file surface:
6
6
  * - `hooks.jsonl` append-only hook events (SessionStart / Stop / …)
7
7
  * - `state.json` latest transcript_path + claude session id
8
8
  * - `message_deltas.jsonl` streamed assistant-text chunks (MessageDisplay)
9
- * - `permission_hook.json` server URL + auth for the blocking PermissionRequest hook
9
+ * - `interactions.jsonl` + `interaction-results/` blocking native interactions
10
10
  *
11
- * Dependency-light on purpose (node builtins only, no `@rynx-ai/core`) so the
12
- * standalone hook entrypoints can import it without pulling the whole runtime
13
- * into every hook subprocess.
11
+ * Dependency-light on purpose (node builtins plus erased protocol types) so the
12
+ * standalone hook entrypoints do not pull the whole runtime into every process.
14
13
  */
15
14
  import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
16
15
  import { createHash } from "node:crypto";
@@ -19,11 +18,15 @@ import { join } from "node:path";
19
18
  export const HOOKS_FILE = "hooks.jsonl";
20
19
  export const STATE_FILE = "state.json";
21
20
  export const DELTAS_FILE = "message_deltas.jsonl";
22
- export const PERMISSION_FILE = "permission_hook.json";
23
21
  export const STATUS_FILE = "status.json";
24
22
  export const FORWARDER_STATE_FILE = "transcript_forwarder.json";
25
- /** Uid-scoped root so other users on a shared host can't read the bridge tree
26
- * (it holds the PermissionRequest bearer headers). */
23
+ export const INTERACTIONS_FILE = "interactions.jsonl";
24
+ export const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
25
+ export const INTERACTION_RESULTS_DIR = "interaction-results";
26
+ export const INTERACTION_CLAIMS_DIR = "interaction-claims";
27
+ export const INTERACTION_LEASES_DIR = "interaction-leases";
28
+ export const MANAGED_SETTINGS_FILE = "managed-settings.json";
29
+ /** Uid-scoped root so other users on a shared host cannot read interaction data. */
27
30
  function bridgeRoot() {
28
31
  const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
29
32
  return join(tmpdir(), `rynx-${uid}`, "claude-native");
@@ -34,15 +37,22 @@ export function claudeBridgeDir(sessionId) {
34
37
  return join(bridgeRoot(), digest);
35
38
  }
36
39
  /**
37
- * Create/refresh the bridge dir: clear stale event files (a re-launched session
38
- * must not replay a prior run's hooks) and, when given, write the permission
39
- * config. `permission_hook.json` is deliberately NOT cleared elsewhere so a
40
- * reattach keeps approval routing. Returns the dir.
40
+ * Create/refresh the bridge dir and clear stale files. Native interactions use
41
+ * this local rendezvous directly; no Control Plane URL or provider-specific
42
+ * server callback is part of the runtime adapter.
41
43
  */
42
- export function prepareClaudeBridgeDir(sessionId, permission) {
44
+ export function prepareClaudeBridgeDir(sessionId) {
43
45
  const dir = claudeBridgeDir(sessionId);
44
46
  mkdirSync(dir, { recursive: true, mode: 0o700 });
45
- for (const file of [HOOKS_FILE, STATE_FILE, DELTAS_FILE, STATUS_FILE]) {
47
+ for (const file of [
48
+ HOOKS_FILE,
49
+ STATE_FILE,
50
+ DELTAS_FILE,
51
+ STATUS_FILE,
52
+ INTERACTIONS_FILE,
53
+ INTERACTION_ACKS_FILE,
54
+ MANAGED_SETTINGS_FILE,
55
+ ]) {
46
56
  try {
47
57
  rmSync(join(dir, file));
48
58
  }
@@ -50,11 +60,207 @@ export function prepareClaudeBridgeDir(sessionId, permission) {
50
60
  // Absent on a cold start — expected.
51
61
  }
52
62
  }
53
- if (permission) {
54
- writeFileSync(join(dir, PERMISSION_FILE), JSON.stringify(permission), { mode: 0o600 });
63
+ for (const child of [
64
+ INTERACTION_RESULTS_DIR,
65
+ INTERACTION_CLAIMS_DIR,
66
+ INTERACTION_LEASES_DIR,
67
+ ]) {
68
+ rmSync(join(dir, child), { recursive: true, force: true });
55
69
  }
70
+ mkdirSync(join(dir, INTERACTION_RESULTS_DIR), { recursive: true, mode: 0o700 });
71
+ mkdirSync(join(dir, INTERACTION_CLAIMS_DIR), { recursive: true, mode: 0o700 });
72
+ mkdirSync(join(dir, INTERACTION_LEASES_DIR), { recursive: true, mode: 0o700 });
56
73
  return dir;
57
74
  }
75
+ /** Remove every one-shot interaction handoff after the Claude terminal and its
76
+ * hook subprocesses have stopped. Result and claim files may contain the raw
77
+ * user answer, so runner shutdown must call this synchronously before exit. */
78
+ export function scrubClaudeInteractionArtifacts(bridgeDir) {
79
+ for (const child of [
80
+ INTERACTION_RESULTS_DIR,
81
+ INTERACTION_CLAIMS_DIR,
82
+ INTERACTION_LEASES_DIR,
83
+ ]) {
84
+ rmSync(join(bridgeDir, child), { recursive: true, force: true });
85
+ }
86
+ }
87
+ /** Persist the effective Claude settings outside argv (it can contain auth env
88
+ * inherited from the user's settings). The bridge directory is uid-private. */
89
+ export function writeManagedClaudeSettings(bridgeDir, settings) {
90
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
91
+ const path = join(bridgeDir, MANAGED_SETTINGS_FILE);
92
+ const tmp = `${path}.${process.pid}.tmp`;
93
+ writeFileSync(tmp, JSON.stringify(settings), { mode: 0o600 });
94
+ renameSync(tmp, path);
95
+ return path;
96
+ }
97
+ export function removeManagedClaudeSettings(bridgeDir) {
98
+ rmSync(join(bridgeDir, MANAGED_SETTINGS_FILE), { force: true });
99
+ }
100
+ function safeInteractionId(interactionId) {
101
+ return /^[a-zA-Z0-9_-]{1,128}$/.test(interactionId);
102
+ }
103
+ function interactionResultPath(bridgeDir, interactionId) {
104
+ return safeInteractionId(interactionId)
105
+ ? join(bridgeDir, INTERACTION_RESULTS_DIR, `${interactionId}.json`)
106
+ : null;
107
+ }
108
+ function interactionClaimPath(bridgeDir, interactionId) {
109
+ return safeInteractionId(interactionId)
110
+ ? join(bridgeDir, INTERACTION_CLAIMS_DIR, `${interactionId}.json`)
111
+ : null;
112
+ }
113
+ function interactionLeasePath(bridgeDir, interactionId) {
114
+ return safeInteractionId(interactionId)
115
+ ? join(bridgeDir, INTERACTION_LEASES_DIR, `${interactionId}.lease`)
116
+ : null;
117
+ }
118
+ export function recordInteractionRequest(bridgeDir, request) {
119
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
120
+ appendFileSync(join(bridgeDir, INTERACTIONS_FILE), `${JSON.stringify({ recordedAt: Date.now(), request })}\n`);
121
+ }
122
+ export function readInteractionRequestsFrom(bridgeDir, byteOffset) {
123
+ const { records, nextOffset } = readJsonlFrom(join(bridgeDir, INTERACTIONS_FILE), byteOffset);
124
+ const requests = records
125
+ .map((row) => row.request)
126
+ .filter((request) => Boolean(request &&
127
+ safeInteractionId(request.interactionId) &&
128
+ (request.hookKind === "permission" || request.hookKind === "pre_tool_use") &&
129
+ (request.waiterPid === undefined ||
130
+ (Number.isSafeInteger(request.waiterPid) && request.waiterPid > 0)) &&
131
+ request.request?.interactionId === request.interactionId));
132
+ return { requests, nextOffset };
133
+ }
134
+ export function writeInteractionResult(bridgeDir, interactionId, result) {
135
+ const path = interactionResultPath(bridgeDir, interactionId);
136
+ if (!path)
137
+ return false;
138
+ mkdirSync(join(bridgeDir, INTERACTION_RESULTS_DIR), { recursive: true, mode: 0o700 });
139
+ const tmp = `${path}.${process.pid}.tmp`;
140
+ writeFileSync(tmp, JSON.stringify(result), { mode: 0o600 });
141
+ renameSync(tmp, path);
142
+ return true;
143
+ }
144
+ export function readInteractionResult(bridgeDir, interactionId) {
145
+ const path = interactionResultPath(bridgeDir, interactionId);
146
+ if (!path)
147
+ return null;
148
+ return parseInteractionResult(readJsonFile(path));
149
+ }
150
+ function parseInteractionResult(raw) {
151
+ if (!raw || typeof raw !== "object")
152
+ return null;
153
+ const value = raw;
154
+ if (value.status === "resolved" && value.resolution && typeof value.resolution === "object") {
155
+ return {
156
+ status: "resolved",
157
+ resolution: value.resolution,
158
+ };
159
+ }
160
+ if (value.status === "cancelled") {
161
+ return {
162
+ status: "cancelled",
163
+ ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
164
+ };
165
+ }
166
+ return null;
167
+ }
168
+ /** Atomically transfer a one-shot response from the forwarder to its hook. Once
169
+ * this succeeds, terminal cleanup must not overwrite that in-flight decision. */
170
+ export function claimInteractionResult(bridgeDir, interactionId) {
171
+ const source = interactionResultPath(bridgeDir, interactionId);
172
+ const claim = interactionClaimPath(bridgeDir, interactionId);
173
+ if (!source || !claim)
174
+ return null;
175
+ mkdirSync(join(bridgeDir, INTERACTION_CLAIMS_DIR), { recursive: true, mode: 0o700 });
176
+ try {
177
+ renameSync(source, claim);
178
+ }
179
+ catch (error) {
180
+ if (error.code === "ENOENT")
181
+ return null;
182
+ throw error;
183
+ }
184
+ const result = parseInteractionResult(readJsonFile(claim));
185
+ if (!result)
186
+ throw new Error("Invalid claimed Claude interaction result");
187
+ return result;
188
+ }
189
+ export function readClaimedInteractionResult(bridgeDir, interactionId) {
190
+ const path = interactionClaimPath(bridgeDir, interactionId);
191
+ return path ? parseInteractionResult(readJsonFile(path)) : null;
192
+ }
193
+ export function removeClaimedInteractionResult(bridgeDir, interactionId) {
194
+ const path = interactionClaimPath(bridgeDir, interactionId);
195
+ if (path)
196
+ rmSync(path, { force: true });
197
+ }
198
+ /** Refresh the hook waiter's lease. Its mtime is an identity-independent
199
+ * liveness signal, so PID reuse cannot keep a dead request pending forever. */
200
+ export function touchInteractionLease(bridgeDir, interactionId) {
201
+ const path = interactionLeasePath(bridgeDir, interactionId);
202
+ if (!path)
203
+ return;
204
+ mkdirSync(join(bridgeDir, INTERACTION_LEASES_DIR), { recursive: true, mode: 0o700 });
205
+ writeFileSync(path, "", { mode: 0o600 });
206
+ }
207
+ export function interactionLeaseUpdatedAt(bridgeDir, interactionId) {
208
+ const path = interactionLeasePath(bridgeDir, interactionId);
209
+ if (!path)
210
+ return undefined;
211
+ try {
212
+ return statSync(path).mtimeMs;
213
+ }
214
+ catch {
215
+ return undefined;
216
+ }
217
+ }
218
+ export function removeInteractionLease(bridgeDir, interactionId) {
219
+ const path = interactionLeasePath(bridgeDir, interactionId);
220
+ if (path)
221
+ rmSync(path, { force: true });
222
+ }
223
+ /** Delete the one-shot answer after the hook has consumed it. */
224
+ export function removeInteractionResult(bridgeDir, interactionId) {
225
+ const path = interactionResultPath(bridgeDir, interactionId);
226
+ if (!path)
227
+ return;
228
+ rmSync(path, { force: true });
229
+ }
230
+ export function recordInteractionAck(bridgeDir, ack) {
231
+ if (!safeInteractionId(ack.interactionId)) {
232
+ throw new Error("Invalid Claude interaction acknowledgement id");
233
+ }
234
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
235
+ appendFileSync(join(bridgeDir, INTERACTION_ACKS_FILE), `${JSON.stringify({ recordedAt: Date.now(), ack })}\n`, { mode: 0o600 });
236
+ }
237
+ export function readInteractionAcksFrom(bridgeDir, byteOffset) {
238
+ const { records, nextOffset } = readJsonlFrom(join(bridgeDir, INTERACTION_ACKS_FILE), byteOffset);
239
+ const acks = [];
240
+ for (const row of records) {
241
+ if (!row.ack || typeof row.ack !== "object" || Array.isArray(row.ack))
242
+ continue;
243
+ const value = row.ack;
244
+ if (!safeInteractionId(String(value.interactionId ?? "")))
245
+ continue;
246
+ if (value.status === "resolved" && value.resolution && typeof value.resolution === "object") {
247
+ acks.push({
248
+ interactionId: value.interactionId,
249
+ status: "resolved",
250
+ resolution: value.resolution,
251
+ });
252
+ continue;
253
+ }
254
+ if (value.status === "cancelled") {
255
+ acks.push({
256
+ interactionId: value.interactionId,
257
+ status: "cancelled",
258
+ ...(typeof value.reason === "string" ? { reason: value.reason } : {}),
259
+ });
260
+ }
261
+ }
262
+ return { acks, nextOffset };
263
+ }
58
264
  function readJsonFile(path) {
59
265
  try {
60
266
  return JSON.parse(readFileSync(path, "utf8"));
@@ -77,24 +283,13 @@ export function readClaudeState(bridgeDir) {
77
283
  lastHookEventName: asString(s.lastHookEventName),
78
284
  };
79
285
  }
80
- export function readPermissionHookConfig(bridgeDir) {
81
- const raw = readJsonFile(join(bridgeDir, PERMISSION_FILE));
82
- if (!raw || typeof raw !== "object")
83
- return null;
84
- const s = raw;
85
- const sessionId = asString(s.sessionId);
86
- const serverUrl = asString(s.serverUrl);
87
- if (!sessionId || !serverUrl)
88
- return null;
89
- return { sessionId, serverUrl };
90
- }
91
286
  /**
92
287
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
93
288
  * `state.json` (transcript path + claude session id + last event). The payload
94
289
  * is Claude's own stdin JSON, so its fields are snake_case (`hook_event_name`,
95
290
  * `transcript_path`, `session_id`). state.json is a lock-free read-modify-write
96
291
  * (last-writer-wins) — the folded fields are idempotent, so a race only re-writes
97
- * the same values (matches omnigent's `record_hook_event`).
292
+ * the same values (matches reference implementation's `record_hook_event`).
98
293
  */
99
294
  export function recordHookEvent(bridgeDir, payload) {
100
295
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
@@ -202,12 +397,12 @@ export function writeClaudeStatus(bridgeDir, status) {
202
397
  function asNumber(value) {
203
398
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
204
399
  }
205
- /** Cap on `seenSourceIds` (matches omnigent's `_MAX_SEEN_SOURCE_IDS`). */
400
+ /** Cap on `seenSourceIds` (matches reference implementation's `_MAX_SEEN_SOURCE_IDS`). */
206
401
  export const MAX_SEEN_SOURCE_IDS = 2000;
207
402
  const CURSOR_FINGERPRINT_BYTES = 256;
208
403
  /** SHA-256 over `byteOffset` (8-byte big-endian) + up to 256 bytes immediately
209
404
  * before it. Undefined if the file is shorter than `byteOffset` (truncated) or
210
- * unreadable. Mirrors omnigent's `_jsonl_cursor_fingerprint`. */
405
+ * unreadable. Mirrors reference implementation's `_jsonl_cursor_fingerprint`. */
211
406
  export function jsonlCursorFingerprint(path, byteOffset) {
212
407
  let fd;
213
408
  try {