claude-code-rust 0.3.0 → 0.4.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/README.md +8 -20
- package/agent-sdk/README.md +13 -0
- package/agent-sdk/dist/bridge/auth.js +7 -0
- package/agent-sdk/dist/bridge/commands.js +163 -0
- package/agent-sdk/dist/bridge/history.js +344 -0
- package/agent-sdk/dist/bridge/permissions.js +99 -0
- package/agent-sdk/dist/bridge/shared.js +6 -0
- package/agent-sdk/dist/bridge/tooling.js +290 -0
- package/agent-sdk/dist/bridge/usage.js +95 -0
- package/agent-sdk/dist/bridge.js +1224 -0
- package/agent-sdk/dist/bridge.test.js +445 -0
- package/agent-sdk/dist/types.js +1 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -8,12 +8,11 @@ A native Rust terminal interface for Claude Code. Drop-in replacement for Anthro
|
|
|
8
8
|
|
|
9
9
|
## About
|
|
10
10
|
|
|
11
|
-
Claude Code Rust replaces the stock Claude Code terminal interface with a native Rust binary built on [Ratatui](https://ratatui.rs/). It connects to the same Claude API through
|
|
11
|
+
Claude Code Rust replaces the stock Claude Code terminal interface with a native Rust binary built on [Ratatui](https://ratatui.rs/). It connects to the same Claude API through a local Agent SDK bridge (`agent-sdk/dist/bridge.js`). Core Claude Code functionality - tool calls, file editing, terminal commands, and permissions - works unchanged.
|
|
12
12
|
|
|
13
13
|
## Requisites
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
- Node.js 18+ with npx (for the ACP adapter)
|
|
15
|
+
- Node.js 18+ (for the Agent SDK bridge)
|
|
17
16
|
- Existing Claude Code authentication (`~/.claude/config.json`)
|
|
18
17
|
|
|
19
18
|
## Install
|
|
@@ -27,17 +26,6 @@ npm install -g claude-code-rust
|
|
|
27
26
|
The npm package installs a `claude-rs` command and downloads the matching
|
|
28
27
|
prebuilt release binary for your platform during `postinstall`.
|
|
29
28
|
|
|
30
|
-
### Cargo (crates.io)
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
cargo install claude-code-rust
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
If `claude-rs` is not found after Cargo install, add your Cargo bin directory
|
|
37
|
-
to `PATH` and restart the terminal:
|
|
38
|
-
- Windows: `%USERPROFILE%\\.cargo\\bin` (or your custom `CARGO_HOME\\bin`)
|
|
39
|
-
- macOS/Linux: `$HOME/.cargo/bin`
|
|
40
|
-
|
|
41
29
|
## Usage
|
|
42
30
|
|
|
43
31
|
```bash
|
|
@@ -60,16 +48,16 @@ Claude Code Rust fixes all of these by compiling to a single native binary with
|
|
|
60
48
|
|
|
61
49
|
Three-layer design:
|
|
62
50
|
|
|
63
|
-
**Presentation** (Rust/Ratatui) - Single binary with an async event loop (Tokio) handling keyboard input and
|
|
51
|
+
**Presentation** (Rust/Ratatui) - Single binary with an async event loop (Tokio) handling keyboard input and bridge client events concurrently. Virtual-scrolled chat history with syntax-highlighted code blocks.
|
|
64
52
|
|
|
65
|
-
**Protocol** (
|
|
53
|
+
**Protocol Bridge** (stdio JSON envelopes) - Spawns `agent-sdk/dist/bridge.js` as a child process and communicates via line-delimited JSON envelopes over stdin/stdout. Bidirectional streaming for user messages, tool updates, and permission requests.
|
|
66
54
|
|
|
67
|
-
**Agent** (
|
|
55
|
+
**Agent Runtime** (Anthropic Agent SDK) - The TypeScript bridge drives `@anthropic-ai/claude-agent-sdk`, which manages authentication, session/query lifecycle, and tool execution.
|
|
68
56
|
|
|
69
57
|
## Known Limitations
|
|
70
58
|
|
|
71
|
-
- Token usage and cost tracking
|
|
72
|
-
- Session resume via `--resume`
|
|
59
|
+
- Token usage and cost tracking can be partial when upstream runtime events do not include full usage data.
|
|
60
|
+
- Session resume via `--resume` depends on runtime support and account/session availability.
|
|
73
61
|
- `/login` and `/logout` are intentionally not offered in command discovery for this release.
|
|
74
62
|
|
|
75
63
|
## Status
|
|
@@ -79,7 +67,7 @@ This project is pre-1.0 and under active development. See [CONTRIBUTING.md](CONT
|
|
|
79
67
|
## License
|
|
80
68
|
|
|
81
69
|
This project is licensed under the [GNU Affero General Public License v3.0 or later](LICENSE).
|
|
82
|
-
This license was chosen because Claude Code is not
|
|
70
|
+
This license was chosen because Claude Code is not open-source and this license allows everyone to use it while stopping Antrophic from implementing it in their clsosed-source version.
|
|
83
71
|
|
|
84
72
|
By using this software, you agree to the terms of the AGPL-3.0. If you modify this software and make it available over a network, you must offer the source code to users of that service.
|
|
85
73
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# claude-rs agent-sdk bridge
|
|
2
|
+
|
|
3
|
+
Initial scaffold for the NDJSON stdio bridge that will connect Rust (`claude-code-rust`) with `@anthropic-ai/claude-agent-sdk`.
|
|
4
|
+
|
|
5
|
+
## Local build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Build output is written to `dist/bridge.mjs`.
|
|
13
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function looksLikeAuthRequired(input) {
|
|
2
|
+
const normalized = input.toLowerCase();
|
|
3
|
+
return (normalized.includes("/login") ||
|
|
4
|
+
normalized.includes("auth required") ||
|
|
5
|
+
normalized.includes("authentication failed") ||
|
|
6
|
+
normalized.includes("please log in"));
|
|
7
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const MODE_NAMES = {
|
|
2
|
+
default: "Default",
|
|
3
|
+
acceptEdits: "Accept Edits",
|
|
4
|
+
bypassPermissions: "Bypass Permissions",
|
|
5
|
+
plan: "Plan",
|
|
6
|
+
dontAsk: "Don't Ask",
|
|
7
|
+
};
|
|
8
|
+
const MODE_OPTIONS = [
|
|
9
|
+
{ id: "default", name: "Default", description: "Standard permission flow" },
|
|
10
|
+
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-approve edit operations" },
|
|
11
|
+
{ id: "plan", name: "Plan", description: "No tool execution" },
|
|
12
|
+
{ id: "dontAsk", name: "Don't Ask", description: "Reject non-approved tools" },
|
|
13
|
+
{ id: "bypassPermissions", name: "Bypass Permissions", description: "Auto-approve all tools" },
|
|
14
|
+
];
|
|
15
|
+
function asRecord(value, context) {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
17
|
+
throw new Error(`${context} must be an object`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function expectString(record, key, context) {
|
|
22
|
+
const value = record[key];
|
|
23
|
+
if (typeof value !== "string") {
|
|
24
|
+
throw new Error(`${context}.${key} must be a string`);
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function expectBoolean(record, key, context) {
|
|
29
|
+
const value = record[key];
|
|
30
|
+
if (typeof value !== "boolean") {
|
|
31
|
+
throw new Error(`${context}.${key} must be a boolean`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function optionalString(record, key, context) {
|
|
36
|
+
const value = record[key];
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (typeof value !== "string") {
|
|
41
|
+
throw new Error(`${context}.${key} must be a string when provided`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function optionalMetadata(record, key) {
|
|
46
|
+
const value = record[key];
|
|
47
|
+
if (value === undefined || value === null) {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
return asRecord(value, `${key} metadata`);
|
|
51
|
+
}
|
|
52
|
+
function parsePromptChunks(record, context) {
|
|
53
|
+
const rawChunks = record.chunks;
|
|
54
|
+
if (!Array.isArray(rawChunks)) {
|
|
55
|
+
throw new Error(`${context}.chunks must be an array`);
|
|
56
|
+
}
|
|
57
|
+
return rawChunks.map((chunk, index) => {
|
|
58
|
+
const parsed = asRecord(chunk, `${context}.chunks[${index}]`);
|
|
59
|
+
const kind = expectString(parsed, "kind", `${context}.chunks[${index}]`);
|
|
60
|
+
return { kind, value: (parsed.value ?? null) };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
export function parseCommandEnvelope(line) {
|
|
64
|
+
const raw = asRecord(JSON.parse(line), "command envelope");
|
|
65
|
+
const requestId = typeof raw.request_id === "string" ? raw.request_id : undefined;
|
|
66
|
+
const commandName = expectString(raw, "command", "command envelope");
|
|
67
|
+
const command = (() => {
|
|
68
|
+
switch (commandName) {
|
|
69
|
+
case "initialize":
|
|
70
|
+
return {
|
|
71
|
+
command: "initialize",
|
|
72
|
+
cwd: expectString(raw, "cwd", "initialize"),
|
|
73
|
+
metadata: optionalMetadata(raw, "metadata"),
|
|
74
|
+
};
|
|
75
|
+
case "create_session":
|
|
76
|
+
return {
|
|
77
|
+
command: "create_session",
|
|
78
|
+
cwd: expectString(raw, "cwd", "create_session"),
|
|
79
|
+
yolo: expectBoolean(raw, "yolo", "create_session"),
|
|
80
|
+
model: optionalString(raw, "model", "create_session"),
|
|
81
|
+
resume: optionalString(raw, "resume", "create_session"),
|
|
82
|
+
metadata: optionalMetadata(raw, "metadata"),
|
|
83
|
+
};
|
|
84
|
+
case "load_session":
|
|
85
|
+
return {
|
|
86
|
+
command: "load_session",
|
|
87
|
+
session_id: expectString(raw, "session_id", "load_session"),
|
|
88
|
+
metadata: optionalMetadata(raw, "metadata"),
|
|
89
|
+
};
|
|
90
|
+
case "new_session":
|
|
91
|
+
return {
|
|
92
|
+
command: "new_session",
|
|
93
|
+
cwd: expectString(raw, "cwd", "new_session"),
|
|
94
|
+
yolo: expectBoolean(raw, "yolo", "new_session"),
|
|
95
|
+
model: optionalString(raw, "model", "new_session"),
|
|
96
|
+
};
|
|
97
|
+
case "prompt":
|
|
98
|
+
return {
|
|
99
|
+
command: "prompt",
|
|
100
|
+
session_id: expectString(raw, "session_id", "prompt"),
|
|
101
|
+
chunks: parsePromptChunks(raw, "prompt"),
|
|
102
|
+
};
|
|
103
|
+
case "cancel_turn":
|
|
104
|
+
return {
|
|
105
|
+
command: "cancel_turn",
|
|
106
|
+
session_id: expectString(raw, "session_id", "cancel_turn"),
|
|
107
|
+
};
|
|
108
|
+
case "set_model":
|
|
109
|
+
return {
|
|
110
|
+
command: "set_model",
|
|
111
|
+
session_id: expectString(raw, "session_id", "set_model"),
|
|
112
|
+
model: expectString(raw, "model", "set_model"),
|
|
113
|
+
};
|
|
114
|
+
case "set_mode":
|
|
115
|
+
return {
|
|
116
|
+
command: "set_mode",
|
|
117
|
+
session_id: expectString(raw, "session_id", "set_mode"),
|
|
118
|
+
mode: expectString(raw, "mode", "set_mode"),
|
|
119
|
+
};
|
|
120
|
+
case "permission_response": {
|
|
121
|
+
const outcome = asRecord(raw.outcome, "permission_response.outcome");
|
|
122
|
+
const outcomeType = expectString(outcome, "outcome", "permission_response.outcome");
|
|
123
|
+
if (outcomeType !== "selected" && outcomeType !== "cancelled") {
|
|
124
|
+
throw new Error("permission_response.outcome.outcome must be 'selected' or 'cancelled'");
|
|
125
|
+
}
|
|
126
|
+
const parsedOutcome = outcomeType === "selected"
|
|
127
|
+
? {
|
|
128
|
+
outcome: "selected",
|
|
129
|
+
option_id: expectString(outcome, "option_id", "permission_response.outcome"),
|
|
130
|
+
}
|
|
131
|
+
: { outcome: "cancelled" };
|
|
132
|
+
return {
|
|
133
|
+
command: "permission_response",
|
|
134
|
+
session_id: expectString(raw, "session_id", "permission_response"),
|
|
135
|
+
tool_call_id: expectString(raw, "tool_call_id", "permission_response"),
|
|
136
|
+
outcome: parsedOutcome,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case "shutdown":
|
|
140
|
+
return { command: "shutdown" };
|
|
141
|
+
default:
|
|
142
|
+
throw new Error(`unsupported command: ${commandName}`);
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
return { requestId, command };
|
|
146
|
+
}
|
|
147
|
+
export function toPermissionMode(mode) {
|
|
148
|
+
if (mode === "default" ||
|
|
149
|
+
mode === "acceptEdits" ||
|
|
150
|
+
mode === "bypassPermissions" ||
|
|
151
|
+
mode === "plan" ||
|
|
152
|
+
mode === "dontAsk") {
|
|
153
|
+
return mode;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
export function buildModeState(mode) {
|
|
158
|
+
return {
|
|
159
|
+
current_mode_id: mode,
|
|
160
|
+
current_mode_name: MODE_NAMES[mode],
|
|
161
|
+
available_modes: MODE_OPTIONS,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { TOOL_RESULT_TYPES, buildToolResultFields, createToolCall, isToolUseBlockType } from "./tooling.js";
|
|
5
|
+
import { asRecordOrNull } from "./shared.js";
|
|
6
|
+
import { buildUsageUpdateFromResult } from "./usage.js";
|
|
7
|
+
function normalizeUserPromptText(raw) {
|
|
8
|
+
let text = raw.replace(/<context[\s\S]*/gi, " ");
|
|
9
|
+
text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
10
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
11
|
+
return text;
|
|
12
|
+
}
|
|
13
|
+
function truncateTextByChars(text, maxChars) {
|
|
14
|
+
const chars = Array.from(text);
|
|
15
|
+
if (chars.length <= maxChars) {
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
return chars.slice(0, maxChars).join("");
|
|
19
|
+
}
|
|
20
|
+
function firstUserMessageTitleFromRecord(record) {
|
|
21
|
+
if (record.type !== "user") {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const message = asRecordOrNull(record.message);
|
|
25
|
+
if (!message || message.role !== "user" || !Array.isArray(message.content)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (const item of message.content) {
|
|
30
|
+
const block = asRecordOrNull(item);
|
|
31
|
+
if (!block || block.type !== "text" || typeof block.text !== "string") {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const cleaned = normalizeUserPromptText(block.text);
|
|
35
|
+
if (!cleaned) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
parts.push(cleaned);
|
|
39
|
+
const combined = parts.join(" ");
|
|
40
|
+
if (Array.from(combined).length >= 180) {
|
|
41
|
+
return truncateTextByChars(combined, 180);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (parts.length === 0) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return truncateTextByChars(parts.join(" "), 180);
|
|
48
|
+
}
|
|
49
|
+
function extractSessionPreviewFromJsonl(filePath) {
|
|
50
|
+
let text;
|
|
51
|
+
try {
|
|
52
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
let cwd;
|
|
58
|
+
let title;
|
|
59
|
+
const lines = text.split(/\r?\n/);
|
|
60
|
+
for (const rawLine of lines) {
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
if (line.length === 0) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(line);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const record = asRecordOrNull(parsed);
|
|
73
|
+
if (!record) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!cwd && typeof record.cwd === "string" && record.cwd.trim().length > 0) {
|
|
77
|
+
cwd = record.cwd;
|
|
78
|
+
}
|
|
79
|
+
if (!title) {
|
|
80
|
+
title = firstUserMessageTitleFromRecord(record);
|
|
81
|
+
}
|
|
82
|
+
if (cwd && title) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
...(cwd ? { cwd } : {}),
|
|
88
|
+
...(title ? { title } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function listRecentPersistedSessions(limit = 8) {
|
|
92
|
+
const root = path.join(os.homedir(), ".claude", "projects");
|
|
93
|
+
if (!fs.existsSync(root)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const candidates = [];
|
|
97
|
+
let projectDirs;
|
|
98
|
+
try {
|
|
99
|
+
projectDirs = fs.readdirSync(root, { withFileTypes: true }).filter((dirent) => dirent.isDirectory());
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
for (const dirent of projectDirs) {
|
|
105
|
+
const projectDir = path.join(root, dirent.name);
|
|
106
|
+
let sessionFiles;
|
|
107
|
+
try {
|
|
108
|
+
sessionFiles = fs
|
|
109
|
+
.readdirSync(projectDir, { withFileTypes: true })
|
|
110
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"));
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
sessionFiles = [];
|
|
114
|
+
}
|
|
115
|
+
for (const sessionFile of sessionFiles) {
|
|
116
|
+
const sessionId = sessionFile.name.slice(0, -".jsonl".length);
|
|
117
|
+
if (!sessionId) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let mtimeMs = 0;
|
|
121
|
+
try {
|
|
122
|
+
mtimeMs = fs.statSync(path.join(projectDir, sessionFile.name)).mtimeMs;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isFinite(mtimeMs) || mtimeMs <= 0) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
candidates.push({
|
|
131
|
+
session_id: sessionId,
|
|
132
|
+
cwd: "",
|
|
133
|
+
file_path: path.join(projectDir, sessionFile.name),
|
|
134
|
+
updated_at: new Date(mtimeMs).toISOString(),
|
|
135
|
+
sort_ms: mtimeMs,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
candidates.sort((a, b) => b.sort_ms - a.sort_ms);
|
|
140
|
+
const deduped = [];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (seen.has(candidate.session_id)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
seen.add(candidate.session_id);
|
|
147
|
+
const preview = extractSessionPreviewFromJsonl(candidate.file_path);
|
|
148
|
+
const cwd = preview.cwd?.trim();
|
|
149
|
+
if (!cwd) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
deduped.push({
|
|
153
|
+
session_id: candidate.session_id,
|
|
154
|
+
cwd,
|
|
155
|
+
file_path: candidate.file_path,
|
|
156
|
+
...(preview.title ? { title: preview.title } : {}),
|
|
157
|
+
...(candidate.updated_at ? { updated_at: candidate.updated_at } : {}),
|
|
158
|
+
sort_ms: candidate.sort_ms,
|
|
159
|
+
});
|
|
160
|
+
if (deduped.length >= limit) {
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return deduped;
|
|
165
|
+
}
|
|
166
|
+
export function resolvePersistedSessionEntry(sessionId) {
|
|
167
|
+
if (sessionId.trim().length === 0 ||
|
|
168
|
+
sessionId.includes("/") ||
|
|
169
|
+
sessionId.includes("\\") ||
|
|
170
|
+
sessionId.includes("..")) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const root = path.join(os.homedir(), ".claude", "projects");
|
|
174
|
+
if (!fs.existsSync(root)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
let projectDirs;
|
|
178
|
+
try {
|
|
179
|
+
projectDirs = fs.readdirSync(root, { withFileTypes: true }).filter((dirent) => dirent.isDirectory());
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
let best = null;
|
|
185
|
+
for (const dirent of projectDirs) {
|
|
186
|
+
const filePath = path.join(root, dirent.name, `${sessionId}.jsonl`);
|
|
187
|
+
if (!fs.existsSync(filePath)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const preview = extractSessionPreviewFromJsonl(filePath);
|
|
191
|
+
const cwd = preview.cwd?.trim();
|
|
192
|
+
if (!cwd) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
let mtimeMs = 0;
|
|
196
|
+
try {
|
|
197
|
+
mtimeMs = fs.statSync(filePath).mtimeMs;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
mtimeMs = 0;
|
|
201
|
+
}
|
|
202
|
+
if (!best || mtimeMs >= best.sort_ms) {
|
|
203
|
+
best = {
|
|
204
|
+
session_id: sessionId,
|
|
205
|
+
cwd,
|
|
206
|
+
file_path: filePath,
|
|
207
|
+
sort_ms: mtimeMs,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return best;
|
|
212
|
+
}
|
|
213
|
+
function persistedMessageCandidates(record) {
|
|
214
|
+
const candidates = [];
|
|
215
|
+
const topLevel = asRecordOrNull(record.message);
|
|
216
|
+
if (topLevel) {
|
|
217
|
+
candidates.push(topLevel);
|
|
218
|
+
}
|
|
219
|
+
const nested = asRecordOrNull(asRecordOrNull(asRecordOrNull(record.data)?.message)?.message);
|
|
220
|
+
if (nested) {
|
|
221
|
+
candidates.push(nested);
|
|
222
|
+
}
|
|
223
|
+
return candidates;
|
|
224
|
+
}
|
|
225
|
+
function pushResumeTextChunk(updates, role, text) {
|
|
226
|
+
if (!text.trim()) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (role === "assistant") {
|
|
230
|
+
updates.push({ type: "agent_message_chunk", content: { type: "text", text } });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
updates.push({ type: "user_message_chunk", content: { type: "text", text } });
|
|
234
|
+
}
|
|
235
|
+
function pushResumeToolUse(updates, toolCalls, block) {
|
|
236
|
+
const toolUseId = typeof block.id === "string" ? block.id : "";
|
|
237
|
+
if (!toolUseId) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const name = typeof block.name === "string" ? block.name : "Tool";
|
|
241
|
+
const input = asRecordOrNull(block.input) ?? {};
|
|
242
|
+
const toolCall = createToolCall(toolUseId, name, input);
|
|
243
|
+
toolCall.status = "in_progress";
|
|
244
|
+
toolCalls.set(toolUseId, toolCall);
|
|
245
|
+
updates.push({ type: "tool_call", tool_call: toolCall });
|
|
246
|
+
}
|
|
247
|
+
function pushResumeToolResult(updates, toolCalls, block) {
|
|
248
|
+
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
249
|
+
if (!toolUseId) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const isError = Boolean(block.is_error);
|
|
253
|
+
const base = toolCalls.get(toolUseId);
|
|
254
|
+
const fields = buildToolResultFields(isError, block.content, base);
|
|
255
|
+
updates.push({ type: "tool_call_update", tool_call_update: { tool_call_id: toolUseId, fields } });
|
|
256
|
+
if (!base) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
base.status = fields.status ?? base.status;
|
|
260
|
+
if (fields.raw_output) {
|
|
261
|
+
base.raw_output = fields.raw_output;
|
|
262
|
+
}
|
|
263
|
+
if (fields.content) {
|
|
264
|
+
base.content = fields.content;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function pushResumeUsageUpdate(updates, message, emittedUsageMessageIds) {
|
|
268
|
+
const messageId = typeof message.id === "string" ? message.id : "";
|
|
269
|
+
if (messageId && emittedUsageMessageIds.has(messageId)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const usageUpdate = buildUsageUpdateFromResult(message);
|
|
273
|
+
if (!usageUpdate) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
updates.push(usageUpdate);
|
|
277
|
+
if (messageId) {
|
|
278
|
+
emittedUsageMessageIds.add(messageId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export function extractSessionHistoryUpdatesFromJsonl(filePath) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
const updates = [];
|
|
290
|
+
const toolCalls = new Map();
|
|
291
|
+
const emittedUsageMessageIds = new Set();
|
|
292
|
+
const lines = text.split(/\r?\n/);
|
|
293
|
+
for (const rawLine of lines) {
|
|
294
|
+
const line = rawLine.trim();
|
|
295
|
+
if (line.length === 0) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
let parsed;
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(line);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
const record = asRecordOrNull(parsed);
|
|
306
|
+
if (!record) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
for (const message of persistedMessageCandidates(record)) {
|
|
310
|
+
const role = message.role;
|
|
311
|
+
if (role !== "user" && role !== "assistant") {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
315
|
+
for (const item of content) {
|
|
316
|
+
const block = asRecordOrNull(item);
|
|
317
|
+
if (!block) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const blockType = typeof block.type === "string" ? block.type : "";
|
|
321
|
+
if (blockType === "thinking") {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (blockType === "text" && typeof block.text === "string") {
|
|
325
|
+
pushResumeTextChunk(updates, role, block.text);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (isToolUseBlockType(blockType) && role === "assistant") {
|
|
329
|
+
pushResumeToolUse(updates, toolCalls, block);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (TOOL_RESULT_TYPES.has(blockType)) {
|
|
333
|
+
pushResumeToolResult(updates, toolCalls, block);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (blockType === "image") {
|
|
337
|
+
pushResumeTextChunk(updates, role, "[image]");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
pushResumeUsageUpdate(updates, message, emittedUsageMessageIds);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return updates;
|
|
344
|
+
}
|