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,1221 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { readdirSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
- import { randomUUID } from "node:crypto";
4
- import { isAbsolute, join, relative, resolve } from "node:path";
5
- import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
6
-
7
- type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
8
- type AgentCollaborationMode = "default" | "plan";
9
- type ClaudePermissionMode = "default" | "dontAsk" | "acceptEdits" | "bypassPermissions" | "plan";
10
-
11
- interface ClaudeContentBlock {
12
- type?: string;
13
- text?: string;
14
- thinking?: string;
15
- id?: string;
16
- name?: string;
17
- input?: Record<string, unknown>;
18
- tool_use_id?: string;
19
- content?: unknown;
20
- is_error?: boolean;
21
- }
22
-
23
- interface ClaudeSdkMessage {
24
- type?: string;
25
- subtype?: string;
26
- message?: Record<string, unknown>;
27
- session_id?: string;
28
- uuid?: string;
29
- result?: string;
30
- total_cost_usd?: number;
31
- duration_ms?: number;
32
- usage?: unknown;
33
- [key: string]: unknown;
34
- }
35
-
36
- type ClaudeQuery = (input: Record<string, unknown>) => AsyncIterable<ClaudeSdkMessage>;
37
-
38
- type AgentInputContentBlock = {
39
- type?: string;
40
- text?: string;
41
- data?: string;
42
- mimeType?: string;
43
- };
44
-
45
- function id(prefix: string): string {
46
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
47
- }
48
-
49
- function projectHash(cwd: string): string {
50
- return (
51
- "-" +
52
- resolve(cwd)
53
- .replace(/\/$/, "")
54
- .replace(/\//g, "-")
55
- );
56
- }
57
-
58
- function claudeProjectDir(cwd: string): string {
59
- return join(homedir(), ".claude", "projects", projectHash(cwd));
60
- }
61
-
62
- function claudeProjectsRoot(): string {
63
- return join(homedir(), ".claude", "projects");
64
- }
65
-
66
- function claudeProjectDirs(preferredCwd: string): string[] {
67
- const root = claudeProjectsRoot();
68
- const preferred = claudeProjectDir(preferredCwd);
69
- const dirs: string[] = [];
70
- if (existsSync(preferred)) dirs.push(preferred);
71
- if (!existsSync(root)) return dirs;
72
- try {
73
- for (const entry of readdirSync(root)) {
74
- const fullPath = join(root, entry);
75
- if (fullPath !== preferred && statSync(fullPath).isDirectory()) {
76
- dirs.push(fullPath);
77
- }
78
- }
79
- } catch {
80
- // Ignore unreadable Claude storage.
81
- }
82
- return dirs;
83
- }
84
-
85
- function parseTimestamp(value: unknown): number | undefined {
86
- if (typeof value === "number" && Number.isFinite(value)) {
87
- return value > 10_000_000_000 ? value : value * 1000;
88
- }
89
- if (typeof value === "string" && value.trim()) {
90
- const parsed = Date.parse(value);
91
- return Number.isNaN(parsed) ? undefined : parsed;
92
- }
93
- return undefined;
94
- }
95
-
96
- function extractToolResultText(content: unknown): string {
97
- if (typeof content === "string") return content;
98
- if (Array.isArray(content)) {
99
- return content
100
- .map((part) => {
101
- if (typeof part === "string") return part;
102
- if (part && typeof part === "object" && "text" in part && typeof (part as { text: unknown }).text === "string") {
103
- return (part as { text: string }).text;
104
- }
105
- return "";
106
- })
107
- .join("");
108
- }
109
- if (content && typeof content === "object" && "text" in content && typeof (content as { text: unknown }).text === "string") {
110
- return (content as { text: string }).text;
111
- }
112
- return String(content ?? "");
113
- }
114
-
115
- function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { data: string; mimeType: string } {
116
- const match = value.match(/^data:([^;,]+)?;base64,(.*)$/is);
117
- if (!match) return { data: value, mimeType: fallbackMimeType };
118
- return {
119
- data: match[2] ?? "",
120
- mimeType: match[1] || fallbackMimeType,
121
- };
122
- }
123
-
124
- function toClaudeMessageContent(blocks: AgentInputContentBlock[]): Record<string, unknown>[] {
125
- return blocks
126
- .map((block) => {
127
- if (block.type === "image" && block.data) {
128
- const image = splitImageDataUrl(block.data, block.mimeType);
129
- return {
130
- type: "image",
131
- source: {
132
- type: "base64",
133
- media_type: image.mimeType,
134
- data: image.data,
135
- },
136
- };
137
- }
138
- return { type: "text", text: block.text ?? "" };
139
- })
140
- .filter((block) =>
141
- block.type === "image" ||
142
- (typeof block.text === "string" && block.text.trim().length > 0),
143
- );
144
- }
145
-
146
- async function* singleUserMessage(content: Record<string, unknown>[]): AsyncIterable<Record<string, unknown>> {
147
- yield {
148
- type: "user",
149
- message: {
150
- role: "user",
151
- content,
152
- },
153
- parent_tool_use_id: null,
154
- };
155
- }
156
-
157
- function isRealClaudeSessionId(value: string | undefined): value is string {
158
- return Boolean(value && !value.startsWith("agent-session-"));
159
- }
160
-
161
- function asRecord(value: unknown): Record<string, unknown> | undefined {
162
- return value && typeof value === "object" && !Array.isArray(value)
163
- ? value as Record<string, unknown>
164
- : undefined;
165
- }
166
-
167
- function stringField(value: unknown, keys: string[]): string | undefined {
168
- const record = asRecord(value);
169
- if (!record) return undefined;
170
- for (const key of keys) {
171
- const candidate = record[key];
172
- if (typeof candidate === "string" && candidate.trim()) return candidate;
173
- }
174
- return undefined;
175
- }
176
-
177
- function previewText(text: string): string {
178
- return text.replace(/\s+/g, " ").trim().slice(0, 160);
179
- }
180
-
181
- function contentText(value: unknown): string {
182
- if (typeof value === "string") return value;
183
- if (!Array.isArray(value)) return "";
184
- return value
185
- .map((entry) => {
186
- if (typeof entry === "string") return entry;
187
- const raw = asRecord(entry);
188
- if (!raw) return "";
189
- if (typeof raw.text === "string") return raw.text;
190
- if (raw.type === "image") return `[${stringField(raw.source, ["media_type", "mimeType"]) ?? "image"} attachment]`;
191
- return "";
192
- })
193
- .filter(Boolean)
194
- .join("\n");
195
- }
196
-
197
- function claudeToolItemType(toolName: string | undefined): "commandExecution" | "fileChange" | "toolCall" {
198
- if (toolName === "Bash") return "commandExecution";
199
- if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") return "fileChange";
200
- return "toolCall";
201
- }
202
-
203
- function claudeToolHistoryItem(
204
- toolId: string,
205
- toolName: string | undefined,
206
- toolInput: Record<string, unknown> | undefined,
207
- cwd: string,
208
- ): Record<string, unknown> {
209
- const filePath = stringField(toolInput, ["file_path", "path", "notebook_path"]);
210
- return {
211
- id: toolId,
212
- type: claudeToolItemType(toolName),
213
- toolName: toolName ?? "tool",
214
- tool: toolName ?? "tool",
215
- input: toolInput,
216
- toolInput,
217
- command: stringField(toolInput, ["command"]),
218
- cwd: stringField(toolInput, ["cwd"]) ?? cwd,
219
- path: filePath,
220
- status: "running",
221
- };
222
- }
223
-
224
- export function parseClaudeJsonlSession(input: {
225
- text: string;
226
- cwd: string;
227
- sessionId?: string;
228
- fallbackUpdatedAt?: number;
229
- }): Record<string, unknown> {
230
- const turns: Array<Record<string, unknown>> = [];
231
- const toolItems = new Map<string, Record<string, unknown>>();
232
- let sessionId = input.sessionId;
233
- let model: string | undefined;
234
- let title: string | undefined;
235
- let preview: string | undefined;
236
- let createdAt: number | undefined;
237
- let updatedAt = input.fallbackUpdatedAt;
238
- let cwd = input.cwd;
239
- // Last assistant line's token usage. input_tokens at the latest turn is the
240
- // current context-window occupancy (NOT a running sum), so we keep the last
241
- // one we see rather than accumulating. Decoupled from any live LinkShell turn
242
- // — this is read straight from the on-disk transcript.
243
- let usage: Record<string, unknown> | undefined;
244
- // Last-seen raw permissionMode ("default" | "acceptEdits" | ...). Claude
245
- // records it on user records; the LAST one is the session's current mode.
246
- // Captured here so readSessionConfig can be a cache field read instead of a
247
- // second full parse of the transcript.
248
- let permissionMode: string | undefined;
249
- let index = 0;
250
-
251
- for (const line of input.text.split(/\r?\n/)) {
252
- if (!line.trim()) continue;
253
- index += 1;
254
- let raw: Record<string, unknown>;
255
- try {
256
- const parsed = JSON.parse(line);
257
- const record = asRecord(parsed);
258
- if (!record) continue;
259
- raw = record;
260
- } catch {
261
- continue;
262
- }
263
-
264
- permissionMode = stringField(raw, ["permissionMode"]) ?? permissionMode;
265
- const timestamp = parseTimestamp(raw.timestamp ?? raw.createdAt ?? raw.created_at);
266
- cwd = stringField(raw, ["cwd", "workingDirectory", "workspacePath"]) ?? cwd;
267
- if (timestamp !== undefined) {
268
- createdAt = createdAt === undefined ? timestamp : Math.min(createdAt, timestamp);
269
- updatedAt = updatedAt === undefined ? timestamp : Math.max(updatedAt, timestamp);
270
- }
271
-
272
- sessionId = sessionId ?? stringField(raw, ["sessionId", "session_id"]);
273
- model = model ?? stringField(raw, ["model"]);
274
- const message = asRecord(raw.message);
275
- model = model ?? stringField(message, ["model"]);
276
- const type = stringField(raw, ["type"]);
277
- const role = stringField(message, ["role"]) ?? type;
278
- const content = message && "content" in message ? message.content : raw.content;
279
- const turnId = stringField(raw, ["uuid", "id"]) ?? `claude-history-${index}`;
280
- const items: Record<string, unknown>[] = [];
281
-
282
- if (type === "summary" || typeof raw.summary === "string") {
283
- const summary = typeof raw.summary === "string" ? raw.summary : undefined;
284
- if (summary) {
285
- items.push({
286
- id: turnId,
287
- type: "contextCompaction",
288
- summary,
289
- status: "completed",
290
- });
291
- preview = previewText(summary) || preview;
292
- }
293
- } else if (role === "user") {
294
- const contentBlocks = Array.isArray(content) ? content : [];
295
- const toolResults = contentBlocks
296
- .map(asRecord)
297
- .filter((block): block is Record<string, unknown> => Boolean(block && block.type === "tool_result"));
298
- const ordinaryText = Array.isArray(content)
299
- ? contentText(contentBlocks.filter((block) => asRecord(block)?.type !== "tool_result"))
300
- : contentText(content);
301
- if (ordinaryText.trim()) {
302
- items.push({
303
- id: stringField(message, ["id"]) ?? turnId,
304
- type: "userMessage",
305
- content: [{ type: "text", text: ordinaryText }],
306
- });
307
- title = title ?? previewText(ordinaryText);
308
- preview = previewText(ordinaryText) || preview;
309
- }
310
- for (const block of toolResults) {
311
- const toolId = stringField(block, ["tool_use_id"]) ?? `tool-result-${index}`;
312
- const previous = toolItems.get(toolId);
313
- const output = extractToolResultText(block.content);
314
- items.push({
315
- ...previous,
316
- id: toolId,
317
- type: stringField(previous, ["type"]) ?? "toolCall",
318
- toolName: stringField(previous, ["toolName", "tool", "name"]),
319
- tool: stringField(previous, ["toolName", "tool", "name"]),
320
- status: block.is_error === true ? "failed" : "completed",
321
- output,
322
- aggregatedOutput: output,
323
- isError: block.is_error === true,
324
- });
325
- }
326
- } else if (role === "assistant") {
327
- const assistantMessageId = stringField(message, ["id"]) ?? turnId;
328
- const lineUsage = asRecord(message?.usage);
329
- if (lineUsage) usage = lineUsage;
330
- if (typeof content === "string") {
331
- if (content.trim()) {
332
- items.push({
333
- id: assistantMessageId,
334
- type: "agentMessage",
335
- content: [{ type: "text", text: content }],
336
- status: "completed",
337
- });
338
- preview = previewText(content) || preview;
339
- }
340
- } else {
341
- const blocks = Array.isArray(content) ? content.map(asRecord).filter((block): block is Record<string, unknown> => Boolean(block)) : [];
342
- const text = contentText(blocks.filter((block) => block.type === "text"));
343
- if (text.trim()) {
344
- items.push({
345
- id: assistantMessageId,
346
- type: "agentMessage",
347
- content: [{ type: "text", text }],
348
- status: "completed",
349
- });
350
- preview = previewText(text) || preview;
351
- }
352
- blocks.forEach((block, blockIndex) => {
353
- if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) {
354
- items.push({
355
- id: `${turnId}:thinking:${blockIndex}`,
356
- type: "thinking",
357
- text: block.thinking,
358
- status: "completed",
359
- });
360
- } else if (block.type === "tool_use") {
361
- const toolId = stringField(block, ["id"]) ?? `${turnId}:tool:${blockIndex}`;
362
- const toolName = stringField(block, ["name"]);
363
- const toolInput = asRecord(block.input);
364
- const item = claudeToolHistoryItem(toolId, toolName, toolInput, cwd);
365
- toolItems.set(toolId, item);
366
- items.push(item);
367
- }
368
- });
369
- }
370
- } else if (type === "system") {
371
- model = model ?? stringField(raw, ["model"]);
372
- }
373
-
374
- if (items.length > 0) {
375
- turns.push({
376
- id: turnId,
377
- createdAt: timestamp,
378
- items,
379
- });
380
- }
381
- }
382
-
383
- return {
384
- thread: {
385
- id: sessionId ?? input.sessionId,
386
- sessionId: sessionId ?? input.sessionId,
387
- cwd,
388
- model,
389
- title,
390
- preview,
391
- createdAt,
392
- updatedAt,
393
- usage,
394
- turns,
395
- ...(permissionMode ? { permissionMode } : {}),
396
- },
397
- };
398
- }
399
-
400
- /** Derive whether an on-disk Claude transcript is being actively driven RIGHT
401
- * NOW by some process (LinkShell or an external `claude` terminal), purely from
402
- * the file. Combines a structural signal (the last record) with a freshness
403
- * signal (mtime), so it neither misses a live external turn nor sticks at
404
- * "running" forever if that process dies mid-turn.
405
- *
406
- * - last record is a completed assistant turn (stop_reason "end_turn") → idle,
407
- * regardless of mtime (the turn is definitively done).
408
- * - otherwise the structure is mid-turn (awaiting an assistant response, an
409
- * assistant still calling tools, or a half-written trailing line). If the
410
- * file changed within FRESH_MS, a process is appending → running; if it's
411
- * stale, the driver is gone → idle.
412
- * Verified against real transcripts: mid-turn+fresh→running, mid-turn+stale→
413
- * idle, end_turn+fresh→idle, half-written-line+fresh→running. */
414
- const TRANSCRIPT_FRESH_MS = 10_000;
415
-
416
- /** The structural half of the status verdict: does the LAST record of a
417
- * transcript indicate an in-progress turn (awaiting/producing an assistant
418
- * response, or a half-written trailing line)? This is purely content-derived
419
- * — it does NOT depend on the clock — so it can be cached alongside the parsed
420
- * metadata and reused across syncs until the file's (mtime,size) changes.
421
- * Scans backward from the end to avoid split()-ing a possibly-huge transcript. */
422
- function deriveTranscriptMidTurn(text: string): boolean {
423
- let last: Record<string, unknown> | undefined;
424
- let sawUnparseableTail = false;
425
- let end = text.length;
426
- while (end > 0) {
427
- const nl = text.lastIndexOf("\n", end - 1);
428
- const line = text.slice(nl + 1, end).trim();
429
- end = nl; // step before this newline; nl === -1 ends the loop (first line)
430
- if (!line) continue;
431
- try {
432
- last = asRecord(JSON.parse(line));
433
- break;
434
- } catch {
435
- // A trailing half-written line means a writer is mid-append.
436
- sawUnparseableTail = true;
437
- }
438
- }
439
- if (!last) return false;
440
- const message = asRecord(last.message);
441
- const role = stringField(message, ["role"]) ?? stringField(last, ["type"]);
442
- const stop = message ? message.stop_reason ?? null : null;
443
- if (role === "assistant" && stop === "end_turn") return false;
444
- return (
445
- role === "user" ||
446
- last.type === "user" ||
447
- (role === "assistant" && (stop === "tool_use" || stop === null)) ||
448
- sawUnparseableTail
449
- );
450
- }
451
-
452
- /** Combine the (clock-independent) structural verdict with the freshness signal.
453
- * A stale file (no writes within FRESH_MS) is always idle; a mid-turn file that
454
- * is still being appended to is running. Cheap: one comparison + one boolean. */
455
- function statusFromMidTurn(midTurn: boolean, mtimeMs: number, nowMs: number): "running" | "idle" {
456
- if (nowMs - mtimeMs >= TRANSCRIPT_FRESH_MS) return "idle";
457
- return midTurn ? "running" : "idle";
458
- }
459
-
460
- /** Derive whether an on-disk Claude transcript is being actively driven RIGHT
461
- * NOW, purely from the file. See {@link deriveTranscriptMidTurn} /
462
- * {@link statusFromMidTurn}; kept as a convenience wrapper for callers that
463
- * hold the full text and the clock but no cache. */
464
- function deriveTranscriptStatus(text: string, mtimeMs: number, nowMs: number): "running" | "idle" {
465
- // Stale short-circuit: most sessions in ~/.claude/projects are old, so avoid
466
- // the backward scan entirely when the file can't possibly be running.
467
- if (nowMs - mtimeMs >= TRANSCRIPT_FRESH_MS) return "idle";
468
- return statusFromMidTurn(deriveTranscriptMidTurn(text), mtimeMs, nowMs);
469
- }
470
-
471
- /** Parse a transcript into the clock-independent, cacheable half of its list
472
- * metadata: everything except `status` (which depends on `now`). The caller
473
- * layers status on via {@link statusFromMidTurn}, and can cache this result
474
- * keyed by (path, mtime, size) so an unchanged transcript is never re-read. */
475
- function claudeSessionBaseFromFile(
476
- filePath: string,
477
- cwd: string,
478
- sessionId: string,
479
- mtimeMs: number,
480
- ): { base: Record<string, unknown>; midTurn: boolean } {
481
- const text = readFileSync(filePath, "utf8");
482
- const parsed = parseClaudeJsonlSession({
483
- text,
484
- cwd,
485
- sessionId,
486
- fallbackUpdatedAt: mtimeMs,
487
- });
488
- const thread = asRecord(parsed.thread) ?? {};
489
- const base: Record<string, unknown> = {
490
- id: sessionId,
491
- cwd: stringField(thread, ["cwd", "workingDirectory", "workspacePath"]) ?? cwd,
492
- title: stringField(thread, ["title", "preview"]),
493
- model: stringField(thread, ["model"]),
494
- createdAt: parseTimestamp(thread.createdAt),
495
- lastActivityAt: parseTimestamp(thread.updatedAt) ?? mtimeMs,
496
- lastModified: mtimeMs,
497
- usage: asRecord(thread.usage),
498
- };
499
- return { base, midTurn: deriveTranscriptMidTurn(text) };
500
- }
501
-
502
- function claudeSessionMetadataFromFile(filePath: string, cwd: string, sessionId: string): Record<string, unknown> {
503
- const stat = statSync(filePath);
504
- const { base, midTurn } = claudeSessionBaseFromFile(filePath, cwd, sessionId, stat.mtimeMs);
505
- // Live status read straight from the transcript, so a session another
506
- // `claude` process is actively driving shows as "running" in the list
507
- // rather than the hardcoded "idle" the workspace would otherwise assign.
508
- return { ...base, status: statusFromMidTurn(midTurn, stat.mtimeMs, Date.now()) };
509
- }
510
-
511
- function isInsideCwd(cwd: string, candidate: string): boolean {
512
- const root = resolve(cwd);
513
- const target = resolve(root, candidate);
514
- const rel = relative(root, target);
515
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
516
- }
517
-
518
- function outcomeFromPermissionResponse(value: unknown): "allow" | "deny" {
519
- const raw = value && typeof value === "object" ? value as Record<string, unknown> : {};
520
- if (raw.behavior === "allow") return "allow";
521
- if (raw.behavior === "deny") return "deny";
522
- const outcome = raw.outcome && typeof raw.outcome === "object"
523
- ? (raw.outcome as Record<string, unknown>).outcome
524
- : raw.outcome;
525
- if (outcome === "selected") {
526
- const optionId = raw.outcome && typeof raw.outcome === "object"
527
- ? String((raw.outcome as Record<string, unknown>).optionId ?? "")
528
- : "";
529
- return optionId.toLowerCase().includes("allow") ? "allow" : "deny";
530
- }
531
- return outcome === "allow" ? "allow" : "deny";
532
- }
533
-
534
- export function claudePermissionModeFor(input: {
535
- permissionMode?: AgentPermissionMode;
536
- collaborationMode?: AgentCollaborationMode;
537
- }): ClaudePermissionMode {
538
- if (input.collaborationMode === "plan" || input.permissionMode === "read_only") return "plan";
539
- if (input.permissionMode === "workspace_write") return "acceptEdits";
540
- if (input.permissionMode === "full_access") return "bypassPermissions";
541
- return "default";
542
- }
543
-
544
- const FALLBACK_MODEL_LABELS: Record<string, string> = {
545
- default: "默认模型",
546
- sonnet: "Sonnet",
547
- opus: "Opus",
548
- opusplan: "Opus Plan",
549
- haiku: "Haiku",
550
- "sonnet[1m]": "Sonnet 1M",
551
- "opus[1m]": "Opus 1M",
552
- };
553
-
554
- // Canonical Claude Code model aliases, used whenever live model discovery
555
- // (supportedModels()) returns nothing. Hoisted so the two listModels() fallback
556
- // branches stay in sync — a new alias added here reaches both.
557
- const DEFAULT_FALLBACK_MODELS = ["default", "sonnet", "opus", "opusplan", "haiku", "sonnet[1m]", "opus[1m]"];
558
- const toModelEntries = (ids: string[]): { id: string; label: string }[] =>
559
- ids.map((id) => ({ id, label: FALLBACK_MODEL_LABELS[id] ?? id }));
560
-
561
- export class ClaudeSdkClient {
562
- private query: ClaudeQuery | undefined;
563
- private startup: ((params?: { options?: Record<string, unknown>; initializeTimeoutMs?: number }) => Promise<{ query(prompt: string): AsyncGenerator<unknown> & { supportedModels(): Promise<{ value: string; displayName: string }[]>; interrupt(): Promise<void> }; close(): void }>) | undefined;
564
- private claudeSessionId: string | undefined;
565
- private abortController: AbortController | undefined;
566
- private permissionWaiters = new Map<string, (outcome: "allow" | "deny") => void>();
567
- // Per-file cache for listSessions: an unchanged transcript (same mtime+size)
568
- // is never re-read or re-parsed. fs.watch re-syncs fire every ~1.5s while any
569
- // session is active, and ~/.claude/projects can hold hundreds of old
570
- // transcripts — without this, each sync would readFileSync + fully parse
571
- // every one of them (the "scan storm"). Only the file being actively appended
572
- // to changes, so the steady-state cost drops to stat()+status per file.
573
- private sessionMetaCache = new Map<string, { mtimeMs: number; size: number; base: Record<string, unknown>; midTurn: boolean }>();
574
- // Full parsed-transcript cache shared by readSession / readSessionConfig /
575
- // listTurns, keyed by file path and validated by (mtime,size) like
576
- // sessionMetaCache above. Opening a conversation hits all three of those in
577
- // quick succession, and every history page re-slices the same parse — on a
578
- // 39MB transcript that was ~257ms per open plus ~93ms per scroll page of
579
- // redundant readFileSync + per-line JSON.parse. Unlike sessionMetaCache this
580
- // holds the FULL turn list (large for big transcripts), so it is bounded to a
581
- // small LRU (Map iteration order + delete/re-set on hit = LRU).
582
- private parsedTranscriptCache = new Map<string, { mtimeMs: number; size: number; parsed: Record<string, unknown> }>();
583
- private static readonly PARSED_TRANSCRIPT_CACHE_MAX = 8;
584
-
585
- /** Read + parse a session transcript through the LRU cache. Returns the
586
- * parseClaudeJsonlSession result (stable for a given (mtime,size)); an
587
- * appended-to or rewritten file re-parses on next access. */
588
- private parsedTranscript(filePath: string, sessionId: string): Record<string, unknown> {
589
- const stat = statSync(filePath);
590
- const cached = this.parsedTranscriptCache.get(filePath);
591
- if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
592
- // Refresh recency so the hottest transcripts survive the LRU bound.
593
- this.parsedTranscriptCache.delete(filePath);
594
- this.parsedTranscriptCache.set(filePath, cached);
595
- return cached.parsed;
596
- }
597
- const parsed = parseClaudeJsonlSession({
598
- text: readFileSync(filePath, "utf8"),
599
- cwd: this.input.cwd,
600
- sessionId,
601
- fallbackUpdatedAt: stat.mtimeMs,
602
- });
603
- this.parsedTranscriptCache.delete(filePath);
604
- this.parsedTranscriptCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, parsed });
605
- while (this.parsedTranscriptCache.size > ClaudeSdkClient.PARSED_TRANSCRIPT_CACHE_MAX) {
606
- const oldest = this.parsedTranscriptCache.keys().next().value;
607
- if (oldest === undefined) break;
608
- this.parsedTranscriptCache.delete(oldest);
609
- }
610
- return parsed;
611
- }
612
-
613
- constructor(
614
- private readonly input: {
615
- command: string;
616
- protocol: AgentProtocol;
617
- framing: AgentFraming;
618
- cwd: string;
619
- onNotification: (method: string, params: unknown) => void;
620
- onRequest: (method: string, params: unknown) => Promise<unknown> | unknown;
621
- onExit: (message: string) => void;
622
- },
623
- ) {}
624
-
625
- async initialize(): Promise<unknown> {
626
- try {
627
- const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<unknown>;
628
- const mod = await dynamicImport("@anthropic-ai/claude-agent-sdk");
629
- const query = (mod as { query?: unknown }).query;
630
- if (typeof query !== "function") {
631
- throw new Error("Claude Agent SDK does not export query()");
632
- }
633
- this.query = query as ClaudeQuery;
634
- // startup() pre-warms the CLI subprocess so we can discover available models
635
- // without starting a full conversation (via supportedModels() on the Query).
636
- const startup = (mod as { startup?: unknown }).startup;
637
- if (typeof startup === "function") {
638
- this.startup = startup as typeof this.startup;
639
- }
640
- return { status: "ok", protocol: "claude-agent-sdk" };
641
- } catch (error) {
642
- throw new Error(
643
- `Claude Agent SDK is not available: ${error instanceof Error ? error.message : String(error)}`,
644
- );
645
- }
646
- }
647
-
648
- async newSession(input: { cwd: string; mcpServers?: unknown }): Promise<unknown> {
649
- this.claudeSessionId = undefined;
650
- return { sessionId: undefined, status: "ready", cwd: input.cwd };
651
- }
652
-
653
- async loadSession(input: { sessionId: string; cwd: string; mcpServers?: unknown }): Promise<unknown> {
654
- this.claudeSessionId = input.sessionId;
655
- return { sessionId: input.sessionId, status: "loaded", cwd: input.cwd };
656
- }
657
-
658
- async readSession(input: { sessionId: string; includeTurns?: boolean }): Promise<unknown> {
659
- const filePath = this.resolveSessionFile(input.sessionId);
660
- if (!filePath) {
661
- return {
662
- thread: {
663
- id: input.sessionId,
664
- sessionId: input.sessionId,
665
- cwd: this.input.cwd,
666
- turns: [],
667
- },
668
- };
669
- }
670
- return this.parsedTranscript(filePath, input.sessionId);
671
- }
672
-
673
- /** Resolve the on-disk JSONL transcript for a session, preferring the cwd's
674
- * project dir and falling back to a scan of sibling project dirs. */
675
- private resolveSessionFile(sessionId: string): string | undefined {
676
- const preferred = join(claudeProjectDir(this.input.cwd), `${sessionId}.jsonl`);
677
- if (existsSync(preferred)) return preferred;
678
- return claudeProjectDirs(this.input.cwd)
679
- .map((dir) => join(dir, `${sessionId}.jsonl`))
680
- .find((candidate) => existsSync(candidate));
681
- }
682
-
683
- /** Fork a session: copy the source transcript into a NEW session file,
684
- * truncated to include everything up to and including the turn `uptoTurnId`
685
- * (omitted → full clone). Claude has no native fork primitive, so this
686
- * simulates one by writing a fresh <uuid>.jsonl alongside the source. The
687
- * source file is NEVER modified. Turn ids match the per-line uuid/id (or the
688
- * `claude-history-<n>` fallback) that parseClaudeJsonlSession assigns, where
689
- * <n> counts non-empty lines — replicated here so the cut lands exactly on
690
- * the turn the client asked for. Returns the new session id, or undefined if
691
- * the source can't be read or the new file can't be created. */
692
- forkSession(input: { sourceSessionId: string; uptoTurnId?: string }): { sessionId: string } | undefined {
693
- const sourceFile = this.resolveSessionFile(input.sourceSessionId);
694
- if (!sourceFile) return undefined;
695
- let text: string;
696
- try {
697
- text = readFileSync(sourceFile, "utf8");
698
- } catch {
699
- return undefined;
700
- }
701
- const lines = text.split("\n");
702
- let cutIndex = lines.length - 1; // default: keep everything (full clone)
703
- if (input.uptoTurnId) {
704
- let nonEmpty = 0;
705
- let found = -1;
706
- for (let i = 0; i < lines.length; i += 1) {
707
- const line = lines[i]?.trim();
708
- if (!line) continue;
709
- nonEmpty += 1;
710
- let turnId: string;
711
- try {
712
- const rec = asRecord(JSON.parse(line));
713
- turnId = stringField(rec, ["uuid", "id"]) ?? `claude-history-${nonEmpty}`;
714
- } catch {
715
- continue; // unparseable line — parser skips it too (index still bumped)
716
- }
717
- if (turnId === input.uptoTurnId) {
718
- found = i;
719
- break;
720
- }
721
- }
722
- if (found < 0) return undefined; // turn not found — don't silently full-clone
723
- cutIndex = found;
724
- }
725
- const truncated = lines.slice(0, cutIndex + 1).join("\n") + "\n";
726
- const newId = randomUUID();
727
- const dir = claudeProjectDir(this.input.cwd);
728
- const newPath = join(dir, `${newId}.jsonl`);
729
- try {
730
- // wx: fail if it somehow already exists — never overwrite.
731
- writeFileSync(newPath, truncated, { flag: "wx" });
732
- } catch {
733
- return undefined;
734
- }
735
- return { sessionId: newId };
736
- }
737
-
738
- /** Read a session's last-known settings (permission mode, plan mode) straight
739
- * from its on-disk transcript, so opening a session LinkShell never drove
740
- * shows the config it actually ran with instead of UI defaults. Claude
741
- * records `permissionMode` on user records (not reasoning effort), so effort
742
- * is left undefined. Only called on open (one file), never per-list. */
743
- async readSessionConfig(input: { sessionId: string }): Promise<unknown> {
744
- const filePath = this.resolveSessionFile(input.sessionId);
745
- if (!filePath) return {};
746
- let permissionModeRaw: string | undefined;
747
- try {
748
- // The shared parse already tracked the last-seen permissionMode, so this
749
- // is a cache field read on the open path (readSession runs right before).
750
- const thread = asRecord(this.parsedTranscript(filePath, input.sessionId).thread);
751
- permissionModeRaw = stringField(thread, ["permissionMode"]);
752
- } catch {
753
- return {};
754
- }
755
- // Map Claude's permission mode to ours. "default" means no explicit
756
- // override → leave undefined so the pill shows 默认 rather than a guess.
757
- switch (permissionModeRaw) {
758
- case "acceptEdits":
759
- return { permissionMode: "workspace_write" };
760
- case "bypassPermissions":
761
- return { permissionMode: "full_access" };
762
- case "plan":
763
- return { permissionMode: "read_only", collaborationMode: "plan" };
764
- default:
765
- return {};
766
- }
767
- }
768
-
769
- /** Page OLDER transcript turns for scroll-back, mirroring Codex's
770
- * thread/turns/list. The whole transcript lives on disk and is already
771
- * parsed by parseClaudeJsonlSession, so we slice a window of chronological
772
- * turns by an integer end-exclusive cursor.
773
- *
774
- * Cursor = the exclusive upper-bound turn index for the page. No cursor =
775
- * start at the end (total turn count). A page returns turns [lo, hi) where
776
- * lo = max(0, hi - limit); nextCursor = String(lo) while lo > 0, else absent
777
- * (start of history reached). The web client's mergeTimeline dedupes by id
778
- * and re-sorts by createdAt, so any overlap between this page and what the
779
- * client already shows is harmless. */
780
- async listTurns(input: {
781
- sessionId: string;
782
- limit?: number;
783
- cursor?: string;
784
- sortDirection?: "asc" | "desc";
785
- itemsView?: "summary" | "full";
786
- }): Promise<unknown> {
787
- const empty = { sessionId: input.sessionId, turns: [], sortDirection: "asc" as const };
788
- const filePath = this.resolveSessionFile(input.sessionId);
789
- if (!filePath) return empty;
790
-
791
- const parsed = this.parsedTranscript(filePath, input.sessionId);
792
- const thread = asRecord(parsed.thread);
793
- const allTurns = Array.isArray(thread?.turns) ? thread.turns : [];
794
- const total = allTurns.length;
795
- if (total === 0) return empty;
796
-
797
- const limit = input.limit && input.limit > 0 ? Math.floor(input.limit) : 50;
798
- // Parse the end-exclusive cursor; default to the end of history. Clamp into
799
- // [0, total] so a stale/garbage cursor can't slice out of bounds.
800
- const parsedCursor = input.cursor !== undefined ? Number.parseInt(input.cursor, 10) : total;
801
- const hi = Number.isFinite(parsedCursor) ? Math.max(0, Math.min(total, parsedCursor)) : total;
802
- const lo = Math.max(0, hi - limit);
803
- const page = allTurns.slice(lo, hi);
804
- return {
805
- sessionId: input.sessionId,
806
- turns: page, // already chronological (ascending)
807
- sortDirection: "asc" as const,
808
- ...(lo > 0 ? { nextCursor: String(lo) } : {}),
809
- };
810
- }
811
-
812
- async prompt(input: {
813
- sessionId?: string;
814
- content: unknown[];
815
- clientMessageId: string;
816
- model?: string;
817
- reasoningEffort?: string;
818
- permissionMode?: AgentPermissionMode;
819
- collaborationMode?: AgentCollaborationMode;
820
- cwd: string;
821
- }): Promise<unknown> {
822
- if (!this.query) await this.initialize();
823
- if (!this.query) throw new Error("Claude Agent SDK is not initialized");
824
-
825
- this.abortController?.abort();
826
- const abortController = new AbortController();
827
- this.abortController = abortController;
828
-
829
- const inputBlocks = input.content as AgentInputContentBlock[];
830
- const hasImages = inputBlocks.some((block) => block.type === "image" && block.data);
831
- const prompt = inputBlocks
832
- .map((block) => {
833
- if (block.type === "image") return `[${block.mimeType ?? "image"} attachment]`;
834
- return block.text ?? "";
835
- })
836
- .filter(Boolean)
837
- .join("\n");
838
-
839
- const sdkOptions: Record<string, unknown> = {
840
- cwd: input.cwd ?? this.input.cwd,
841
- abortController,
842
- permissionMode: claudePermissionModeFor({
843
- permissionMode: input.permissionMode,
844
- collaborationMode: input.collaborationMode,
845
- }),
846
- toolConfig: {
847
- askUserQuestion: { previewFormat: "markdown" },
848
- },
849
- canUseTool: async (toolName: string, toolInput: unknown) => {
850
- if (toolName === "AskUserQuestion") {
851
- const response = await this.input.onRequest("claude/askUserQuestion", {
852
- ...(asRecord(toolInput) ?? {}),
853
- requestId: id("claude-input"),
854
- sessionId: this.claudeSessionId,
855
- });
856
- return response;
857
- }
858
- if (input.permissionMode === "full_access") return { behavior: "allow", updatedInput: toolInput };
859
- if (["Read", "Glob", "Grep", "LS", "NotebookRead", "TodoRead"].includes(toolName)) {
860
- return { behavior: "allow", updatedInput: toolInput };
861
- }
862
- if (input.permissionMode === "read_only" && ["Write", "Edit", "MultiEdit", "Bash"].includes(toolName)) {
863
- return { behavior: "deny", message: "Read-only mode is active." };
864
- }
865
- if (input.permissionMode === "workspace_write" && ["Write", "Edit", "MultiEdit"].includes(toolName)) {
866
- const filePath = stringField(toolInput, ["file_path", "path", "notebook_path"]);
867
- if (filePath && isInsideCwd(input.cwd ?? this.input.cwd, filePath)) {
868
- return { behavior: "allow", updatedInput: toolInput };
869
- }
870
- }
871
- const requestId = id("claude-perm");
872
- const response = await this.input.onRequest("claude/requestApproval", {
873
- requestId,
874
- sessionId: this.claudeSessionId,
875
- toolCall: {
876
- toolName,
877
- input: toolInput,
878
- },
879
- context: `Claude wants to use ${toolName}`,
880
- options: [
881
- { id: "deny", label: "拒绝", kind: "deny" },
882
- { id: "allow_once", label: "允许一次", kind: "allow" },
883
- ],
884
- });
885
- return outcomeFromPermissionResponse(response) === "allow"
886
- ? { behavior: "allow", updatedInput: toolInput }
887
- : { behavior: "deny", message: "User denied this action from LinkShell." };
888
- },
889
- };
890
- if (input.model) sdkOptions.model = input.model;
891
- if (isRealClaudeSessionId(input.sessionId ?? this.claudeSessionId)) {
892
- sdkOptions.resume = input.sessionId ?? this.claudeSessionId;
893
- }
894
-
895
- const toolNames = new Map<string, string>();
896
- let currentToolId: string | undefined;
897
- let currentToolName: string | undefined;
898
- let currentMessageId: string | undefined;
899
- const progressItemId = `claude-progress:${input.clientMessageId}`;
900
-
901
- try {
902
- this.input.onNotification("item/started", {
903
- sessionId: input.sessionId ?? this.claudeSessionId,
904
- item: {
905
- id: progressItemId,
906
- type: "thinking",
907
- text: "Claude 正在处理请求",
908
- status: "running",
909
- },
910
- });
911
- const queryPrompt = hasImages ? singleUserMessage(toClaudeMessageContent(inputBlocks)) : prompt;
912
- for await (const message of this.query({ prompt: queryPrompt, options: sdkOptions })) {
913
- if (abortController.signal.aborted) break;
914
- this.handleSdkMessage(message, {
915
- cwd: input.cwd ?? this.input.cwd,
916
- toolNames,
917
- currentToolId: (value?: string | null) => {
918
- if (value !== undefined) currentToolId = value ?? undefined;
919
- return currentToolId;
920
- },
921
- currentToolName: (value?: string | null) => {
922
- if (value !== undefined) currentToolName = value ?? undefined;
923
- return currentToolName;
924
- },
925
- currentMessageId: (value?: string | null) => {
926
- if (value !== undefined) currentMessageId = value ?? undefined;
927
- return currentMessageId;
928
- },
929
- });
930
- }
931
- return { sessionId: this.claudeSessionId, status: abortController.signal.aborted ? "cancelled" : "completed" };
932
- } finally {
933
- this.input.onNotification("item/completed", {
934
- sessionId: this.claudeSessionId ?? input.sessionId,
935
- item: {
936
- id: progressItemId,
937
- type: "thinking",
938
- text: abortController.signal.aborted ? "Claude 已停止" : "Claude 已完成",
939
- status: abortController.signal.aborted ? "failed" : "completed",
940
- },
941
- });
942
- if (this.abortController === abortController) this.abortController = undefined;
943
- }
944
- }
945
-
946
- cancel(input: { sessionId?: string; turnId?: string }): void {
947
- this.abortController?.abort();
948
- this.abortController = undefined;
949
- for (const [, resolve] of this.permissionWaiters) resolve("deny");
950
- this.permissionWaiters.clear();
951
- }
952
-
953
- respondPermission(input: { sessionId?: string; requestId: string; outcome: "allow" | "deny"; optionId?: string }): void {
954
- const waiter = this.permissionWaiters.get(input.requestId);
955
- if (!waiter) return;
956
- this.permissionWaiters.delete(input.requestId);
957
- waiter(input.outcome);
958
- }
959
-
960
- async listSessions(): Promise<unknown> {
961
- const sessions: Array<Record<string, unknown>> = [];
962
- const seen = new Set<string>();
963
- const now = Date.now();
964
- const liveKeys = new Set<string>();
965
- for (const projectDir of claudeProjectDirs(this.input.cwd)) {
966
- try {
967
- for (const entry of readdirSync(projectDir)) {
968
- if (!entry.endsWith(".jsonl")) continue;
969
- const sessionId = entry.replace(".jsonl", "");
970
- if (seen.has(sessionId)) continue;
971
- seen.add(sessionId);
972
- const filePath = join(projectDir, entry);
973
- let stat;
974
- try {
975
- stat = statSync(filePath);
976
- } catch {
977
- continue; // vanished between readdir and stat
978
- }
979
- liveKeys.add(filePath);
980
- let cached = this.sessionMetaCache.get(filePath);
981
- if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
982
- // Miss or stale: re-read + re-parse this ONE file, refresh the cache.
983
- const { base, midTurn } = claudeSessionBaseFromFile(filePath, this.input.cwd, sessionId, stat.mtimeMs);
984
- cached = { mtimeMs: stat.mtimeMs, size: stat.size, base, midTurn };
985
- this.sessionMetaCache.set(filePath, cached);
986
- }
987
- // status is clock-dependent, so recompute it every call (cheap) even
988
- // on a cache hit — a running session goes idle purely by time passing.
989
- sessions.push({ ...cached.base, status: statusFromMidTurn(cached.midTurn, stat.mtimeMs, now) });
990
- }
991
- } catch {
992
- // Ignore unreadable Claude project storage.
993
- }
994
- }
995
- // Drop cache entries for transcripts that no longer exist so the map can't
996
- // grow without bound across a long-lived process.
997
- if (this.sessionMetaCache.size > liveKeys.size) {
998
- for (const key of this.sessionMetaCache.keys()) {
999
- if (!liveKeys.has(key)) this.sessionMetaCache.delete(key);
1000
- }
1001
- }
1002
- return { sessions };
1003
- }
1004
-
1005
- async listModels(): Promise<unknown> {
1006
- let defaultModel: string = "default";
1007
- // Read the user's configured default model from settings.json. This is always
1008
- // consulted (both for live discovery and for the fallback path).
1009
- let availableModels: string[] | undefined;
1010
- try {
1011
- const settingsPath = join(homedir(), ".claude", "settings.json");
1012
- if (existsSync(settingsPath)) {
1013
- const raw = JSON.parse(readFileSync(settingsPath, "utf8")) as { model?: unknown; availableModels?: unknown };
1014
- if (typeof raw.model === "string" && raw.model.trim().length > 0) {
1015
- defaultModel = raw.model.trim();
1016
- }
1017
- if (Array.isArray(raw.availableModels)) {
1018
- availableModels = raw.availableModels.filter((m): m is string => typeof m === "string");
1019
- }
1020
- }
1021
- } catch {
1022
- // Fallback to "default" if settings file is unreadable.
1023
- }
1024
-
1025
- // Dynamic model discovery: use the Claude Agent SDK's startup() to get the
1026
- // REAL model list for THIS API key (different keys/tiers see different models).
1027
- // Falls back to settings.json availableModels → hardcoded list on any failure.
1028
- if (this.startup) {
1029
- let warm: Awaited<ReturnType<NonNullable<typeof this.startup>>> | undefined;
1030
- try {
1031
- warm = await this.startup({ initializeTimeoutMs: 10_000 });
1032
- const q = warm.query("");
1033
- try {
1034
- const sdkModels: { value: string; displayName: string }[] = await (q as unknown as { supportedModels(): Promise<{ value: string; displayName: string }[]> }).supportedModels();
1035
- // Discover the REAL slash-command set for the installed Claude Code
1036
- // (built-ins like /goal + project/user custom commands), instead of a
1037
- // hardcoded list that drifts out of sync. Best-effort: an older SDK
1038
- // without supportedCommands() just leaves commands undefined and the
1039
- // static defaults still apply.
1040
- let commands: { name: string; description?: string; argumentHint?: string }[] | undefined;
1041
- try {
1042
- const sdkCommands = await (q as unknown as { supportedCommands?: () => Promise<{ name: string; description?: string; argumentHint?: string }[]> }).supportedCommands?.();
1043
- if (Array.isArray(sdkCommands) && sdkCommands.length > 0) commands = sdkCommands;
1044
- } catch {
1045
- // supportedCommands() unavailable or failed — fall back to static defaults.
1046
- }
1047
- // Discover MCP server status the same best-effort way. Older SDKs
1048
- // without mcpServerStatus() just leave mcpServers undefined; live
1049
- // startupStatus events still populate it. Shape is passed through
1050
- // verbatim to the workspace parser, which tolerates array/record forms.
1051
- let mcpServers: unknown;
1052
- try {
1053
- mcpServers = await (q as unknown as { mcpServerStatus?: () => Promise<unknown> }).mcpServerStatus?.();
1054
- } catch {
1055
- // mcpServerStatus() unavailable or failed — rely on live events.
1056
- }
1057
- if (sdkModels.length > 0) {
1058
- // Filter to admin-configured allowlist if present, then map to our format.
1059
- const allow = availableModels;
1060
- const filtered = allow
1061
- ? sdkModels.filter((m) => allow.includes(m.value) || m.value === "default")
1062
- : sdkModels;
1063
- return {
1064
- defaultModel,
1065
- models: filtered.map((m) => ({ id: m.value, label: m.displayName || m.value })),
1066
- ...(commands ? { commands } : {}),
1067
- ...(mcpServers ? { mcpServers } : {}),
1068
- };
1069
- }
1070
- // No models surfaced, but commands may still be present — return them
1071
- // so the command palette stays in sync even when the model list is empty.
1072
- if (commands || mcpServers) {
1073
- const fallbackModels = availableModels ?? DEFAULT_FALLBACK_MODELS;
1074
- return {
1075
- defaultModel,
1076
- models: toModelEntries(fallbackModels),
1077
- ...(commands ? { commands } : {}),
1078
- ...(mcpServers ? { mcpServers } : {}),
1079
- };
1080
- }
1081
- } catch {
1082
- // supportedModels() failed — the subprocess may be in a bad state. Close it.
1083
- } finally {
1084
- try { warm.close(); } catch { /* ignore */ }
1085
- }
1086
- } catch {
1087
- // startup() failed — clean up if it partially succeeded.
1088
- try { warm?.close(); } catch { /* ignore */ }
1089
- }
1090
- }
1091
-
1092
- // Fallback: admin-configured allowlist, or the full Claude Code alias set.
1093
- const fallbackModels = availableModels ?? DEFAULT_FALLBACK_MODELS;
1094
- return {
1095
- defaultModel,
1096
- models: toModelEntries(fallbackModels),
1097
- };
1098
- }
1099
-
1100
- stop(): void {
1101
- this.cancel({});
1102
- }
1103
-
1104
- private handleSdkMessage(
1105
- event: ClaudeSdkMessage,
1106
- state: {
1107
- cwd: string;
1108
- toolNames: Map<string, string>;
1109
- currentToolId: (value?: string | null) => string | undefined;
1110
- currentToolName: (value?: string | null) => string | undefined;
1111
- currentMessageId: (value?: string | null) => string | undefined;
1112
- },
1113
- ): void {
1114
- switch (event.type) {
1115
- case "system": {
1116
- if (event.subtype === "init" && typeof event.session_id === "string") {
1117
- this.claudeSessionId = event.session_id;
1118
- this.input.onNotification("thread/started", {
1119
- sessionId: event.session_id,
1120
- threadId: event.session_id,
1121
- });
1122
- this.input.onNotification("initialized", {
1123
- sessionId: event.session_id,
1124
- threadId: event.session_id,
1125
- cwd: event.cwd ?? state.cwd,
1126
- model: event.model,
1127
- });
1128
- }
1129
- break;
1130
- }
1131
- case "assistant": {
1132
- const rawMessage = event.message;
1133
- const content = (Array.isArray(rawMessage?.content) ? rawMessage.content : []) as ClaudeContentBlock[];
1134
- state.currentMessageId(null);
1135
- for (const block of content) {
1136
- if (block.type === "thinking") {
1137
- this.input.onNotification("item/completed", {
1138
- sessionId: this.claudeSessionId,
1139
- item: {
1140
- id: event.uuid ?? id("thinking"),
1141
- type: "thinking",
1142
- text: block.thinking,
1143
- status: "completed",
1144
- },
1145
- });
1146
- } else if (block.type === "text") {
1147
- const messageId = (typeof rawMessage?.id === "string" ? rawMessage.id : undefined) ?? event.uuid ?? id("msg");
1148
- state.currentMessageId(messageId);
1149
- this.input.onNotification("item/agentMessage/delta", {
1150
- sessionId: this.claudeSessionId,
1151
- itemId: messageId,
1152
- delta: block.text,
1153
- });
1154
- } else if (block.type === "tool_use") {
1155
- const toolId = block.id ?? id("tool");
1156
- const toolName = block.name ?? "tool";
1157
- state.currentToolId(toolId);
1158
- state.currentToolName(toolName);
1159
- state.toolNames.set(toolId, toolName);
1160
- this.input.onNotification("item/started", {
1161
- sessionId: this.claudeSessionId,
1162
- item: {
1163
- id: toolId,
1164
- type: toolName === "Bash" ? "commandExecution" : toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit" ? "fileChange" : "toolCall",
1165
- toolName,
1166
- tool: toolName,
1167
- input: block.input,
1168
- command: block.input?.command as string | undefined,
1169
- cwd: block.input?.cwd as string | undefined ?? state.cwd,
1170
- status: "running",
1171
- },
1172
- });
1173
- }
1174
- }
1175
- const messageId = state.currentMessageId();
1176
- if (messageId) {
1177
- this.input.onNotification("item/completed", {
1178
- sessionId: this.claudeSessionId,
1179
- item: { id: messageId, type: "agentMessage", status: "completed" },
1180
- });
1181
- }
1182
- break;
1183
- }
1184
- case "user": {
1185
- const rawMessage = event.message;
1186
- const content = (Array.isArray(rawMessage?.content) ? rawMessage.content : []) as ClaudeContentBlock[];
1187
- for (const block of content) {
1188
- if (block.type !== "tool_result") continue;
1189
- const toolId = block.tool_use_id ?? state.currentToolId();
1190
- const toolName = (toolId ? state.toolNames.get(toolId) : undefined) ?? state.currentToolName();
1191
- const output = extractToolResultText(block.content);
1192
- this.input.onNotification("item/completed", {
1193
- sessionId: this.claudeSessionId,
1194
- item: {
1195
- id: toolId ?? id("tool"),
1196
- type: "toolCall",
1197
- toolName,
1198
- tool: toolName,
1199
- status: block.is_error ? "failed" : "completed",
1200
- output,
1201
- aggregatedOutput: output,
1202
- isError: block.is_error === true,
1203
- },
1204
- });
1205
- }
1206
- break;
1207
- }
1208
- case "result": {
1209
- if (typeof event.session_id === "string") this.claudeSessionId = event.session_id;
1210
- this.input.onNotification("turn/completed", {
1211
- sessionId: this.claudeSessionId,
1212
- durationMs: event.duration_ms,
1213
- totalCostUsd: event.total_cost_usd,
1214
- usage: event.usage,
1215
- isError: event.subtype === "error",
1216
- });
1217
- break;
1218
- }
1219
- }
1220
- }
1221
- }