@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
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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;
|
package/dist/claude/executor.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* - `
|
|
9
|
+
* - `interactions.jsonl` + `interaction-results/` blocking native interactions
|
|
10
10
|
*
|
|
11
|
-
* Dependency-light on purpose (node builtins
|
|
12
|
-
* standalone hook entrypoints
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
44
|
+
export function prepareClaudeBridgeDir(sessionId) {
|
|
43
45
|
const dir = claudeBridgeDir(sessionId);
|
|
44
46
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
-
for (const file of [
|
|
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
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
405
|
+
* unreadable. Mirrors reference implementation's `_jsonl_cursor_fingerprint`. */
|
|
211
406
|
export function jsonlCursorFingerprint(path, byteOffset) {
|
|
212
407
|
let fd;
|
|
213
408
|
try {
|