@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,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone Claude Code `MessageDisplay` hook (built to
|
|
4
|
+
* `dist/claude/native-message-display.js`). claude fires it per streamed
|
|
5
|
+
* assistant-text chunk; this appends the chunk to the session's
|
|
6
|
+
* `message_deltas.jsonl` for the forwarder to tail into live
|
|
7
|
+
* `response.output_text.delta` events. Kept dependency-light + fast (Claude
|
|
8
|
+
* blocks on the hook, so the per-chunk subprocess must stay cheap).
|
|
9
|
+
*
|
|
10
|
+
* Payload: `{ hook_event_name:"MessageDisplay", message_id, index, final, delta }`
|
|
11
|
+
* — `message_id` is stable per assistant message; consecutive `index` values
|
|
12
|
+
* carry disjoint text. `message_id` does NOT appear in the transcript, so the
|
|
13
|
+
* forwarder correlates the live buffer to the final item positionally (FIFO).
|
|
14
|
+
*/
|
|
15
|
+
import { recordMessageDelta } from "./native-bridge.js";
|
|
16
|
+
function argValue(argv, flag) {
|
|
17
|
+
const i = argv.indexOf(flag);
|
|
18
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
19
|
+
}
|
|
20
|
+
async function readStdin() {
|
|
21
|
+
const chunks = [];
|
|
22
|
+
for await (const chunk of process.stdin)
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
const bridgeDir = argValue(process.argv.slice(2), "--bridge-dir");
|
|
28
|
+
if (!bridgeDir)
|
|
29
|
+
return;
|
|
30
|
+
const raw = await readStdin();
|
|
31
|
+
let payload;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw || "{}");
|
|
34
|
+
payload = parsed && typeof parsed === "object" ? parsed : {};
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const messageId = typeof payload.message_id === "string" ? payload.message_id : undefined;
|
|
40
|
+
const delta = typeof payload.delta === "string" ? payload.delta : undefined;
|
|
41
|
+
const index = typeof payload.index === "number" && !Number.isNaN(payload.index) ? payload.index : undefined;
|
|
42
|
+
if (!messageId || delta === undefined || index === undefined)
|
|
43
|
+
return; // common no-op
|
|
44
|
+
try {
|
|
45
|
+
recordMessageDelta(bridgeDir, { messageId, index, final: Boolean(payload.final), delta });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Best-effort: a failed append must not fail the hook (would wedge claude).
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
void main();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone Claude Code `statusLine` command (built to
|
|
4
|
+
* `dist/claude/native-status.js`). claude invokes it on every TUI render with a
|
|
5
|
+
* JSON status snapshot on stdin — the ONLY place it surfaces the running
|
|
6
|
+
* `context_window` size/usage and cumulative `cost`. This normalizes those
|
|
7
|
+
* fields into `status.json` for the forwarder to pick up (→ turn usage +
|
|
8
|
+
* context-threshold banner), then prints a compact line back to stdout so the
|
|
9
|
+
* co-driven TUI's status bar still shows something.
|
|
10
|
+
*
|
|
11
|
+
* stdin (claude v2.1.x, snake_case): `{ model:{id,display_name}, cost:{total_cost_usd},
|
|
12
|
+
* context_window:{ context_window_size, used_percentage, total_input_tokens,
|
|
13
|
+
* total_output_tokens, current_usage:{ input_tokens, output_tokens,
|
|
14
|
+
* cache_creation_input_tokens, cache_read_input_tokens } } }`.
|
|
15
|
+
*/
|
|
16
|
+
import { writeClaudeStatus } from "./native-bridge.js";
|
|
17
|
+
function argValue(argv, flag) {
|
|
18
|
+
const i = argv.indexOf(flag);
|
|
19
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
|
|
20
|
+
}
|
|
21
|
+
async function readStdin() {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin)
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
}
|
|
27
|
+
function isRecord(value) {
|
|
28
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
|
+
}
|
|
30
|
+
function num(value) {
|
|
31
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
function parseStatus(payload) {
|
|
34
|
+
const status = {};
|
|
35
|
+
const ctx = isRecord(payload.context_window) ? payload.context_window : {};
|
|
36
|
+
const usage = isRecord(ctx.current_usage) ? ctx.current_usage : {};
|
|
37
|
+
const cost = isRecord(payload.cost) ? payload.cost : {};
|
|
38
|
+
const model = isRecord(payload.model) ? payload.model : {};
|
|
39
|
+
const size = num(ctx.context_window_size);
|
|
40
|
+
if (size !== undefined)
|
|
41
|
+
status.contextWindowSize = size;
|
|
42
|
+
const used = num(ctx.used_percentage);
|
|
43
|
+
if (used !== undefined)
|
|
44
|
+
status.usedPercentage = used;
|
|
45
|
+
const totalIn = num(ctx.total_input_tokens);
|
|
46
|
+
const totalOut = num(ctx.total_output_tokens);
|
|
47
|
+
if (totalIn !== undefined || totalOut !== undefined)
|
|
48
|
+
status.totalTokens = (totalIn ?? 0) + (totalOut ?? 0);
|
|
49
|
+
const inTok = num(usage.input_tokens);
|
|
50
|
+
if (inTok !== undefined)
|
|
51
|
+
status.inputTokens = inTok;
|
|
52
|
+
const outTok = num(usage.output_tokens);
|
|
53
|
+
if (outTok !== undefined)
|
|
54
|
+
status.outputTokens = outTok;
|
|
55
|
+
const cacheRead = num(usage.cache_read_input_tokens);
|
|
56
|
+
if (cacheRead !== undefined)
|
|
57
|
+
status.cacheReadTokens = cacheRead;
|
|
58
|
+
const cacheWrite = num(usage.cache_creation_input_tokens);
|
|
59
|
+
if (cacheWrite !== undefined)
|
|
60
|
+
status.cacheWriteTokens = cacheWrite;
|
|
61
|
+
const costUsd = num(cost.total_cost_usd);
|
|
62
|
+
if (costUsd !== undefined)
|
|
63
|
+
status.costUsd = costUsd;
|
|
64
|
+
const modelId = typeof model.id === "string" ? model.id : undefined;
|
|
65
|
+
if (modelId)
|
|
66
|
+
status.model = modelId;
|
|
67
|
+
return status;
|
|
68
|
+
}
|
|
69
|
+
/** A compact one-line status for the TUI bar (claude renders stdout verbatim). */
|
|
70
|
+
function statusLine(status) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (status.model)
|
|
73
|
+
parts.push(status.model);
|
|
74
|
+
if (status.usedPercentage !== undefined)
|
|
75
|
+
parts.push(`ctx ${Math.round(status.usedPercentage)}%`);
|
|
76
|
+
if (status.costUsd !== undefined)
|
|
77
|
+
parts.push(`$${status.costUsd.toFixed(4)}`);
|
|
78
|
+
return parts.join(" · ");
|
|
79
|
+
}
|
|
80
|
+
async function main() {
|
|
81
|
+
const bridgeDir = argValue(process.argv.slice(2), "--bridge-dir");
|
|
82
|
+
const raw = await readStdin();
|
|
83
|
+
let payload = {};
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw || "{}");
|
|
86
|
+
if (parsed && typeof parsed === "object")
|
|
87
|
+
payload = parsed;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const status = parseStatus(payload);
|
|
93
|
+
if (bridgeDir) {
|
|
94
|
+
try {
|
|
95
|
+
writeClaudeStatus(bridgeDir, status);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Best-effort: a failed write must not fail the status hook (would wedge the TUI).
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const line = statusLine(status);
|
|
102
|
+
if (line)
|
|
103
|
+
process.stdout.write(line);
|
|
104
|
+
}
|
|
105
|
+
void main();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CodexRuntimeStatus } from "../host.js";
|
|
2
|
+
import type { CodexSessionStore } from "../codex-session-store.js";
|
|
3
|
+
/**
|
|
4
|
+
* Whether the `claude` CLI is runnable — used by the status probe. Resolves a
|
|
5
|
+
* bare name against `PATH`; an absolute/relative path is checked directly.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isClaudeCliAvailable(binary?: string): Promise<boolean>;
|
|
8
|
+
export interface ReadClaudeStatusDeps {
|
|
9
|
+
sessionStore: CodexSessionStore;
|
|
10
|
+
/** Process env (injectable for tests). */
|
|
11
|
+
env?: NodeJS.ProcessEnv;
|
|
12
|
+
/** Whether the `claude` CLI is runnable (injectable for tests). */
|
|
13
|
+
sdkAvailable?: () => Promise<boolean>;
|
|
14
|
+
/** Path-exists probe (injectable for tests). */
|
|
15
|
+
pathExists?: (target: string) => Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Compute the runtime status for the claude runtime. Mirrors the
|
|
19
|
+
* {@link CodexRuntimeStatus} shape the host's status probe produces, but probes
|
|
20
|
+
* the local `claude` CLI + Anthropic credentials instead of a codex app-server /
|
|
21
|
+
* `<bin> login status`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readClaudeStatus({ sessionStore, env, sdkAvailable, pathExists, }: ReadClaudeStatusDeps): Promise<CodexRuntimeStatus>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { access, stat } from "node:fs/promises";
|
|
2
|
+
import { accessSync, constants as fsConstants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getRuntimeProfile, resolveRuntimeHome, resolveRuntimeSessionsRoot, } from "@rynx-ai/core";
|
|
5
|
+
/**
|
|
6
|
+
* Whether the `claude` CLI is runnable — used by the status probe. Resolves a
|
|
7
|
+
* bare name against `PATH`; an absolute/relative path is checked directly.
|
|
8
|
+
*/
|
|
9
|
+
export async function isClaudeCliAvailable(binary = "claude") {
|
|
10
|
+
if (binary.includes("/")) {
|
|
11
|
+
try {
|
|
12
|
+
accessSync(binary, fsConstants.X_OK);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
20
|
+
if (!dir)
|
|
21
|
+
continue;
|
|
22
|
+
try {
|
|
23
|
+
accessSync(path.join(dir, binary), fsConstants.X_OK);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* keep scanning */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Compute the runtime status for the claude runtime. Mirrors the
|
|
34
|
+
* {@link CodexRuntimeStatus} shape the host's status probe produces, but probes
|
|
35
|
+
* the local `claude` CLI + Anthropic credentials instead of a codex app-server /
|
|
36
|
+
* `<bin> login status`.
|
|
37
|
+
*/
|
|
38
|
+
export async function readClaudeStatus({ sessionStore, env = process.env, sdkAvailable = isClaudeCliAvailable, pathExists = defaultPathExists, }) {
|
|
39
|
+
const issues = [];
|
|
40
|
+
const profile = getRuntimeProfile("claude");
|
|
41
|
+
const codexAvailable = await sdkAvailable();
|
|
42
|
+
if (!codexAvailable) {
|
|
43
|
+
issues.push("未找到 `claude` CLI:请安装 Claude Code 并确保 `claude` 在 PATH 上。");
|
|
44
|
+
}
|
|
45
|
+
const { loggedIn, authMode } = await resolveClaudeAuth(env, profile, pathExists);
|
|
46
|
+
if (!authMode) {
|
|
47
|
+
// We couldn't positively confirm auth (oauth creds may live in the OS
|
|
48
|
+
// keychain, or a gateway token may be injected later). Don't block the
|
|
49
|
+
// runtime on this — a genuinely-unauthenticated run surfaces a clear auth
|
|
50
|
+
// error from the SDK at turn time. Surface it as an informational note.
|
|
51
|
+
issues.push("未确认 Claude 鉴权:若运行报鉴权错误,请设置 ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN 或运行 `claude /login`。");
|
|
52
|
+
}
|
|
53
|
+
const sessionStoreWritable = await sessionStore.isWritable();
|
|
54
|
+
if (!sessionStoreWritable) {
|
|
55
|
+
issues.push(`Session store is not writable: ${sessionStore.filePath}`);
|
|
56
|
+
}
|
|
57
|
+
const sessionsAccessible = await checkClaudeHomeAccessible(pathExists);
|
|
58
|
+
if (!sessionsAccessible) {
|
|
59
|
+
issues.push(`${profile.displayName} home ${resolveRuntimeHome(profile)} 不可访问。`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
codex_available: codexAvailable,
|
|
63
|
+
logged_in: loggedIn,
|
|
64
|
+
auth_mode: authMode,
|
|
65
|
+
session_store_writable: sessionStoreWritable,
|
|
66
|
+
codex_sessions_accessible: sessionsAccessible,
|
|
67
|
+
issues,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async function resolveClaudeAuth(env, profile, pathExists) {
|
|
71
|
+
if (env.ANTHROPIC_API_KEY?.trim()) {
|
|
72
|
+
return { loggedIn: true, authMode: "api_key" };
|
|
73
|
+
}
|
|
74
|
+
if (env.ANTHROPIC_AUTH_TOKEN?.trim()) {
|
|
75
|
+
return { loggedIn: true, authMode: "auth_token" };
|
|
76
|
+
}
|
|
77
|
+
// Fall back to a stored Claude Code login (oauth). The credentials file lives
|
|
78
|
+
// under the claude home; on some platforms it may be in the OS keychain
|
|
79
|
+
// instead, so a miss here is "unknown", surfaced as not-logged-in.
|
|
80
|
+
const home = resolveRuntimeHome(profile, env);
|
|
81
|
+
const credentialsFile = path.join(home, ".credentials.json");
|
|
82
|
+
if (await pathExists(credentialsFile)) {
|
|
83
|
+
return { loggedIn: true, authMode: "oauth" };
|
|
84
|
+
}
|
|
85
|
+
// Uncertain — be lenient (don't block); auth_mode null signals "unconfirmed".
|
|
86
|
+
return { loggedIn: true, authMode: null };
|
|
87
|
+
}
|
|
88
|
+
async function checkClaudeHomeAccessible(pathExists) {
|
|
89
|
+
const profile = getRuntimeProfile("claude");
|
|
90
|
+
const home = resolveRuntimeHome(profile);
|
|
91
|
+
const sessionsRoot = resolveRuntimeSessionsRoot(profile);
|
|
92
|
+
try {
|
|
93
|
+
if (await pathExists(sessionsRoot)) {
|
|
94
|
+
await access(sessionsRoot, fsConstants.R_OK | fsConstants.W_OK);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (await pathExists(home)) {
|
|
98
|
+
await access(home, fsConstants.R_OK | fsConstants.W_OK);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
// Home not created yet — the SDK will create it on first run. Treat the
|
|
102
|
+
// parent (HOME) being writable as accessible.
|
|
103
|
+
await access(path.dirname(home), fsConstants.R_OK | fsConstants.W_OK);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function defaultPathExists(target) {
|
|
111
|
+
try {
|
|
112
|
+
await stat(target);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { AgentEvent, TerminalCommandData } from "@rynx-ai/core";
|
|
2
|
+
/** Claude Code encodes a cwd into a project-dir name by replacing `/` and `.`
|
|
3
|
+
* with `-` (e.g. `/Users/x/Workspace/rynx` → `-Users-x-Workspace-rynx`). */
|
|
4
|
+
export declare function encodeClaudeProjectDir(cwd: string): string;
|
|
5
|
+
/** The directory Claude Code writes this cwd's session transcripts into. */
|
|
6
|
+
export declare function claudeProjectDir(cwd: string, home?: string): string;
|
|
7
|
+
/** The transcript file for a specific claude session id. */
|
|
8
|
+
export declare function claudeTranscriptPath(cwd: string, sessionId: string, home?: string): string;
|
|
9
|
+
/** A sub-agent (Task) writes its own transcript to
|
|
10
|
+
* `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
|
|
11
|
+
* `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
|
|
12
|
+
export declare function subagentTranscriptPath(parentTranscriptPath: string, agentId: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Parse a claude local-command (`!` bash mode) user record's string content into
|
|
15
|
+
* a {@link TerminalCommandData}. Claude records the command and its captured
|
|
16
|
+
* output together as `<bash-input>` / `<bash-stdout>` / `<bash-stderr>` markers
|
|
17
|
+
* inside one `role:user` string message. Returns undefined when no `<bash-input>`
|
|
18
|
+
* is present — other `<…>`-marker user records (slash commands, `<caveat>`,
|
|
19
|
+
* `<system-reminder>`) are bookkeeping, not terminal commands.
|
|
20
|
+
*/
|
|
21
|
+
export declare function parseTerminalCommand(content: string): TerminalCommandData | undefined;
|
|
22
|
+
/** Options for {@link parseTranscriptRecord} / {@link parseTranscriptLine}. */
|
|
23
|
+
export interface ParseTranscriptOptions {
|
|
24
|
+
/** When set, the record is a sub-agent (Task) record: sidechains are kept
|
|
25
|
+
* (not skipped) and every emitted event is tagged with this parent Task
|
|
26
|
+
* tool-use id, so the canonical layer nests them under that call. */
|
|
27
|
+
parentToolUseId?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse one transcript JSONL line into zero or more {@link AgentEvent}s. Returns
|
|
31
|
+
* `[]` for blank lines, malformed JSON, sidechains, and non-content records.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseTranscriptLine(line: string, opts?: ParseTranscriptOptions): AgentEvent[];
|
|
34
|
+
/**
|
|
35
|
+
* Map an already-parsed transcript record into {@link AgentEvent}s — identical
|
|
36
|
+
* semantics to {@link parseTranscriptLine}, which is a thin JSON wrapper over
|
|
37
|
+
* this. Exposed so the claude-native forwarder can dispatch a record it already
|
|
38
|
+
* parsed (to first detect a `role:user` prompt) without re-stringifying it.
|
|
39
|
+
*
|
|
40
|
+
* In the main transcript, sub-agent sidechain records are skipped — they live in
|
|
41
|
+
* `subagents/agent-<id>.jsonl` and are replayed with {@link opts.parentToolUseId}
|
|
42
|
+
* set (which keeps them and tags each event with the parent Task's id).
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseTranscriptRecord(record: unknown, opts?: ParseTranscriptOptions): AgentEvent[];
|
|
45
|
+
/**
|
|
46
|
+
* Whether a transcript is a `/fork` (branch) of another session: claude stamps a
|
|
47
|
+
* `forkedFrom: { sessionId }` marker in an early record pointing at the source
|
|
48
|
+
* session (omnigent's `transcript_has_forked_from_marker`). Used to distinguish a
|
|
49
|
+
* fork from an ordinary `resume` (both arrive as `SessionStart source="resume"`).
|
|
50
|
+
* Scans only the head of the file (the marker lands up front). NOTE: unverified on
|
|
51
|
+
* this host — no local transcript carries the marker — so it follows omnigent's shape.
|
|
52
|
+
*/
|
|
53
|
+
export declare function transcriptHasForkedFrom(path: string, currentSessionId?: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
|
|
56
|
+
* tagged with `parentToolUseId`. Called once the parent Task tool_result arrives
|
|
57
|
+
* (the sub-agent file is complete by then), so a plain synchronous read is enough
|
|
58
|
+
* and there is no live-correlation race. The sub-agent's leading string-content
|
|
59
|
+
* user record is its task prompt (already shown in the Task tool's arguments) and
|
|
60
|
+
* is naturally skipped by {@link parseTranscriptLine} (non-array content).
|
|
61
|
+
* Returns `[]` if the file is missing/unreadable (best-effort enrichment).
|
|
62
|
+
*/
|
|
63
|
+
export declare function readSubagentEvents(path: string, parentToolUseId: string): AgentEvent[];
|
|
64
|
+
export interface TailOptions {
|
|
65
|
+
/** Byte offset to resume from (persist across restarts for `--resume`). */
|
|
66
|
+
fromOffset?: number;
|
|
67
|
+
/** Poll interval; the JSONL is append-only so polling is simplest/robust. */
|
|
68
|
+
pollMs?: number;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
/** Called with the current byte offset after each read (cursor persistence). */
|
|
71
|
+
onOffset?: (offset: number) => void;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Tail a Claude transcript JSONL, yielding {@link AgentEvent}s for each new
|
|
75
|
+
* complete line. Append-only, so a byte-offset cursor + polling is enough; a
|
|
76
|
+
* partial trailing line is buffered until its newline arrives. Ends when the
|
|
77
|
+
* signal aborts.
|
|
78
|
+
*/
|
|
79
|
+
export declare function tailTranscript(path: string, opts?: TailOptions): AsyncGenerator<AgentEvent>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code transcript reader (Phase E, claude-native). Claude Code appends a
|
|
3
|
+
* JSONL transcript per session at
|
|
4
|
+
* `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl`; the interactive TUI's
|
|
5
|
+
* stdout is a full-screen UI, so — like omnigent's claude-native — we take the
|
|
6
|
+
* structured story from this file, not the PTY. Each `user`/`assistant` record
|
|
7
|
+
* wraps an Anthropic message (`{ role, content: [blocks] }`), so we map its
|
|
8
|
+
* blocks to the same typed {@link AgentEvent}s the headless executor emits.
|
|
9
|
+
*
|
|
10
|
+
* Records are full messages (not deltas), so text → a completed message,
|
|
11
|
+
* thinking → completed reasoning, tool_use → tool start, tool_result → tool end.
|
|
12
|
+
* Non-content records (attachment/system/mode/…) and sub-agent sidechains are
|
|
13
|
+
* skipped.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { open, stat } from "node:fs/promises";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
/** Claude Code encodes a cwd into a project-dir name by replacing `/` and `.`
|
|
20
|
+
* with `-` (e.g. `/Users/x/Workspace/rynx` → `-Users-x-Workspace-rynx`). */
|
|
21
|
+
export function encodeClaudeProjectDir(cwd) {
|
|
22
|
+
return cwd.replace(/[/.]/g, "-");
|
|
23
|
+
}
|
|
24
|
+
/** The directory Claude Code writes this cwd's session transcripts into. */
|
|
25
|
+
export function claudeProjectDir(cwd, home = homedir()) {
|
|
26
|
+
return join(home, ".claude", "projects", encodeClaudeProjectDir(cwd));
|
|
27
|
+
}
|
|
28
|
+
/** The transcript file for a specific claude session id. */
|
|
29
|
+
export function claudeTranscriptPath(cwd, sessionId, home = homedir()) {
|
|
30
|
+
return join(claudeProjectDir(cwd, home), `${sessionId}.jsonl`);
|
|
31
|
+
}
|
|
32
|
+
/** A sub-agent (Task) writes its own transcript to
|
|
33
|
+
* `<project>/<sessionId>/subagents/agent-<agentId>.jsonl`, alongside the parent
|
|
34
|
+
* `<sessionId>.jsonl`. Derive that path from the parent transcript path. */
|
|
35
|
+
export function subagentTranscriptPath(parentTranscriptPath, agentId) {
|
|
36
|
+
const dir = parentTranscriptPath.replace(/\.jsonl$/, "");
|
|
37
|
+
return join(dir, "subagents", `agent-${agentId}.jsonl`);
|
|
38
|
+
}
|
|
39
|
+
function stringifyToolContent(content) {
|
|
40
|
+
if (typeof content === "string")
|
|
41
|
+
return content;
|
|
42
|
+
if (Array.isArray(content)) {
|
|
43
|
+
return content
|
|
44
|
+
.map((part) => part && typeof part === "object" && "text" in part
|
|
45
|
+
? String(part.text ?? "")
|
|
46
|
+
: typeof part === "string"
|
|
47
|
+
? part
|
|
48
|
+
: JSON.stringify(part))
|
|
49
|
+
.join("");
|
|
50
|
+
}
|
|
51
|
+
return content == null ? "" : JSON.stringify(content);
|
|
52
|
+
}
|
|
53
|
+
function toolLabel(name, input) {
|
|
54
|
+
if (input && typeof input === "object") {
|
|
55
|
+
const rec = input;
|
|
56
|
+
for (const key of ["command", "cmd", "path", "file_path", "pattern", "query"]) {
|
|
57
|
+
const v = rec[key];
|
|
58
|
+
if (typeof v === "string" && v)
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return name ?? "tool";
|
|
63
|
+
}
|
|
64
|
+
const BASH_INPUT_RE = /<bash-input>([\s\S]*?)<\/bash-input>/;
|
|
65
|
+
const BASH_STDOUT_RE = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
|
|
66
|
+
const BASH_STDERR_RE = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
|
|
67
|
+
/**
|
|
68
|
+
* Parse a claude local-command (`!` bash mode) user record's string content into
|
|
69
|
+
* a {@link TerminalCommandData}. Claude records the command and its captured
|
|
70
|
+
* output together as `<bash-input>` / `<bash-stdout>` / `<bash-stderr>` markers
|
|
71
|
+
* inside one `role:user` string message. Returns undefined when no `<bash-input>`
|
|
72
|
+
* is present — other `<…>`-marker user records (slash commands, `<caveat>`,
|
|
73
|
+
* `<system-reminder>`) are bookkeeping, not terminal commands.
|
|
74
|
+
*/
|
|
75
|
+
export function parseTerminalCommand(content) {
|
|
76
|
+
const input = BASH_INPUT_RE.exec(content);
|
|
77
|
+
if (!input)
|
|
78
|
+
return undefined;
|
|
79
|
+
const data = { command: input[1].trim() };
|
|
80
|
+
const stdout = BASH_STDOUT_RE.exec(content)?.[1];
|
|
81
|
+
const stderr = BASH_STDERR_RE.exec(content)?.[1];
|
|
82
|
+
if (stdout)
|
|
83
|
+
data.stdout = stdout;
|
|
84
|
+
if (stderr)
|
|
85
|
+
data.stderr = stderr;
|
|
86
|
+
return data;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse one transcript JSONL line into zero or more {@link AgentEvent}s. Returns
|
|
90
|
+
* `[]` for blank lines, malformed JSON, sidechains, and non-content records.
|
|
91
|
+
*/
|
|
92
|
+
export function parseTranscriptLine(line, opts) {
|
|
93
|
+
const trimmed = line.trim();
|
|
94
|
+
if (!trimmed)
|
|
95
|
+
return [];
|
|
96
|
+
try {
|
|
97
|
+
return parseTranscriptRecord(JSON.parse(trimmed), opts);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Map an already-parsed transcript record into {@link AgentEvent}s — identical
|
|
105
|
+
* semantics to {@link parseTranscriptLine}, which is a thin JSON wrapper over
|
|
106
|
+
* this. Exposed so the claude-native forwarder can dispatch a record it already
|
|
107
|
+
* parsed (to first detect a `role:user` prompt) without re-stringifying it.
|
|
108
|
+
*
|
|
109
|
+
* In the main transcript, sub-agent sidechain records are skipped — they live in
|
|
110
|
+
* `subagents/agent-<id>.jsonl` and are replayed with {@link opts.parentToolUseId}
|
|
111
|
+
* set (which keeps them and tags each event with the parent Task's id).
|
|
112
|
+
*/
|
|
113
|
+
export function parseTranscriptRecord(record, opts) {
|
|
114
|
+
const rec = record;
|
|
115
|
+
const parent = opts?.parentToolUseId;
|
|
116
|
+
const parentTag = parent ? { parentToolUseId: parent } : {};
|
|
117
|
+
if (rec.isSidechain && !parent)
|
|
118
|
+
return []; // sub-agent turns are replayed separately
|
|
119
|
+
const message = rec.message;
|
|
120
|
+
if (!message || !Array.isArray(message.content))
|
|
121
|
+
return [];
|
|
122
|
+
const out = [];
|
|
123
|
+
if (rec.type === "assistant" && message.role === "assistant") {
|
|
124
|
+
const itemId = message.id;
|
|
125
|
+
const texts = [];
|
|
126
|
+
for (const block of message.content) {
|
|
127
|
+
if (block.type === "text" && block.text) {
|
|
128
|
+
texts.push(block.text);
|
|
129
|
+
}
|
|
130
|
+
else if (block.type === "thinking" && block.thinking) {
|
|
131
|
+
out.push({ type: "reasoning_completed", summary: [block.thinking], ...(itemId ? { itemId } : {}), ...parentTag });
|
|
132
|
+
}
|
|
133
|
+
else if (block.type === "tool_use") {
|
|
134
|
+
out.push({
|
|
135
|
+
type: "tool",
|
|
136
|
+
event: "on_tool_start",
|
|
137
|
+
name: block.name,
|
|
138
|
+
input: { ...(isObject(block.input) ? block.input : {}), id: block.id, command: toolLabel(block.name, block.input) },
|
|
139
|
+
data: { id: block.id },
|
|
140
|
+
...parentTag,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (texts.length) {
|
|
145
|
+
out.push({ type: "message_completed", text: texts.join(""), ...(itemId ? { itemId } : {}), ...parentTag });
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
if (rec.type === "user" && message.role === "user") {
|
|
150
|
+
for (const block of message.content) {
|
|
151
|
+
if (block.type === "tool_result") {
|
|
152
|
+
out.push({
|
|
153
|
+
type: "tool",
|
|
154
|
+
event: "on_tool_end",
|
|
155
|
+
output: {
|
|
156
|
+
id: block.tool_use_id,
|
|
157
|
+
status: block.is_error ? "failed" : "completed",
|
|
158
|
+
aggregatedOutput: stringifyToolContent(block.content),
|
|
159
|
+
exitCode: null,
|
|
160
|
+
},
|
|
161
|
+
...(block.is_error ? { error: "tool_error" } : {}),
|
|
162
|
+
data: { tool_use_id: block.tool_use_id },
|
|
163
|
+
...parentTag,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
function isObject(value) {
|
|
171
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Whether a transcript is a `/fork` (branch) of another session: claude stamps a
|
|
175
|
+
* `forkedFrom: { sessionId }` marker in an early record pointing at the source
|
|
176
|
+
* session (omnigent's `transcript_has_forked_from_marker`). Used to distinguish a
|
|
177
|
+
* fork from an ordinary `resume` (both arrive as `SessionStart source="resume"`).
|
|
178
|
+
* Scans only the head of the file (the marker lands up front). NOTE: unverified on
|
|
179
|
+
* this host — no local transcript carries the marker — so it follows omnigent's shape.
|
|
180
|
+
*/
|
|
181
|
+
export function transcriptHasForkedFrom(path, currentSessionId) {
|
|
182
|
+
let text;
|
|
183
|
+
try {
|
|
184
|
+
text = readFileSync(path, "utf8");
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
let scanned = 0;
|
|
190
|
+
for (const line of text.split("\n")) {
|
|
191
|
+
if (++scanned > 200)
|
|
192
|
+
break;
|
|
193
|
+
const trimmed = line.trim();
|
|
194
|
+
if (!trimmed || !trimmed.includes("forkedFrom"))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const rec = JSON.parse(trimmed);
|
|
198
|
+
const from = rec.forkedFrom?.sessionId;
|
|
199
|
+
if (typeof from === "string" && from && from !== currentSessionId)
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// skip malformed
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Read a sub-agent (Task) transcript in full and map it to {@link AgentEvent}s
|
|
210
|
+
* tagged with `parentToolUseId`. Called once the parent Task tool_result arrives
|
|
211
|
+
* (the sub-agent file is complete by then), so a plain synchronous read is enough
|
|
212
|
+
* and there is no live-correlation race. The sub-agent's leading string-content
|
|
213
|
+
* user record is its task prompt (already shown in the Task tool's arguments) and
|
|
214
|
+
* is naturally skipped by {@link parseTranscriptLine} (non-array content).
|
|
215
|
+
* Returns `[]` if the file is missing/unreadable (best-effort enrichment).
|
|
216
|
+
*/
|
|
217
|
+
export function readSubagentEvents(path, parentToolUseId) {
|
|
218
|
+
let text;
|
|
219
|
+
try {
|
|
220
|
+
text = readFileSync(path, "utf8");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const line of text.split("\n")) {
|
|
227
|
+
for (const event of parseTranscriptLine(line, { parentToolUseId }))
|
|
228
|
+
out.push(event);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
async function readFrom(path, offset, end) {
|
|
233
|
+
const fh = await open(path, "r");
|
|
234
|
+
try {
|
|
235
|
+
const length = end - offset;
|
|
236
|
+
const buf = Buffer.alloc(length);
|
|
237
|
+
await fh.read(buf, 0, length, offset);
|
|
238
|
+
return buf.toString("utf8");
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await fh.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Tail a Claude transcript JSONL, yielding {@link AgentEvent}s for each new
|
|
246
|
+
* complete line. Append-only, so a byte-offset cursor + polling is enough; a
|
|
247
|
+
* partial trailing line is buffered until its newline arrives. Ends when the
|
|
248
|
+
* signal aborts.
|
|
249
|
+
*/
|
|
250
|
+
export async function* tailTranscript(path, opts = {}) {
|
|
251
|
+
const pollMs = opts.pollMs ?? 200;
|
|
252
|
+
let offset = opts.fromOffset ?? 0;
|
|
253
|
+
let buffer = "";
|
|
254
|
+
while (!opts.signal?.aborted) {
|
|
255
|
+
const info = await stat(path).catch(() => null);
|
|
256
|
+
if (info && info.size > offset) {
|
|
257
|
+
buffer += await readFrom(path, offset, info.size);
|
|
258
|
+
offset = info.size;
|
|
259
|
+
opts.onOffset?.(offset);
|
|
260
|
+
const lines = buffer.split("\n");
|
|
261
|
+
buffer = lines.pop() ?? "";
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
for (const event of parseTranscriptLine(line)) {
|
|
264
|
+
yield event;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (opts.signal?.aborted)
|
|
269
|
+
break;
|
|
270
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ensure `cwd` is pre-trusted in `~/.claude.json`. Idempotent; throws (rather
|
|
3
|
+
* than clobbering) if an existing config isn't shaped as expected. Opt out with
|
|
4
|
+
* `RYNX_CLAUDE_PRESEED_TRUST=0`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function ensureProjectTrusted(cwd: string, configPath?: string): void;
|