@rynx-ai/runtime 0.1.0
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 +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- package/package.json +32 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SkillMeta } from "@rynx-ai/core";
|
|
2
|
+
/**
|
|
3
|
+
* Materialize a selected catalog skill subset into a throwaway claude
|
|
4
|
+
* `--plugin-dir` (a dir with `skills/<name>/SKILL.md` + a `.claude-plugin/plugin.json`
|
|
5
|
+
* manifest — claude's mechanism for exposing skills outside its host dirs, since
|
|
6
|
+
* it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
|
|
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.
|
|
13
|
+
*/
|
|
14
|
+
export declare function materializeSkillPlugin(selected: SkillMeta[] | null | undefined): Promise<{
|
|
15
|
+
pluginDir?: string;
|
|
16
|
+
cleanup?: () => Promise<void>;
|
|
17
|
+
} | null>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Materialize a selected catalog skill subset into a throwaway claude
|
|
6
|
+
* `--plugin-dir` (a dir with `skills/<name>/SKILL.md` + a `.claude-plugin/plugin.json`
|
|
7
|
+
* manifest — claude's mechanism for exposing skills outside its host dirs, since
|
|
8
|
+
* it doesn't scan `~/.rynx/skills`). Consumed by the claude-native live launch.
|
|
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.
|
|
15
|
+
*/
|
|
16
|
+
export async function materializeSkillPlugin(selected) {
|
|
17
|
+
if (selected == null || selected.length === 0)
|
|
18
|
+
return null;
|
|
19
|
+
const dir = await mkdtemp(path.join(tmpdir(), "rynx-claude-skills-"));
|
|
20
|
+
const skillsRoot = path.join(dir, "skills");
|
|
21
|
+
await mkdir(skillsRoot, { recursive: true });
|
|
22
|
+
for (const skill of selected) {
|
|
23
|
+
await cp(skill.dir, path.join(skillsRoot, skill.name), { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
await mkdir(path.join(dir, ".claude-plugin"), { recursive: true });
|
|
26
|
+
await writeFile(path.join(dir, ".claude-plugin", "plugin.json"), `${JSON.stringify({ name: "rynx-agent-skills", description: "Per-agent skill subset" }, null, 2)}\n`);
|
|
27
|
+
return { pluginDir: dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ModelInfo, ModelListResponse } from "../codex-app-server/protocol.js";
|
|
2
|
+
/**
|
|
3
|
+
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
4
|
+
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
5
|
+
*/
|
|
6
|
+
export declare const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
7
|
+
/** Build the `ModelInfo[]` for the claude runtime. */
|
|
8
|
+
export declare function claudeModelInfos(): ModelInfo[];
|
|
9
|
+
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
|
10
|
+
export declare function listClaudeModels(): ModelListResponse;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default model for the claude runtime when `CLAUDE_MODEL` is unset. Kept in
|
|
3
|
+
* sync with the `CLAUDE_MODEL` default in {@link import("../config.js")}.
|
|
4
|
+
*/
|
|
5
|
+
export const CLAUDE_DEFAULT_MODEL = "claude-sonnet-4-6";
|
|
6
|
+
/**
|
|
7
|
+
* Static catalogue of Claude models the `/model` card and `/models` reply offer
|
|
8
|
+
* for the claude runtime. Unlike codex/traex (which query a live app-server),
|
|
9
|
+
* the Agent SDK has no per-install model-list RPC, so we ship a curated list of
|
|
10
|
+
* current model ids. `value`/`label` mirror the codex `ModelInfo` shape the
|
|
11
|
+
* settings card consumes (`model || id`, `displayName || model || id`).
|
|
12
|
+
*/
|
|
13
|
+
const CLAUDE_MODEL_IDS = [
|
|
14
|
+
{ id: "claude-opus-4-8", displayName: "Claude Opus 4.8 · 最强" },
|
|
15
|
+
{ id: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 · 均衡(默认)" },
|
|
16
|
+
{ id: "claude-haiku-4-5", displayName: "Claude Haiku 4.5 · 最快" },
|
|
17
|
+
{ id: "claude-opus-4-7", displayName: "Claude Opus 4.7" },
|
|
18
|
+
{ id: "claude-opus-4-6", displayName: "Claude Opus 4.6" },
|
|
19
|
+
{ id: "claude-fable-5", displayName: "Claude Fable 5" },
|
|
20
|
+
];
|
|
21
|
+
/** Build the `ModelInfo[]` for the claude runtime. */
|
|
22
|
+
export function claudeModelInfos() {
|
|
23
|
+
return CLAUDE_MODEL_IDS.map(({ id, displayName }) => ({
|
|
24
|
+
id,
|
|
25
|
+
model: id,
|
|
26
|
+
displayName,
|
|
27
|
+
isDefault: id === CLAUDE_DEFAULT_MODEL,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
/** {@link ModelListResponse} for the claude runtime (broker `listModels`). */
|
|
31
|
+
export function listClaudeModels() {
|
|
32
|
+
return { data: claudeModelInfos() };
|
|
33
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export declare const HOOKS_FILE = "hooks.jsonl";
|
|
2
|
+
export declare const STATE_FILE = "state.json";
|
|
3
|
+
export declare const DELTAS_FILE = "message_deltas.jsonl";
|
|
4
|
+
export declare const PERMISSION_FILE = "permission_hook.json";
|
|
5
|
+
export declare const STATUS_FILE = "status.json";
|
|
6
|
+
export declare const FORWARDER_STATE_FILE = "transcript_forwarder.json";
|
|
7
|
+
/** The deterministic bridge directory for a rynx session id. */
|
|
8
|
+
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
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
export declare function prepareClaudeBridgeDir(sessionId: string, permission?: PermissionHookConfig): string;
|
|
22
|
+
export interface ClaudeBridgeState {
|
|
23
|
+
/** Claude transcript JSONL path (from the SessionStart/any hook payload). */
|
|
24
|
+
transcriptPath?: string;
|
|
25
|
+
/** Claude's own session uuid (from the hook payload `session_id`). */
|
|
26
|
+
claudeSessionId?: string;
|
|
27
|
+
lastHookEventName?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function readClaudeState(bridgeDir: string): ClaudeBridgeState;
|
|
30
|
+
export declare function readPermissionHookConfig(bridgeDir: string): PermissionHookConfig | null;
|
|
31
|
+
/**
|
|
32
|
+
* Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
|
|
33
|
+
* `state.json` (transcript path + claude session id + last event). The payload
|
|
34
|
+
* is Claude's own stdin JSON, so its fields are snake_case (`hook_event_name`,
|
|
35
|
+
* `transcript_path`, `session_id`). state.json is a lock-free read-modify-write
|
|
36
|
+
* (last-writer-wins) — the folded fields are idempotent, so a race only re-writes
|
|
37
|
+
* the same values (matches omnigent's `record_hook_event`).
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordHookEvent(bridgeDir: string, payload: Record<string, unknown>): void;
|
|
40
|
+
export interface JsonlReadResult<T> {
|
|
41
|
+
records: T[];
|
|
42
|
+
/** Byte offset immediately after the last COMPLETE record; a partial trailing
|
|
43
|
+
* line is left unconsumed so the next poll retries it once its newline lands. */
|
|
44
|
+
nextOffset: number;
|
|
45
|
+
}
|
|
46
|
+
/** Read complete newline-terminated JSON records appended after `byteOffset`. */
|
|
47
|
+
export declare function readJsonlFrom<T = unknown>(path: string, byteOffset: number): JsonlReadResult<T>;
|
|
48
|
+
/** One parsed Claude hook event (the fields the forwarder acts on + the raw payload). */
|
|
49
|
+
export interface HookEvent {
|
|
50
|
+
eventName?: string;
|
|
51
|
+
transcriptPath?: string;
|
|
52
|
+
/** Claude's session uuid — subagent hooks report a `subagents/…` transcript path. */
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
source?: string;
|
|
55
|
+
payload: Record<string, unknown>;
|
|
56
|
+
}
|
|
57
|
+
/** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
|
|
58
|
+
export declare function readHookEventsFrom(bridgeDir: string, byteOffset: number): {
|
|
59
|
+
events: HookEvent[];
|
|
60
|
+
nextOffset: number;
|
|
61
|
+
};
|
|
62
|
+
/** One streamed assistant-text chunk from `message_deltas.jsonl` (MessageDisplay). */
|
|
63
|
+
export interface MessageDelta {
|
|
64
|
+
messageId: string;
|
|
65
|
+
index: number;
|
|
66
|
+
final: boolean;
|
|
67
|
+
delta: string;
|
|
68
|
+
}
|
|
69
|
+
/** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
|
|
70
|
+
export declare function readMessageDeltasFrom(bridgeDir: string, byteOffset: number): {
|
|
71
|
+
deltas: MessageDelta[];
|
|
72
|
+
nextOffset: number;
|
|
73
|
+
};
|
|
74
|
+
/** Append one streamed chunk to `message_deltas.jsonl` (the MessageDisplay hook). */
|
|
75
|
+
export declare function recordMessageDelta(bridgeDir: string, delta: MessageDelta): void;
|
|
76
|
+
/**
|
|
77
|
+
* The latest context-window + cost snapshot from claude's `statusLine` hook — the
|
|
78
|
+
* ONLY place claude surfaces the running `context_window` size/usage and cumulative
|
|
79
|
+
* cost. Normalized (camelCase) at write time so the forwarder consumes one shape.
|
|
80
|
+
*/
|
|
81
|
+
export interface ClaudeStatusState {
|
|
82
|
+
/** Max context tokens (`context_window.context_window_size`). */
|
|
83
|
+
contextWindowSize?: number;
|
|
84
|
+
/** Pre-computed fraction used (`context_window.used_percentage`, 0–100). */
|
|
85
|
+
usedPercentage?: number;
|
|
86
|
+
/** Context tokens currently in use (total_input + total_output). */
|
|
87
|
+
totalTokens?: number;
|
|
88
|
+
inputTokens?: number;
|
|
89
|
+
outputTokens?: number;
|
|
90
|
+
cacheReadTokens?: number;
|
|
91
|
+
cacheWriteTokens?: number;
|
|
92
|
+
/** Cumulative session cost (`cost.total_cost_usd`). */
|
|
93
|
+
costUsd?: number;
|
|
94
|
+
/** Live model id (`model.id`) — the only race-free source before the transcript. */
|
|
95
|
+
model?: string;
|
|
96
|
+
}
|
|
97
|
+
/** Overwrite `status.json` with the latest snapshot (last-writer-wins; statusLine
|
|
98
|
+
* fires on every TUI render so a plain write is enough — no append log). */
|
|
99
|
+
export declare function writeClaudeStatus(bridgeDir: string, status: ClaudeStatusState): void;
|
|
100
|
+
/**
|
|
101
|
+
* The forwarder's durable cursor into a claude transcript. Persisted so a
|
|
102
|
+
* re-launched forwarder (reconnect / daemon restart) resumes from where it left
|
|
103
|
+
* off instead of re-tailing from byte 0 and re-mirroring every past turn
|
|
104
|
+
* (duplicates). Ports omnigent's `TranscriptForwardState` / `transcript_forwarder.json`.
|
|
105
|
+
* NOT cleared by {@link prepareClaudeBridgeDir} — it must outlive a re-launch.
|
|
106
|
+
*/
|
|
107
|
+
export interface TranscriptForwardState {
|
|
108
|
+
/** The transcript file this cursor points into (guards against a stale file). */
|
|
109
|
+
transcriptPath: string;
|
|
110
|
+
/** Primary dedup: bytes already forwarded. A restart seeks here. */
|
|
111
|
+
byteOffset: number;
|
|
112
|
+
/** Secondary dedup: recently-forwarded record source ids (a bounded ring), so a
|
|
113
|
+
* re-read (fingerprint reset / mid-poll death) doesn't re-emit. */
|
|
114
|
+
seenSourceIds: string[];
|
|
115
|
+
/** SHA-256 of `byteOffset` (8-byte BE) + up to 256 bytes before it. On restart a
|
|
116
|
+
* mismatch means the file was truncated/replaced → don't seek into a stale offset. */
|
|
117
|
+
cursorFingerprint?: string;
|
|
118
|
+
}
|
|
119
|
+
/** Cap on `seenSourceIds` (matches omnigent's `_MAX_SEEN_SOURCE_IDS`). */
|
|
120
|
+
export declare const MAX_SEEN_SOURCE_IDS = 2000;
|
|
121
|
+
/** SHA-256 over `byteOffset` (8-byte big-endian) + up to 256 bytes immediately
|
|
122
|
+
* before it. Undefined if the file is shorter than `byteOffset` (truncated) or
|
|
123
|
+
* unreadable. Mirrors omnigent's `_jsonl_cursor_fingerprint`. */
|
|
124
|
+
export declare function jsonlCursorFingerprint(path: string, byteOffset: number): string | undefined;
|
|
125
|
+
/** Persist the forwarder cursor atomically (temp file + rename); `seenSourceIds`
|
|
126
|
+
* is bounded to the most-recent {@link MAX_SEEN_SOURCE_IDS}. */
|
|
127
|
+
export declare function writeForwardState(bridgeDir: string, state: TranscriptForwardState): void;
|
|
128
|
+
/** Read the forwarder cursor, or undefined if absent/malformed. */
|
|
129
|
+
export declare function readForwardState(bridgeDir: string): TranscriptForwardState | undefined;
|
|
130
|
+
/** Drop the forwarder cursor (a `/clear` starts fresh). */
|
|
131
|
+
export declare function resetForwardState(bridgeDir: string): void;
|
|
132
|
+
/** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
|
|
133
|
+
export declare function readClaudeStatus(bridgeDir: string): ClaudeStatusState | undefined;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude-native bridge directory — the filesystem rendezvous between the
|
|
3
|
+
* short-lived Claude Code hook subprocesses (which APPEND events) and the
|
|
4
|
+
* runner-child forwarder (which TAILS them). Mirrors omnigent's
|
|
5
|
+
* `claude_native_bridge` file surface:
|
|
6
|
+
* - `hooks.jsonl` append-only hook events (SessionStart / Stop / …)
|
|
7
|
+
* - `state.json` latest transcript_path + claude session id
|
|
8
|
+
* - `message_deltas.jsonl` streamed assistant-text chunks (MessageDisplay)
|
|
9
|
+
* - `permission_hook.json` server URL + auth for the blocking PermissionRequest hook
|
|
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.
|
|
14
|
+
*/
|
|
15
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
export const HOOKS_FILE = "hooks.jsonl";
|
|
20
|
+
export const STATE_FILE = "state.json";
|
|
21
|
+
export const DELTAS_FILE = "message_deltas.jsonl";
|
|
22
|
+
export const PERMISSION_FILE = "permission_hook.json";
|
|
23
|
+
export const STATUS_FILE = "status.json";
|
|
24
|
+
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). */
|
|
27
|
+
function bridgeRoot() {
|
|
28
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "nouid";
|
|
29
|
+
return join(tmpdir(), `rynx-${uid}`, "claude-native");
|
|
30
|
+
}
|
|
31
|
+
/** The deterministic bridge directory for a rynx session id. */
|
|
32
|
+
export function claudeBridgeDir(sessionId) {
|
|
33
|
+
const digest = createHash("sha256").update(sessionId).digest("hex").slice(0, 32);
|
|
34
|
+
return join(bridgeRoot(), digest);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
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.
|
|
41
|
+
*/
|
|
42
|
+
export function prepareClaudeBridgeDir(sessionId, permission) {
|
|
43
|
+
const dir = claudeBridgeDir(sessionId);
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
45
|
+
for (const file of [HOOKS_FILE, STATE_FILE, DELTAS_FILE, STATUS_FILE]) {
|
|
46
|
+
try {
|
|
47
|
+
rmSync(join(dir, file));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Absent on a cold start — expected.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (permission) {
|
|
54
|
+
writeFileSync(join(dir, PERMISSION_FILE), JSON.stringify(permission), { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
function readJsonFile(path) {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function asString(value) {
|
|
67
|
+
return typeof value === "string" && value ? value : undefined;
|
|
68
|
+
}
|
|
69
|
+
export function readClaudeState(bridgeDir) {
|
|
70
|
+
const raw = readJsonFile(join(bridgeDir, STATE_FILE));
|
|
71
|
+
if (!raw || typeof raw !== "object")
|
|
72
|
+
return {};
|
|
73
|
+
const s = raw;
|
|
74
|
+
return {
|
|
75
|
+
transcriptPath: asString(s.transcriptPath),
|
|
76
|
+
claudeSessionId: asString(s.claudeSessionId),
|
|
77
|
+
lastHookEventName: asString(s.lastHookEventName),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
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
|
+
/**
|
|
92
|
+
* Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
|
|
93
|
+
* `state.json` (transcript path + claude session id + last event). The payload
|
|
94
|
+
* is Claude's own stdin JSON, so its fields are snake_case (`hook_event_name`,
|
|
95
|
+
* `transcript_path`, `session_id`). state.json is a lock-free read-modify-write
|
|
96
|
+
* (last-writer-wins) — the folded fields are idempotent, so a race only re-writes
|
|
97
|
+
* the same values (matches omnigent's `record_hook_event`).
|
|
98
|
+
*/
|
|
99
|
+
export function recordHookEvent(bridgeDir, payload) {
|
|
100
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
101
|
+
const envelope = { recordedAt: Date.now(), payload };
|
|
102
|
+
appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
|
|
103
|
+
const state = readClaudeState(bridgeDir);
|
|
104
|
+
const eventName = asString(payload.hook_event_name);
|
|
105
|
+
if (eventName)
|
|
106
|
+
state.lastHookEventName = eventName;
|
|
107
|
+
const transcriptPath = asString(payload.transcript_path);
|
|
108
|
+
if (transcriptPath)
|
|
109
|
+
state.transcriptPath = transcriptPath;
|
|
110
|
+
const claudeSessionId = asString(payload.session_id);
|
|
111
|
+
if (claudeSessionId)
|
|
112
|
+
state.claudeSessionId = claudeSessionId;
|
|
113
|
+
writeFileSync(join(bridgeDir, STATE_FILE), JSON.stringify(state));
|
|
114
|
+
}
|
|
115
|
+
/** Read complete newline-terminated JSON records appended after `byteOffset`. */
|
|
116
|
+
export function readJsonlFrom(path, byteOffset) {
|
|
117
|
+
let info;
|
|
118
|
+
try {
|
|
119
|
+
info = statSync(path);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { records: [], nextOffset: byteOffset };
|
|
123
|
+
}
|
|
124
|
+
if (info.size <= byteOffset)
|
|
125
|
+
return { records: [], nextOffset: byteOffset };
|
|
126
|
+
const fd = openSync(path, "r");
|
|
127
|
+
try {
|
|
128
|
+
const length = info.size - byteOffset;
|
|
129
|
+
const buf = Buffer.alloc(length);
|
|
130
|
+
readSync(fd, buf, 0, length, byteOffset);
|
|
131
|
+
const text = buf.toString("utf8");
|
|
132
|
+
const lines = text.split("\n");
|
|
133
|
+
const trailing = lines.pop() ?? ""; // partial (no trailing newline yet)
|
|
134
|
+
const consumed = length - Buffer.byteLength(trailing, "utf8");
|
|
135
|
+
const records = [];
|
|
136
|
+
for (const line of lines) {
|
|
137
|
+
const trimmed = line.trim();
|
|
138
|
+
if (!trimmed)
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
records.push(JSON.parse(trimmed));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Skip a malformed complete line; the offset still advances past it.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { records, nextOffset: byteOffset + consumed };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
closeSync(fd);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
|
|
154
|
+
export function readHookEventsFrom(bridgeDir, byteOffset) {
|
|
155
|
+
const { records, nextOffset } = readJsonlFrom(join(bridgeDir, HOOKS_FILE), byteOffset);
|
|
156
|
+
const events = [];
|
|
157
|
+
for (const rec of records) {
|
|
158
|
+
const payload = rec.payload;
|
|
159
|
+
if (!payload || typeof payload !== "object")
|
|
160
|
+
continue;
|
|
161
|
+
events.push({
|
|
162
|
+
eventName: asString(payload.hook_event_name),
|
|
163
|
+
transcriptPath: asString(payload.transcript_path),
|
|
164
|
+
sessionId: asString(payload.session_id),
|
|
165
|
+
source: asString(payload.source),
|
|
166
|
+
payload,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return { events, nextOffset };
|
|
170
|
+
}
|
|
171
|
+
/** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
|
|
172
|
+
export function readMessageDeltasFrom(bridgeDir, byteOffset) {
|
|
173
|
+
const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
|
|
174
|
+
const deltas = [];
|
|
175
|
+
for (const r of records) {
|
|
176
|
+
const messageId = asString(r.message_id);
|
|
177
|
+
const delta = typeof r.delta === "string" ? r.delta : undefined;
|
|
178
|
+
const index = typeof r.index === "number" && !Number.isNaN(r.index) ? r.index : undefined;
|
|
179
|
+
if (messageId && delta !== undefined && index !== undefined) {
|
|
180
|
+
deltas.push({ messageId, index, final: Boolean(r.final), delta });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { deltas, nextOffset };
|
|
184
|
+
}
|
|
185
|
+
/** Append one streamed chunk to `message_deltas.jsonl` (the MessageDisplay hook). */
|
|
186
|
+
export function recordMessageDelta(bridgeDir, delta) {
|
|
187
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
188
|
+
const row = {
|
|
189
|
+
message_id: delta.messageId,
|
|
190
|
+
index: delta.index,
|
|
191
|
+
final: delta.final,
|
|
192
|
+
delta: delta.delta,
|
|
193
|
+
};
|
|
194
|
+
appendFileSync(join(bridgeDir, DELTAS_FILE), `${JSON.stringify(row)}\n`);
|
|
195
|
+
}
|
|
196
|
+
/** Overwrite `status.json` with the latest snapshot (last-writer-wins; statusLine
|
|
197
|
+
* fires on every TUI render so a plain write is enough — no append log). */
|
|
198
|
+
export function writeClaudeStatus(bridgeDir, status) {
|
|
199
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
200
|
+
writeFileSync(join(bridgeDir, STATUS_FILE), JSON.stringify(status));
|
|
201
|
+
}
|
|
202
|
+
function asNumber(value) {
|
|
203
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
204
|
+
}
|
|
205
|
+
/** Cap on `seenSourceIds` (matches omnigent's `_MAX_SEEN_SOURCE_IDS`). */
|
|
206
|
+
export const MAX_SEEN_SOURCE_IDS = 2000;
|
|
207
|
+
const CURSOR_FINGERPRINT_BYTES = 256;
|
|
208
|
+
/** SHA-256 over `byteOffset` (8-byte big-endian) + up to 256 bytes immediately
|
|
209
|
+
* before it. Undefined if the file is shorter than `byteOffset` (truncated) or
|
|
210
|
+
* unreadable. Mirrors omnigent's `_jsonl_cursor_fingerprint`. */
|
|
211
|
+
export function jsonlCursorFingerprint(path, byteOffset) {
|
|
212
|
+
let fd;
|
|
213
|
+
try {
|
|
214
|
+
if (statSync(path).size < byteOffset)
|
|
215
|
+
return undefined; // truncated below the cursor
|
|
216
|
+
fd = openSync(path, "r");
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const start = Math.max(0, byteOffset - CURSOR_FINGERPRINT_BYTES);
|
|
223
|
+
const len = byteOffset - start;
|
|
224
|
+
const sample = Buffer.alloc(len);
|
|
225
|
+
if (len > 0)
|
|
226
|
+
readSync(fd, sample, 0, len, start);
|
|
227
|
+
const prefix = Buffer.alloc(8);
|
|
228
|
+
prefix.writeBigUInt64BE(BigInt(byteOffset));
|
|
229
|
+
return createHash("sha256").update(prefix).update(sample).digest("hex");
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
closeSync(fd);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Persist the forwarder cursor atomically (temp file + rename); `seenSourceIds`
|
|
239
|
+
* is bounded to the most-recent {@link MAX_SEEN_SOURCE_IDS}. */
|
|
240
|
+
export function writeForwardState(bridgeDir, state) {
|
|
241
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
242
|
+
const bounded = {
|
|
243
|
+
...state,
|
|
244
|
+
seenSourceIds: state.seenSourceIds.slice(-MAX_SEEN_SOURCE_IDS),
|
|
245
|
+
};
|
|
246
|
+
const tmp = join(bridgeDir, `${FORWARDER_STATE_FILE}.tmp`);
|
|
247
|
+
writeFileSync(tmp, JSON.stringify(bounded));
|
|
248
|
+
renameSync(tmp, join(bridgeDir, FORWARDER_STATE_FILE));
|
|
249
|
+
}
|
|
250
|
+
/** Read the forwarder cursor, or undefined if absent/malformed. */
|
|
251
|
+
export function readForwardState(bridgeDir) {
|
|
252
|
+
const raw = readJsonFile(join(bridgeDir, FORWARDER_STATE_FILE));
|
|
253
|
+
if (!raw || typeof raw !== "object")
|
|
254
|
+
return undefined;
|
|
255
|
+
const s = raw;
|
|
256
|
+
const transcriptPath = asString(s.transcriptPath);
|
|
257
|
+
const byteOffset = asNumber(s.byteOffset);
|
|
258
|
+
if (!transcriptPath || byteOffset === undefined)
|
|
259
|
+
return undefined;
|
|
260
|
+
const seenSourceIds = Array.isArray(s.seenSourceIds)
|
|
261
|
+
? s.seenSourceIds.filter((x) => typeof x === "string")
|
|
262
|
+
: [];
|
|
263
|
+
const cursorFingerprint = asString(s.cursorFingerprint);
|
|
264
|
+
return { transcriptPath, byteOffset, seenSourceIds, ...(cursorFingerprint ? { cursorFingerprint } : {}) };
|
|
265
|
+
}
|
|
266
|
+
/** Drop the forwarder cursor (a `/clear` starts fresh). */
|
|
267
|
+
export function resetForwardState(bridgeDir) {
|
|
268
|
+
try {
|
|
269
|
+
rmSync(join(bridgeDir, FORWARDER_STATE_FILE));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// absent — fine
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
|
|
276
|
+
export function readClaudeStatus(bridgeDir) {
|
|
277
|
+
const raw = readJsonFile(join(bridgeDir, STATUS_FILE));
|
|
278
|
+
if (!raw || typeof raw !== "object")
|
|
279
|
+
return undefined;
|
|
280
|
+
const s = raw;
|
|
281
|
+
const out = {};
|
|
282
|
+
const n = (k) => {
|
|
283
|
+
const v = asNumber(s[k]);
|
|
284
|
+
if (v !== undefined)
|
|
285
|
+
out[k] = v;
|
|
286
|
+
};
|
|
287
|
+
n("contextWindowSize");
|
|
288
|
+
n("usedPercentage");
|
|
289
|
+
n("totalTokens");
|
|
290
|
+
n("inputTokens");
|
|
291
|
+
n("outputTokens");
|
|
292
|
+
n("cacheReadTokens");
|
|
293
|
+
n("cacheWriteTokens");
|
|
294
|
+
n("costUsd");
|
|
295
|
+
const model = asString(s.model);
|
|
296
|
+
if (model)
|
|
297
|
+
out.model = model;
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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";
|
|
18
|
+
function argValue(argv, flag) {
|
|
19
|
+
const i = argv.indexOf(flag);
|
|
20
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
21
|
+
}
|
|
22
|
+
async function readStdin() {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of process.stdin)
|
|
25
|
+
chunks.push(chunk);
|
|
26
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
27
|
+
}
|
|
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()));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Server unreachable / hung → empty stdout → claude falls back to its TUI prompt.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function main() {
|
|
49
|
+
const argv = process.argv.slice(2);
|
|
50
|
+
const isPermission = argv[0] === "permission-request";
|
|
51
|
+
const bridgeDir = argValue(argv, "--bridge-dir");
|
|
52
|
+
if (!bridgeDir)
|
|
53
|
+
return;
|
|
54
|
+
const raw = await readStdin();
|
|
55
|
+
let payload;
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(raw || "{}");
|
|
58
|
+
payload = parsed && typeof parsed === "object" ? parsed : {};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return; // malformed hook payload — don't block claude
|
|
62
|
+
}
|
|
63
|
+
if (isPermission) {
|
|
64
|
+
await relayPermission(bridgeDir, payload);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
recordHookEvent(bridgeDir, payload);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Best-effort: a failed append must not fail the hook (would wedge claude).
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
void main();
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
interface CommandHook {
|
|
2
|
+
type: "command";
|
|
3
|
+
command: string;
|
|
4
|
+
timeout?: number;
|
|
5
|
+
}
|
|
6
|
+
interface HookMatcher {
|
|
7
|
+
matcher?: string;
|
|
8
|
+
hooks: CommandHook[];
|
|
9
|
+
}
|
|
10
|
+
export interface ClaudeHookSettings {
|
|
11
|
+
hooks: Record<string, HookMatcher[]>;
|
|
12
|
+
/** Claude's top-level `statusLine` command (a sibling of `hooks`, not a hook) —
|
|
13
|
+
* captures the running context_window + cost, only surfaced here. */
|
|
14
|
+
statusLine?: CommandHook;
|
|
15
|
+
}
|
|
16
|
+
export interface BuildClaudeHookSettingsOptions {
|
|
17
|
+
/** The session's bridge dir (all hook subprocesses target it). */
|
|
18
|
+
bridgeDir: string;
|
|
19
|
+
/** Register the blocking PermissionRequest hook (approvals → web). */
|
|
20
|
+
permission?: boolean;
|
|
21
|
+
/** Register the MessageDisplay hook (live assistant-text streaming). */
|
|
22
|
+
messageDisplay?: boolean;
|
|
23
|
+
/** Register the statusLine command (context_window + cost capture). */
|
|
24
|
+
statusLine?: boolean;
|
|
25
|
+
/** Override the node binary (defaults to the current `process.execPath`). */
|
|
26
|
+
nodePath?: string;
|
|
27
|
+
/** Override the hook entrypoint path (defaults to the built sibling module). */
|
|
28
|
+
hookEntryPath?: string;
|
|
29
|
+
/** Override the MessageDisplay entrypoint path (tests). */
|
|
30
|
+
messageDisplayEntryPath?: string;
|
|
31
|
+
/** Override the statusLine entrypoint path (tests). */
|
|
32
|
+
statusEntryPath?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Absolute path to the built standalone hook entrypoint (sibling `.js`). */
|
|
35
|
+
export declare function resolveHookEntryPath(): string;
|
|
36
|
+
/** Absolute path to the built MessageDisplay hook entrypoint (sibling `.js`). */
|
|
37
|
+
export declare function resolveMessageDisplayEntryPath(): string;
|
|
38
|
+
/** Absolute path to the built statusLine entrypoint (sibling `.js`). */
|
|
39
|
+
export declare function resolveStatusEntryPath(): string;
|
|
40
|
+
export declare function buildClaudeHookSettings(options: BuildClaudeHookSettingsOptions): ClaudeHookSettings;
|
|
41
|
+
export {};
|