linkshell-cli 0.8.2 → 0.10.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.
Files changed (173) hide show
  1. package/README.md +11 -6
  2. package/dist/cli/src/commands/gateway.d.ts +2 -0
  3. package/dist/cli/src/commands/gateway.js +29 -0
  4. package/dist/cli/src/commands/gateway.js.map +1 -0
  5. package/dist/cli/src/commands/host.d.ts +4 -0
  6. package/dist/cli/src/commands/host.js +12 -2
  7. package/dist/cli/src/commands/host.js.map +1 -1
  8. package/dist/cli/src/commands/screen.d.ts +10 -0
  9. package/dist/cli/src/commands/screen.js +82 -74
  10. package/dist/cli/src/commands/screen.js.map +1 -1
  11. package/dist/cli/src/commands/setup.d.ts +11 -1
  12. package/dist/cli/src/commands/setup.js +102 -38
  13. package/dist/cli/src/commands/setup.js.map +1 -1
  14. package/dist/cli/src/index.js +28 -338
  15. package/dist/cli/src/index.js.map +1 -1
  16. package/dist/cli/src/utils/daemon.d.ts +0 -7
  17. package/dist/cli/src/utils/daemon.js +1 -18
  18. package/dist/cli/src/utils/daemon.js.map +1 -1
  19. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  20. package/dist/gateway/src/accounts.d.ts +6 -0
  21. package/dist/gateway/src/accounts.js +32 -0
  22. package/dist/gateway/src/accounts.js.map +1 -0
  23. package/dist/gateway/src/index.d.ts +4 -0
  24. package/dist/gateway/src/index.js +5 -0
  25. package/dist/gateway/src/index.js.map +1 -0
  26. package/dist/gateway/src/main.d.ts +1 -0
  27. package/dist/gateway/src/main.js +57 -0
  28. package/dist/gateway/src/main.js.map +1 -0
  29. package/dist/gateway/src/rate-limit.d.ts +22 -0
  30. package/dist/gateway/src/rate-limit.js +66 -0
  31. package/dist/gateway/src/rate-limit.js.map +1 -0
  32. package/dist/gateway/src/relay.d.ts +84 -0
  33. package/dist/gateway/src/relay.js +434 -0
  34. package/dist/gateway/src/relay.js.map +1 -0
  35. package/dist/gateway/src/serve.d.ts +34 -0
  36. package/dist/gateway/src/serve.js +102 -0
  37. package/dist/gateway/src/serve.js.map +1 -0
  38. package/dist/gateway/src/store.d.ts +35 -0
  39. package/dist/gateway/src/store.js +109 -0
  40. package/dist/gateway/src/store.js.map +1 -0
  41. package/dist/gateway/src/subscription.d.ts +18 -0
  42. package/dist/gateway/src/subscription.js +46 -0
  43. package/dist/gateway/src/subscription.js.map +1 -0
  44. package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
  45. package/dist/host/src/fs.d.ts +3 -3
  46. package/dist/host/src/input.d.ts +112 -27
  47. package/dist/host/src/input.js +214 -144
  48. package/dist/host/src/input.js.map +1 -1
  49. package/dist/host/src/rpc/server.js +1 -1
  50. package/dist/host/src/rpc/server.js.map +1 -1
  51. package/dist/host/src/screen-pacer.d.ts +25 -11
  52. package/dist/host/src/screen-pacer.js +28 -6
  53. package/dist/host/src/screen-pacer.js.map +1 -1
  54. package/dist/host/src/screen-viewer.d.ts +5 -0
  55. package/dist/host/src/screen-viewer.js +1231 -55
  56. package/dist/host/src/screen-viewer.js.map +1 -1
  57. package/dist/host/src/screen.d.ts +19 -10
  58. package/dist/host/src/screen.js +314 -108
  59. package/dist/host/src/screen.js.map +1 -1
  60. package/dist/host/src/terminals.d.ts +1 -1
  61. package/dist/host/src/terminals.js +1 -0
  62. package/dist/host/src/terminals.js.map +1 -1
  63. package/dist/wire/src/model.d.ts +6 -6
  64. package/dist/wire/src/relay.d.ts +8 -8
  65. package/dist/wire/src/rpc.d.ts +818 -818
  66. package/dist/wire/src/updates.d.ts +18 -18
  67. package/package.json +6 -15
  68. package/src/commands/gateway.ts +31 -0
  69. package/src/commands/host.ts +15 -2
  70. package/src/commands/screen.ts +78 -72
  71. package/src/commands/setup.ts +102 -60
  72. package/src/index.ts +29 -433
  73. package/src/utils/daemon.ts +2 -27
  74. package/dist/cli/src/config.d.ts +0 -14
  75. package/dist/cli/src/config.js +0 -23
  76. package/dist/cli/src/config.js.map +0 -1
  77. package/dist/cli/src/machine-id.d.ts +0 -6
  78. package/dist/cli/src/machine-id.js +0 -50
  79. package/dist/cli/src/machine-id.js.map +0 -1
  80. package/dist/cli/src/providers.d.ts +0 -13
  81. package/dist/cli/src/providers.js +0 -77
  82. package/dist/cli/src/providers.js.map +0 -1
  83. package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
  84. package/dist/cli/src/runtime/acp/acp-client.js +0 -430
  85. package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
  86. package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
  87. package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
  88. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
  89. package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
  90. package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
  91. package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
  92. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
  93. package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
  94. package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
  95. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
  96. package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
  97. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
  98. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
  99. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
  100. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
  101. package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
  102. package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
  103. package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
  104. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
  105. package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
  106. package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
  107. package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
  108. package/dist/cli/src/runtime/bridge-session.js +0 -1364
  109. package/dist/cli/src/runtime/bridge-session.js.map +0 -1
  110. package/dist/cli/src/runtime/hook-command.d.ts +0 -9
  111. package/dist/cli/src/runtime/hook-command.js +0 -80
  112. package/dist/cli/src/runtime/hook-command.js.map +0 -1
  113. package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
  114. package/dist/cli/src/runtime/screen-fallback.js +0 -202
  115. package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
  116. package/dist/cli/src/runtime/screen-share.d.ts +0 -34
  117. package/dist/cli/src/runtime/screen-share.js +0 -376
  118. package/dist/cli/src/runtime/screen-share.js.map +0 -1
  119. package/dist/cli/src/runtime/scrollback.d.ts +0 -11
  120. package/dist/cli/src/runtime/scrollback.js +0 -33
  121. package/dist/cli/src/runtime/scrollback.js.map +0 -1
  122. package/dist/cli/src/runtime/usage-report.d.ts +0 -34
  123. package/dist/cli/src/runtime/usage-report.js +0 -332
  124. package/dist/cli/src/runtime/usage-report.js.map +0 -1
  125. package/dist/cli/src/utils/keep-awake.d.ts +0 -6
  126. package/dist/cli/src/utils/keep-awake.js +0 -48
  127. package/dist/cli/src/utils/keep-awake.js.map +0 -1
  128. package/dist/cli/src/utils/lan-ip.d.ts +0 -9
  129. package/dist/cli/src/utils/lan-ip.js +0 -61
  130. package/dist/cli/src/utils/lan-ip.js.map +0 -1
  131. package/dist/host/src/input-helper.d.ts +0 -1
  132. package/dist/host/src/input-helper.js +0 -564
  133. package/dist/host/src/input-helper.js.map +0 -1
  134. package/dist/shared-protocol/src/index.d.ts +0 -17029
  135. package/dist/shared-protocol/src/index.js +0 -1064
  136. package/dist/shared-protocol/src/index.js.map +0 -1
  137. package/scripts/copy-web.mjs +0 -34
  138. package/src/config.ts +0 -36
  139. package/src/machine-id.ts +0 -61
  140. package/src/providers.ts +0 -97
  141. package/src/runtime/acp/acp-client.ts +0 -521
  142. package/src/runtime/acp/agent-process-discovery.ts +0 -308
  143. package/src/runtime/acp/agent-session.ts +0 -1186
  144. package/src/runtime/acp/agent-workspace.ts +0 -6590
  145. package/src/runtime/acp/claude-sdk-client.ts +0 -1221
  146. package/src/runtime/acp/claude-stream-json-client.ts +0 -633
  147. package/src/runtime/acp/json-rpc.ts +0 -184
  148. package/src/runtime/acp/provider-resolver.ts +0 -154
  149. package/src/runtime/bridge-session.ts +0 -1549
  150. package/src/runtime/hook-command.ts +0 -73
  151. package/src/runtime/screen-fallback.ts +0 -235
  152. package/src/runtime/screen-share.ts +0 -426
  153. package/src/runtime/scrollback.ts +0 -43
  154. package/src/runtime/usage-report.ts +0 -342
  155. package/src/utils/keep-awake.ts +0 -61
  156. package/src/utils/lan-ip.ts +0 -61
  157. package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
  158. package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
  159. package/web/assets/TerminalPanel-CTSklkct.js +0 -1
  160. package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
  161. package/web/assets/index-BsnDaeCt.css +0 -33
  162. package/web/assets/index-q85psYZ5.js +0 -36
  163. package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
  164. package/web/assets/vendor-react-M7xG3qiw.js +0 -49
  165. package/web/assets/vendor-xterm-D15THcI4.js +0 -9
  166. package/web/index.html +0 -37
  167. package/web/logos/claude.png +0 -0
  168. package/web/logos/codex.png +0 -0
  169. package/web/logos/copilot.png +0 -0
  170. package/web/logos/gemini.png +0 -0
  171. package/web/logos/linkshell-1024.png +0 -0
  172. package/web/logos/linkshell-splash.png +0 -0
  173. package/web/logos/linkshell.png +0 -0
@@ -1,633 +0,0 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
- import { createInterface } from "node:readline";
3
- import { homedir } from "node:os";
4
- import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
5
- import { join, resolve } from "node:path";
6
- import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
7
- import { parseClaudeJsonlSession } from "./claude-sdk-client.js";
8
-
9
- type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
10
-
11
- type AgentInputContentBlock = {
12
- type?: string;
13
- text?: string;
14
- data?: string;
15
- mimeType?: string;
16
- };
17
-
18
- interface ClaudeStreamEvent {
19
- type: string;
20
- subtype?: string;
21
- message?: Record<string, unknown>;
22
- session_id?: string;
23
- parent_tool_use_id?: string | null;
24
- uuid?: string;
25
- [key: string]: unknown;
26
- }
27
-
28
- interface ClaudeContentBlock {
29
- type: string;
30
- text?: string;
31
- thinking?: string;
32
- id?: string;
33
- name?: string;
34
- input?: Record<string, unknown>;
35
- tool_use_id?: string;
36
- content?: string;
37
- is_error?: boolean;
38
- signature?: string;
39
- }
40
-
41
- // Hash a directory path the same way Claude Code does for project storage
42
- function projectHash(cwd: string): string {
43
- return (
44
- "-" +
45
- resolve(cwd)
46
- .replace(/\/$/, "")
47
- .replace(/\//g, "-")
48
- );
49
- }
50
-
51
- function claudeProjectsRoot(): string {
52
- return join(homedir(), ".claude", "projects");
53
- }
54
-
55
- function claudeProjectDir(cwd: string): string {
56
- return join(claudeProjectsRoot(), projectHash(cwd));
57
- }
58
-
59
- function claudeProjectDirs(preferredCwd: string): string[] {
60
- const root = claudeProjectsRoot();
61
- const preferred = claudeProjectDir(preferredCwd);
62
- const dirs: string[] = [];
63
- if (existsSync(preferred)) dirs.push(preferred);
64
- if (!existsSync(root)) return dirs;
65
- try {
66
- for (const entry of readdirSync(root)) {
67
- const fullPath = join(root, entry);
68
- if (fullPath !== preferred && statSync(fullPath).isDirectory()) {
69
- dirs.push(fullPath);
70
- }
71
- }
72
- } catch {
73
- // Ignore unreadable Claude storage.
74
- }
75
- return dirs;
76
- }
77
-
78
- function stringField(value: unknown, keys: string[]): string | undefined {
79
- if (!value || typeof value !== "object") return undefined;
80
- const record = value as Record<string, unknown>;
81
- for (const key of keys) {
82
- const candidate = record[key];
83
- if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
84
- }
85
- return undefined;
86
- }
87
-
88
- function id(prefix: string): string {
89
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
90
- }
91
-
92
- // Claude stream-json returns tool_result content as either a plain string or
93
- // an array of content-block objects. Extract the text regardless of shape.
94
- function extractToolResultText(content: unknown): string {
95
- if (typeof content === "string") return content;
96
- if (Array.isArray(content)) {
97
- return content
98
- .map((part) => {
99
- if (typeof part === "string") return part;
100
- if (part && typeof part === "object" && "text" in part && typeof part.text === "string") {
101
- return part.text;
102
- }
103
- return "";
104
- })
105
- .join("");
106
- }
107
- if (content && typeof content === "object" && "text" in content && typeof (content as { text: unknown }).text === "string") {
108
- return (content as { text: string }).text;
109
- }
110
- return String(content ?? "");
111
- }
112
-
113
- function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { data: string; mimeType: string } {
114
- const match = value.match(/^data:([^;,]+)?;base64,(.*)$/is);
115
- if (!match) return { data: value, mimeType: fallbackMimeType };
116
- return {
117
- data: match[2] ?? "",
118
- mimeType: match[1] || fallbackMimeType,
119
- };
120
- }
121
-
122
- export class ClaudeStreamJsonClient {
123
- private static warnedBypass = false;
124
- private child: ChildProcessWithoutNullStreams | undefined;
125
- private claudeSessionId: string | undefined;
126
- private pendingCancel = false;
127
- // Per-file cache for listSessions: skip re-reading/parsing an unchanged
128
- // transcript (same mtime+size). fs.watch re-syncs fire every ~1.5s, so
129
- // without this each sync would re-parse every transcript in ~/.claude/projects.
130
- private sessionMetaCache = new Map<string, { mtimeMs: number; size: number; entry: Record<string, unknown> }>();
131
-
132
- constructor(
133
- private readonly input: {
134
- command: string;
135
- protocol: AgentProtocol;
136
- framing: AgentFraming;
137
- cwd: string;
138
- onNotification: (method: string, params: unknown) => void;
139
- onRequest: (method: string, params: unknown) => Promise<unknown> | unknown;
140
- onExit: (message: string) => void;
141
- },
142
- ) {}
143
-
144
- async initialize(): Promise<unknown> {
145
- // No persistent process to start — we'll capture session on first prompt.
146
- // But we can verify the binary exists by running a quick --help.
147
- try {
148
- const { execSync } = await import("node:child_process");
149
- execSync("claude --version", { stdio: "ignore", timeout: 5000 });
150
- } catch {
151
- throw new Error("Claude Code CLI not found or not executable");
152
- }
153
- return { status: "ok" };
154
- }
155
-
156
- async newSession(input: { cwd: string; mcpServers?: unknown }): Promise<unknown> {
157
- // Start a dry-run prompt to get a session_id, then cancel.
158
- // Actually, we just store that there's no session yet — the real session
159
- // will be created on the first prompt() call.
160
- this.claudeSessionId = undefined;
161
- return { sessionId: undefined, status: "ready" };
162
- }
163
-
164
- async loadSession(input: { sessionId: string; cwd: string; mcpServers?: unknown }): Promise<unknown> {
165
- this.claudeSessionId = input.sessionId;
166
- return { sessionId: input.sessionId, status: "loaded" };
167
- }
168
-
169
- async prompt(input: {
170
- sessionId?: string;
171
- content: unknown[];
172
- clientMessageId: string;
173
- model?: string;
174
- reasoningEffort?: string;
175
- permissionMode?: AgentPermissionMode;
176
- collaborationMode?: "default" | "plan";
177
- cwd: string;
178
- }): Promise<unknown> {
179
- if (this.child) {
180
- this.stop();
181
- }
182
-
183
- this.pendingCancel = false;
184
-
185
- // SECURITY: the headless stream-json fallback can only run with
186
- // --permission-mode bypassPermissions (there is no TTY to prompt on), so
187
- // remotely-submitted prompts execute tools — including shell — without
188
- // confirmation. The SDK path (preferred when @anthropic-ai/claude-agent-sdk
189
- // is installed) routes tool approvals to the mobile UI instead.
190
- if (/^(1|true|yes)$/i.test(process.env.LINKSHELL_REQUIRE_AGENT_PERMISSIONS ?? "")) {
191
- throw new Error(
192
- "Agent permission enforcement is required (LINKSHELL_REQUIRE_AGENT_PERMISSIONS) " +
193
- "but the Claude stream-json fallback can only run with bypassPermissions. " +
194
- "Install @anthropic-ai/claude-agent-sdk to enable permission-gated tool approvals.",
195
- );
196
- }
197
- if (!ClaudeStreamJsonClient.warnedBypass) {
198
- ClaudeStreamJsonClient.warnedBypass = true;
199
- process.stderr.write(
200
- "[bridge] WARNING: Claude stream-json fallback runs with bypassPermissions — " +
201
- "remotely submitted prompts can execute tools (including shell) without confirmation. " +
202
- "Install @anthropic-ai/claude-agent-sdk for permission-gated control, or set " +
203
- "LINKSHELL_REQUIRE_AGENT_PERMISSIONS=true to refuse the unsafe fallback.\n",
204
- );
205
- }
206
-
207
- // Build Claude args
208
- const args: string[] = [
209
- "--print",
210
- "--output-format", "stream-json",
211
- "--input-format", "stream-json",
212
- "--verbose",
213
- "--permission-mode", "bypassPermissions",
214
- ];
215
-
216
- // Use stored session for --resume (only when we have a real session ID from system.init)
217
- if (this.claudeSessionId) {
218
- args.push("--resume", this.claudeSessionId);
219
- }
220
- // Allow up to 10 turns so Claude can execute tools and continue responding.
221
- // With bypassPermissions, tools auto-execute — a generous limit prevents
222
- // infinite loops while still allowing complex multi-step workflows.
223
- args.push("--max-turns", "10");
224
-
225
- if (input.model) {
226
- args.push("--model", input.model);
227
- }
228
-
229
- // Build the user message
230
- const contentBlocks = (input.content as AgentInputContentBlock[]).map(
231
- (block) => {
232
- if (block.type === "image" && block.data) {
233
- const image = splitImageDataUrl(block.data, block.mimeType);
234
- return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } };
235
- }
236
- return { type: "text", text: block.text ?? "" };
237
- },
238
- );
239
-
240
- const userMessage = {
241
- type: "user",
242
- message: {
243
- role: "user",
244
- content: contentBlocks,
245
- },
246
- };
247
-
248
- return new Promise((resolve, reject) => {
249
- const cwd = input.cwd ?? this.input.cwd;
250
- const child = spawn("claude", args, {
251
- cwd,
252
- env: process.env,
253
- stdio: ["pipe", "pipe", "pipe"],
254
- });
255
-
256
- this.child = child;
257
- let settled = false;
258
-
259
- const finish = (err: Error | null, result: unknown) => {
260
- if (settled) return;
261
- settled = true;
262
- // Only clear reference if this child is still the active one
263
- if (this.child === child) {
264
- this.child = undefined;
265
- }
266
- if (err) {
267
- // Don't call onExit for per-prompt failures — the client can still accept new prompts.
268
- // onExit is reserved for fatal errors (e.g. binary not found in initialize()).
269
- reject(err);
270
- } else {
271
- resolve(result);
272
- }
273
- };
274
-
275
- // Send the prompt
276
- try {
277
- child.stdin.write(JSON.stringify(userMessage) + "\n");
278
- child.stdin.end();
279
- } catch (err) {
280
- child.kill("SIGTERM");
281
- finish(err instanceof Error ? err : new Error(String(err)), undefined);
282
- return child;
283
- }
284
-
285
- // Read stdout line by line (stream-json is newline-delimited)
286
- const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
287
- let currentToolId: string | undefined;
288
- let currentToolName: string | undefined;
289
- let currentMessageId: string | undefined;
290
- const progressItemId = `claude-progress:${input.clientMessageId}`;
291
- // Map tool_use_id → tool_name so tool_result can look up the correct name
292
- // even when multiple tools are in flight
293
- const toolNames = new Map<string, string>();
294
-
295
- this.input.onNotification("item/started", {
296
- sessionId: input.sessionId ?? this.claudeSessionId,
297
- item: {
298
- id: progressItemId,
299
- type: "thinking",
300
- text: "Claude 正在处理请求",
301
- status: "running",
302
- },
303
- });
304
-
305
- rl.on("line", (line: string) => {
306
- if (this.pendingCancel) {
307
- child.kill("SIGTERM");
308
- return;
309
- }
310
-
311
- let event: ClaudeStreamEvent;
312
- try {
313
- event = JSON.parse(line);
314
- } catch {
315
- return; // skip unparseable lines
316
- }
317
-
318
- switch (event.type) {
319
- case "system": {
320
- if (event.subtype === "init") {
321
- // Capture session ID
322
- if (event.session_id) {
323
- this.claudeSessionId = event.session_id;
324
- }
325
- // Emit thread/started so workspace/session proxies update their agentSessionId
326
- this.input.onNotification("thread/started", {
327
- sessionId: event.session_id,
328
- threadId: event.session_id,
329
- });
330
- // Also send initialized with full metadata
331
- const initParams: Record<string, unknown> = {
332
- sessionId: event.session_id,
333
- threadId: event.session_id,
334
- cwd: event.cwd ?? cwd,
335
- model: event.model,
336
- };
337
- if (event.tools) initParams.tools = event.tools;
338
- if (event.slash_commands) initParams.slashCommands = event.slash_commands;
339
- if (event.mcp_servers) initParams.mcpServers = event.mcp_servers;
340
- this.input.onNotification("initialized", initParams);
341
- }
342
- // Hook events and other system messages are informational, skip
343
- break;
344
- }
345
-
346
- case "assistant": {
347
- const message = event.message;
348
- if (!message) break;
349
- const content = (message.content ?? []) as ClaudeContentBlock[];
350
- // Reset per-message tracking — each assistant message starts fresh
351
- currentMessageId = undefined;
352
-
353
- for (const block of content) {
354
- switch (block.type) {
355
- case "thinking":
356
- // Use item/completed since thinking blocks arrive complete (not streaming deltas)
357
- // item/started would leave isStreaming=true forever with no matching item/completed
358
- this.input.onNotification("item/completed", {
359
- sessionId: this.claudeSessionId,
360
- item: {
361
- id: event.uuid ?? id("thinking"),
362
- type: "thinking",
363
- text: block.thinking,
364
- status: "completed",
365
- },
366
- });
367
- break;
368
-
369
- case "text":
370
- currentMessageId = (typeof message.id === "string" ? message.id : undefined) ?? event.uuid ?? id("msg");
371
- this.input.onNotification("item/agentMessage/delta", {
372
- sessionId: this.claudeSessionId,
373
- itemId: currentMessageId,
374
- delta: block.text,
375
- });
376
- break;
377
-
378
- case "tool_use": {
379
- currentToolId = block.id;
380
- currentToolName = block.name ?? "tool";
381
- if (block.id) toolNames.set(block.id, block.name ?? "tool");
382
- const toolName = block.name ?? "tool";
383
- this.input.onNotification("item/started", {
384
- sessionId: this.claudeSessionId,
385
- item: {
386
- id: block.id ?? id("tool"),
387
- type: toolName === "Bash" ? "commandExecution" : toolName === "Write" || toolName === "Edit" ? "fileChange" : "toolCall",
388
- toolName: block.name,
389
- tool: block.name,
390
- input: block.input,
391
- command: block.input?.command as string | undefined,
392
- cwd: block.input?.cwd as string | undefined ?? cwd,
393
- status: "running",
394
- },
395
- });
396
- break;
397
- }
398
-
399
- case "tool_result":
400
- // tool_result comes in user messages, not assistant
401
- break;
402
- }
403
- }
404
-
405
- // Mark this assistant message as complete — the full message has arrived.
406
- // Each text block was streamed via item/agentMessage/delta; now we signal
407
- // that streaming is done so the UI stops showing "正在生成".
408
- if (currentMessageId) {
409
- this.input.onNotification("item/completed", {
410
- sessionId: this.claudeSessionId,
411
- item: {
412
- id: currentMessageId,
413
- type: "agentMessage",
414
- status: "completed",
415
- },
416
- });
417
- }
418
- break;
419
- }
420
-
421
- case "user": {
422
- // User messages in stream-json contain tool results
423
- const message = event.message;
424
- if (!message) break;
425
- const content = (message.content ?? []) as ClaudeContentBlock[];
426
-
427
- for (const block of content) {
428
- if (block.type === "tool_result") {
429
- const toolId = block.tool_use_id ?? currentToolId;
430
- const toolName = (toolId ? toolNames.get(toolId) : undefined) ?? currentToolName;
431
- const isError = block.is_error === true;
432
- // Claude stream-json may return content as a plain string or as
433
- // an array of content-block objects (e.g. [{type:"text", text:"..."}]).
434
- const output = extractToolResultText(block.content);
435
- this.input.onNotification("item/completed", {
436
- sessionId: this.claudeSessionId,
437
- item: {
438
- id: toolId ?? id("tool"),
439
- type: "toolCall",
440
- toolName,
441
- tool: toolName,
442
- status: isError ? "failed" : "completed",
443
- output,
444
- aggregatedOutput: output,
445
- isError,
446
- },
447
- });
448
- }
449
- }
450
- break;
451
- }
452
-
453
- case "result": {
454
- const isError = event.subtype === "error" || event.is_error === true;
455
- this.input.onNotification("item/completed", {
456
- sessionId: this.claudeSessionId ?? input.sessionId,
457
- item: {
458
- id: progressItemId,
459
- type: "thinking",
460
- text: isError ? "Claude 运行出错" : "Claude 已完成",
461
- status: isError ? "failed" : "completed",
462
- },
463
- });
464
- // Mark the last agent message as complete so isStreaming flips to false
465
- if (currentMessageId) {
466
- this.input.onNotification("item/completed", {
467
- sessionId: this.claudeSessionId,
468
- item: {
469
- id: currentMessageId,
470
- type: "agentMessage",
471
- status: "completed",
472
- },
473
- });
474
- }
475
- // Turn complete
476
- this.input.onNotification("turn/completed", {
477
- sessionId: this.claudeSessionId,
478
- stopReason: event.stop_reason ?? (isError ? "error" : "end_turn"),
479
- durationMs: event.duration_ms,
480
- totalCostUsd: event.total_cost_usd,
481
- usage: event.usage,
482
- isError,
483
- });
484
- finish(null, { sessionId: this.claudeSessionId, status: isError ? "error" : "completed" });
485
- break;
486
- }
487
- }
488
- });
489
-
490
- child.stderr.on("data", (chunk: Buffer) => {
491
- const trimmed = chunk.toString().trim();
492
- if (trimmed) process.stderr.write(`[claude:stderr] ${trimmed}\n`);
493
- });
494
-
495
- child.on("error", (err) => {
496
- this.input.onNotification("item/completed", {
497
- sessionId: this.claudeSessionId ?? input.sessionId,
498
- item: {
499
- id: progressItemId,
500
- type: "thinking",
501
- text: "Claude 运行出错",
502
- status: "failed",
503
- },
504
- });
505
- finish(err, undefined);
506
- });
507
-
508
- child.on("exit", (code, signal) => {
509
- if (!settled) {
510
- this.input.onNotification("item/completed", {
511
- sessionId: this.claudeSessionId ?? input.sessionId,
512
- item: {
513
- id: progressItemId,
514
- type: "thinking",
515
- text: this.pendingCancel ? "Claude 已停止" : "Claude 意外退出",
516
- status: "failed",
517
- },
518
- });
519
- finish(
520
- new Error(`Claude exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`),
521
- undefined,
522
- );
523
- }
524
- });
525
- });
526
- }
527
-
528
- cancel(input: { sessionId?: string; turnId?: string }): void {
529
- this.pendingCancel = true;
530
- if (this.child && !this.child.killed) {
531
- this.child.kill("SIGTERM");
532
- }
533
- this.child = undefined;
534
- }
535
-
536
- respondPermission(input: { sessionId?: string; requestId: string; outcome: "allow" | "deny"; optionId?: string }): void {
537
- // In bypassPermissions mode, permission requests don't occur.
538
- // If we later switch to hook-based permissions, we'd send the response via a hook.
539
- }
540
-
541
- async listSessions(): Promise<unknown> {
542
- const sessions: Array<Record<string, unknown>> = [];
543
- const seen = new Set<string>();
544
- const liveKeys = new Set<string>();
545
- for (const projectDir of claudeProjectDirs(this.input.cwd)) {
546
- try {
547
- for (const entry of readdirSync(projectDir)) {
548
- if (!entry.endsWith(".jsonl")) continue;
549
- const sessionId = entry.replace(".jsonl", "");
550
- if (seen.has(sessionId)) continue;
551
- seen.add(sessionId);
552
- const filePath = join(projectDir, entry);
553
- let stat;
554
- try {
555
- stat = statSync(filePath);
556
- } catch {
557
- continue; // vanished between readdir and stat
558
- }
559
- liveKeys.add(filePath);
560
- let cached = this.sessionMetaCache.get(filePath);
561
- if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
562
- const parsed = parseClaudeJsonlSession({
563
- text: readFileSync(filePath, "utf8"),
564
- cwd: this.input.cwd,
565
- sessionId,
566
- fallbackUpdatedAt: stat.mtimeMs,
567
- });
568
- const thread = parsed.thread && typeof parsed.thread === "object"
569
- ? parsed.thread as Record<string, unknown>
570
- : {};
571
- const item: Record<string, unknown> = {
572
- id: sessionId,
573
- cwd: stringField(thread, ["cwd", "workingDirectory", "workspacePath"]) ?? this.input.cwd,
574
- title: stringField(thread, ["title", "preview"]),
575
- model: stringField(thread, ["model"]),
576
- createdAt: thread.createdAt,
577
- lastActivityAt: thread.updatedAt ?? stat.mtimeMs,
578
- lastModified: stat.mtimeMs,
579
- };
580
- cached = { mtimeMs: stat.mtimeMs, size: stat.size, entry: item };
581
- this.sessionMetaCache.set(filePath, cached);
582
- }
583
- sessions.push(cached.entry);
584
- }
585
- } catch {
586
- // directory read failed
587
- }
588
- }
589
- // Prune cache entries for transcripts that no longer exist.
590
- if (this.sessionMetaCache.size > liveKeys.size) {
591
- for (const key of this.sessionMetaCache.keys()) {
592
- if (!liveKeys.has(key)) this.sessionMetaCache.delete(key);
593
- }
594
- }
595
-
596
- return { sessions };
597
- }
598
-
599
- async listModels(): Promise<unknown> {
600
- let defaultModel: string = "default";
601
- let availableModels: string[] | undefined;
602
- try {
603
- const settingsPath = join(homedir(), ".claude", "settings.json");
604
- if (existsSync(settingsPath)) {
605
- const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { model?: unknown; availableModels?: unknown };
606
- if (typeof raw.model === "string" && raw.model.trim().length > 0) {
607
- defaultModel = raw.model.trim();
608
- }
609
- if (Array.isArray(raw.availableModels)) {
610
- availableModels = raw.availableModels.filter((m): m is string => typeof m === "string");
611
- }
612
- }
613
- } catch {
614
- // Fallback to defaults if settings file is unreadable.
615
- }
616
- const ids = availableModels ?? ["default", "sonnet", "opus", "opusplan", "haiku", "sonnet[1m]", "opus[1m]"];
617
- const LABELS: Record<string, string> = {
618
- default: "默认模型", sonnet: "Sonnet", opus: "Opus", opusplan: "Opus Plan",
619
- haiku: "Haiku", "sonnet[1m]": "Sonnet 1M", "opus[1m]": "Opus 1M",
620
- };
621
- return {
622
- defaultModel,
623
- models: ids.map((id) => ({ id, label: LABELS[id] ?? id })),
624
- };
625
- }
626
-
627
- stop(): void {
628
- if (this.child && !this.child.killed) {
629
- this.child.kill("SIGTERM");
630
- }
631
- this.child = undefined;
632
- }
633
- }