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