linkshell-cli 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/src/index.js +1 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +16 -0
- package/dist/cli/src/runtime/acp/acp-client.js +40 -2
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace-git.d.ts +63 -0
- package/dist/cli/src/runtime/acp/agent-workspace-git.js +297 -0
- package/dist/cli/src/runtime/acp/agent-workspace-git.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +6 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +690 -81
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +17 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +331 -19
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +69 -14
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -0
- package/dist/cli/src/runtime/acp/json-rpc.js +6 -0
- package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
- package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
- package/dist/cli/src/runtime/acp-relay.js +73 -0
- package/dist/cli/src/runtime/acp-relay.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.d.ts +5 -0
- package/dist/cli/src/runtime/bridge-session.js +104 -57
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +2505 -2479
- package/dist/shared-protocol/src/index.js +46 -0
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/runtime/acp/acp-client.ts +53 -2
- package/src/runtime/acp/agent-workspace.ts +752 -76
- package/src/runtime/acp/claude-sdk-client.ts +352 -31
- package/src/runtime/acp/claude-stream-json-client.ts +66 -16
- package/src/runtime/acp/json-rpc.ts +5 -0
- package/src/runtime/bridge-session.ts +114 -61
- package/dist/cli/src/runtime/acp/claude-sessions.d.ts +0 -57
- package/dist/cli/src/runtime/acp/claude-sessions.js +0 -506
- package/dist/cli/src/runtime/acp/claude-sessions.js.map +0 -1
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.d.ts +0 -34
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.js +0 -204
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.js.map +0 -1
- package/dist/cli/src/runtime/acp/codex-sessions.d.ts +0 -60
- package/dist/cli/src/runtime/acp/codex-sessions.js +0 -731
- package/dist/cli/src/runtime/acp/codex-sessions.js.map +0 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { readdirSync, existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
|
|
5
5
|
|
|
6
6
|
type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
|
|
7
|
+
type AgentCollaborationMode = "default" | "plan";
|
|
8
|
+
type ClaudePermissionMode = "default" | "dontAsk" | "acceptEdits" | "bypassPermissions" | "plan";
|
|
7
9
|
|
|
8
10
|
interface ClaudeContentBlock {
|
|
9
11
|
type?: string;
|
|
@@ -52,6 +54,44 @@ function projectHash(cwd: string): string {
|
|
|
52
54
|
);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function claudeProjectDir(cwd: string): string {
|
|
58
|
+
return join(homedir(), ".claude", "projects", projectHash(cwd));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function claudeProjectsRoot(): string {
|
|
62
|
+
return join(homedir(), ".claude", "projects");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function claudeProjectDirs(preferredCwd: string): string[] {
|
|
66
|
+
const root = claudeProjectsRoot();
|
|
67
|
+
const preferred = claudeProjectDir(preferredCwd);
|
|
68
|
+
const dirs: string[] = [];
|
|
69
|
+
if (existsSync(preferred)) dirs.push(preferred);
|
|
70
|
+
if (!existsSync(root)) return dirs;
|
|
71
|
+
try {
|
|
72
|
+
for (const entry of readdirSync(root)) {
|
|
73
|
+
const fullPath = join(root, entry);
|
|
74
|
+
if (fullPath !== preferred && statSync(fullPath).isDirectory()) {
|
|
75
|
+
dirs.push(fullPath);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore unreadable Claude storage.
|
|
80
|
+
}
|
|
81
|
+
return dirs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
85
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
86
|
+
return value > 10_000_000_000 ? value : value * 1000;
|
|
87
|
+
}
|
|
88
|
+
if (typeof value === "string" && value.trim()) {
|
|
89
|
+
const parsed = Date.parse(value);
|
|
90
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
55
95
|
function extractToolResultText(content: unknown): string {
|
|
56
96
|
if (typeof content === "string") return content;
|
|
57
97
|
if (Array.isArray(content)) {
|
|
@@ -133,6 +173,234 @@ function stringField(value: unknown, keys: string[]): string | undefined {
|
|
|
133
173
|
return undefined;
|
|
134
174
|
}
|
|
135
175
|
|
|
176
|
+
function previewText(text: string): string {
|
|
177
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function contentText(value: unknown): string {
|
|
181
|
+
if (typeof value === "string") return value;
|
|
182
|
+
if (!Array.isArray(value)) return "";
|
|
183
|
+
return value
|
|
184
|
+
.map((entry) => {
|
|
185
|
+
if (typeof entry === "string") return entry;
|
|
186
|
+
const raw = asRecord(entry);
|
|
187
|
+
if (!raw) return "";
|
|
188
|
+
if (typeof raw.text === "string") return raw.text;
|
|
189
|
+
if (raw.type === "image") return `[${stringField(raw.source, ["media_type", "mimeType"]) ?? "image"} attachment]`;
|
|
190
|
+
return "";
|
|
191
|
+
})
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function claudeToolItemType(toolName: string | undefined): "commandExecution" | "fileChange" | "toolCall" {
|
|
197
|
+
if (toolName === "Bash") return "commandExecution";
|
|
198
|
+
if (toolName === "Write" || toolName === "Edit" || toolName === "MultiEdit") return "fileChange";
|
|
199
|
+
return "toolCall";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function claudeToolHistoryItem(
|
|
203
|
+
toolId: string,
|
|
204
|
+
toolName: string | undefined,
|
|
205
|
+
toolInput: Record<string, unknown> | undefined,
|
|
206
|
+
cwd: string,
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
const filePath = stringField(toolInput, ["file_path", "path", "notebook_path"]);
|
|
209
|
+
return {
|
|
210
|
+
id: toolId,
|
|
211
|
+
type: claudeToolItemType(toolName),
|
|
212
|
+
toolName: toolName ?? "tool",
|
|
213
|
+
tool: toolName ?? "tool",
|
|
214
|
+
input: toolInput,
|
|
215
|
+
toolInput,
|
|
216
|
+
command: stringField(toolInput, ["command"]),
|
|
217
|
+
cwd: stringField(toolInput, ["cwd"]) ?? cwd,
|
|
218
|
+
path: filePath,
|
|
219
|
+
status: "running",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function parseClaudeJsonlSession(input: {
|
|
224
|
+
text: string;
|
|
225
|
+
cwd: string;
|
|
226
|
+
sessionId?: string;
|
|
227
|
+
fallbackUpdatedAt?: number;
|
|
228
|
+
}): Record<string, unknown> {
|
|
229
|
+
const turns: Array<Record<string, unknown>> = [];
|
|
230
|
+
const toolItems = new Map<string, Record<string, unknown>>();
|
|
231
|
+
let sessionId = input.sessionId;
|
|
232
|
+
let model: string | undefined;
|
|
233
|
+
let title: string | undefined;
|
|
234
|
+
let preview: string | undefined;
|
|
235
|
+
let createdAt: number | undefined;
|
|
236
|
+
let updatedAt = input.fallbackUpdatedAt;
|
|
237
|
+
let cwd = input.cwd;
|
|
238
|
+
let index = 0;
|
|
239
|
+
|
|
240
|
+
for (const line of input.text.split(/\r?\n/)) {
|
|
241
|
+
if (!line.trim()) continue;
|
|
242
|
+
index += 1;
|
|
243
|
+
let raw: Record<string, unknown>;
|
|
244
|
+
try {
|
|
245
|
+
const parsed = JSON.parse(line);
|
|
246
|
+
const record = asRecord(parsed);
|
|
247
|
+
if (!record) continue;
|
|
248
|
+
raw = record;
|
|
249
|
+
} catch {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const timestamp = parseTimestamp(raw.timestamp ?? raw.createdAt ?? raw.created_at);
|
|
254
|
+
cwd = stringField(raw, ["cwd", "workingDirectory", "workspacePath"]) ?? cwd;
|
|
255
|
+
if (timestamp !== undefined) {
|
|
256
|
+
createdAt = createdAt === undefined ? timestamp : Math.min(createdAt, timestamp);
|
|
257
|
+
updatedAt = updatedAt === undefined ? timestamp : Math.max(updatedAt, timestamp);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
sessionId = sessionId ?? stringField(raw, ["sessionId", "session_id"]);
|
|
261
|
+
model = model ?? stringField(raw, ["model"]);
|
|
262
|
+
const message = asRecord(raw.message);
|
|
263
|
+
model = model ?? stringField(message, ["model"]);
|
|
264
|
+
const type = stringField(raw, ["type"]);
|
|
265
|
+
const role = stringField(message, ["role"]) ?? type;
|
|
266
|
+
const content = message && "content" in message ? message.content : raw.content;
|
|
267
|
+
const turnId = stringField(raw, ["uuid", "id"]) ?? `claude-history-${index}`;
|
|
268
|
+
const items: Record<string, unknown>[] = [];
|
|
269
|
+
|
|
270
|
+
if (type === "summary" || typeof raw.summary === "string") {
|
|
271
|
+
const summary = typeof raw.summary === "string" ? raw.summary : undefined;
|
|
272
|
+
if (summary) {
|
|
273
|
+
items.push({
|
|
274
|
+
id: turnId,
|
|
275
|
+
type: "contextCompaction",
|
|
276
|
+
summary,
|
|
277
|
+
status: "completed",
|
|
278
|
+
});
|
|
279
|
+
preview = previewText(summary) || preview;
|
|
280
|
+
}
|
|
281
|
+
} else if (role === "user") {
|
|
282
|
+
const contentBlocks = Array.isArray(content) ? content : [];
|
|
283
|
+
const toolResults = contentBlocks
|
|
284
|
+
.map(asRecord)
|
|
285
|
+
.filter((block): block is Record<string, unknown> => Boolean(block && block.type === "tool_result"));
|
|
286
|
+
const ordinaryText = Array.isArray(content)
|
|
287
|
+
? contentText(contentBlocks.filter((block) => asRecord(block)?.type !== "tool_result"))
|
|
288
|
+
: contentText(content);
|
|
289
|
+
if (ordinaryText.trim()) {
|
|
290
|
+
items.push({
|
|
291
|
+
id: stringField(message, ["id"]) ?? turnId,
|
|
292
|
+
type: "userMessage",
|
|
293
|
+
content: [{ type: "text", text: ordinaryText }],
|
|
294
|
+
});
|
|
295
|
+
title = title ?? previewText(ordinaryText);
|
|
296
|
+
preview = previewText(ordinaryText) || preview;
|
|
297
|
+
}
|
|
298
|
+
for (const block of toolResults) {
|
|
299
|
+
const toolId = stringField(block, ["tool_use_id"]) ?? `tool-result-${index}`;
|
|
300
|
+
const previous = toolItems.get(toolId);
|
|
301
|
+
const output = extractToolResultText(block.content);
|
|
302
|
+
items.push({
|
|
303
|
+
...previous,
|
|
304
|
+
id: toolId,
|
|
305
|
+
type: stringField(previous, ["type"]) ?? "toolCall",
|
|
306
|
+
toolName: stringField(previous, ["toolName", "tool", "name"]),
|
|
307
|
+
tool: stringField(previous, ["toolName", "tool", "name"]),
|
|
308
|
+
status: block.is_error === true ? "failed" : "completed",
|
|
309
|
+
output,
|
|
310
|
+
aggregatedOutput: output,
|
|
311
|
+
isError: block.is_error === true,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} else if (role === "assistant") {
|
|
315
|
+
const assistantMessageId = stringField(message, ["id"]) ?? turnId;
|
|
316
|
+
if (typeof content === "string") {
|
|
317
|
+
if (content.trim()) {
|
|
318
|
+
items.push({
|
|
319
|
+
id: assistantMessageId,
|
|
320
|
+
type: "agentMessage",
|
|
321
|
+
content: [{ type: "text", text: content }],
|
|
322
|
+
status: "completed",
|
|
323
|
+
});
|
|
324
|
+
preview = previewText(content) || preview;
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
const blocks = Array.isArray(content) ? content.map(asRecord).filter((block): block is Record<string, unknown> => Boolean(block)) : [];
|
|
328
|
+
const text = contentText(blocks.filter((block) => block.type === "text"));
|
|
329
|
+
if (text.trim()) {
|
|
330
|
+
items.push({
|
|
331
|
+
id: assistantMessageId,
|
|
332
|
+
type: "agentMessage",
|
|
333
|
+
content: [{ type: "text", text }],
|
|
334
|
+
status: "completed",
|
|
335
|
+
});
|
|
336
|
+
preview = previewText(text) || preview;
|
|
337
|
+
}
|
|
338
|
+
blocks.forEach((block, blockIndex) => {
|
|
339
|
+
if (block.type === "thinking" && typeof block.thinking === "string" && block.thinking.trim()) {
|
|
340
|
+
items.push({
|
|
341
|
+
id: `${turnId}:thinking:${blockIndex}`,
|
|
342
|
+
type: "thinking",
|
|
343
|
+
text: block.thinking,
|
|
344
|
+
status: "completed",
|
|
345
|
+
});
|
|
346
|
+
} else if (block.type === "tool_use") {
|
|
347
|
+
const toolId = stringField(block, ["id"]) ?? `${turnId}:tool:${blockIndex}`;
|
|
348
|
+
const toolName = stringField(block, ["name"]);
|
|
349
|
+
const toolInput = asRecord(block.input);
|
|
350
|
+
const item = claudeToolHistoryItem(toolId, toolName, toolInput, cwd);
|
|
351
|
+
toolItems.set(toolId, item);
|
|
352
|
+
items.push(item);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
} else if (type === "system") {
|
|
357
|
+
model = model ?? stringField(raw, ["model"]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (items.length > 0) {
|
|
361
|
+
turns.push({
|
|
362
|
+
id: turnId,
|
|
363
|
+
createdAt: timestamp,
|
|
364
|
+
items,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
thread: {
|
|
371
|
+
id: sessionId ?? input.sessionId,
|
|
372
|
+
sessionId: sessionId ?? input.sessionId,
|
|
373
|
+
cwd,
|
|
374
|
+
model,
|
|
375
|
+
title,
|
|
376
|
+
preview,
|
|
377
|
+
createdAt,
|
|
378
|
+
updatedAt,
|
|
379
|
+
turns,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function claudeSessionMetadataFromFile(filePath: string, cwd: string, sessionId: string): Record<string, unknown> {
|
|
385
|
+
const stat = statSync(filePath);
|
|
386
|
+
const parsed = parseClaudeJsonlSession({
|
|
387
|
+
text: readFileSync(filePath, "utf8"),
|
|
388
|
+
cwd,
|
|
389
|
+
sessionId,
|
|
390
|
+
fallbackUpdatedAt: stat.mtimeMs,
|
|
391
|
+
});
|
|
392
|
+
const thread = asRecord(parsed.thread) ?? {};
|
|
393
|
+
return {
|
|
394
|
+
id: sessionId,
|
|
395
|
+
cwd: stringField(thread, ["cwd", "workingDirectory", "workspacePath"]) ?? cwd,
|
|
396
|
+
title: stringField(thread, ["title", "preview"]),
|
|
397
|
+
model: stringField(thread, ["model"]),
|
|
398
|
+
createdAt: parseTimestamp(thread.createdAt),
|
|
399
|
+
lastActivityAt: parseTimestamp(thread.updatedAt) ?? stat.mtimeMs,
|
|
400
|
+
lastModified: stat.mtimeMs,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
136
404
|
function isInsideCwd(cwd: string, candidate: string): boolean {
|
|
137
405
|
const root = resolve(cwd);
|
|
138
406
|
const target = resolve(root, candidate);
|
|
@@ -156,6 +424,16 @@ function outcomeFromPermissionResponse(value: unknown): "allow" | "deny" {
|
|
|
156
424
|
return outcome === "allow" ? "allow" : "deny";
|
|
157
425
|
}
|
|
158
426
|
|
|
427
|
+
export function claudePermissionModeFor(input: {
|
|
428
|
+
permissionMode?: AgentPermissionMode;
|
|
429
|
+
collaborationMode?: AgentCollaborationMode;
|
|
430
|
+
}): ClaudePermissionMode {
|
|
431
|
+
if (input.collaborationMode === "plan" || input.permissionMode === "read_only") return "plan";
|
|
432
|
+
if (input.permissionMode === "workspace_write") return "acceptEdits";
|
|
433
|
+
if (input.permissionMode === "full_access") return "bypassPermissions";
|
|
434
|
+
return "default";
|
|
435
|
+
}
|
|
436
|
+
|
|
159
437
|
export class ClaudeSdkClient {
|
|
160
438
|
private query: ClaudeQuery | undefined;
|
|
161
439
|
private claudeSessionId: string | undefined;
|
|
@@ -201,6 +479,33 @@ export class ClaudeSdkClient {
|
|
|
201
479
|
return { sessionId: input.sessionId, status: "loaded", cwd: input.cwd };
|
|
202
480
|
}
|
|
203
481
|
|
|
482
|
+
async readSession(input: { sessionId: string; includeTurns?: boolean }): Promise<unknown> {
|
|
483
|
+
let filePath = join(claudeProjectDir(this.input.cwd), `${input.sessionId}.jsonl`);
|
|
484
|
+
if (!existsSync(filePath)) {
|
|
485
|
+
const match = claudeProjectDirs(this.input.cwd)
|
|
486
|
+
.map((dir) => join(dir, `${input.sessionId}.jsonl`))
|
|
487
|
+
.find((candidate) => existsSync(candidate));
|
|
488
|
+
if (match) filePath = match;
|
|
489
|
+
}
|
|
490
|
+
if (!existsSync(filePath)) {
|
|
491
|
+
return {
|
|
492
|
+
thread: {
|
|
493
|
+
id: input.sessionId,
|
|
494
|
+
sessionId: input.sessionId,
|
|
495
|
+
cwd: this.input.cwd,
|
|
496
|
+
turns: [],
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const stat = statSync(filePath);
|
|
501
|
+
return parseClaudeJsonlSession({
|
|
502
|
+
text: readFileSync(filePath, "utf8"),
|
|
503
|
+
cwd: this.input.cwd,
|
|
504
|
+
sessionId: input.sessionId,
|
|
505
|
+
fallbackUpdatedAt: stat.mtimeMs,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
204
509
|
async prompt(input: {
|
|
205
510
|
sessionId?: string;
|
|
206
511
|
content: unknown[];
|
|
@@ -208,7 +513,7 @@ export class ClaudeSdkClient {
|
|
|
208
513
|
model?: string;
|
|
209
514
|
reasoningEffort?: string;
|
|
210
515
|
permissionMode?: AgentPermissionMode;
|
|
211
|
-
collaborationMode?:
|
|
516
|
+
collaborationMode?: AgentCollaborationMode;
|
|
212
517
|
cwd: string;
|
|
213
518
|
}): Promise<unknown> {
|
|
214
519
|
if (!this.query) await this.initialize();
|
|
@@ -231,10 +536,25 @@ export class ClaudeSdkClient {
|
|
|
231
536
|
const sdkOptions: Record<string, unknown> = {
|
|
232
537
|
cwd: input.cwd ?? this.input.cwd,
|
|
233
538
|
abortController,
|
|
539
|
+
permissionMode: claudePermissionModeFor({
|
|
540
|
+
permissionMode: input.permissionMode,
|
|
541
|
+
collaborationMode: input.collaborationMode,
|
|
542
|
+
}),
|
|
543
|
+
toolConfig: {
|
|
544
|
+
askUserQuestion: { previewFormat: "markdown" },
|
|
545
|
+
},
|
|
234
546
|
canUseTool: async (toolName: string, toolInput: unknown) => {
|
|
235
|
-
if (
|
|
547
|
+
if (toolName === "AskUserQuestion") {
|
|
548
|
+
const response = await this.input.onRequest("claude/askUserQuestion", {
|
|
549
|
+
...(asRecord(toolInput) ?? {}),
|
|
550
|
+
requestId: id("claude-input"),
|
|
551
|
+
sessionId: this.claudeSessionId,
|
|
552
|
+
});
|
|
553
|
+
return response;
|
|
554
|
+
}
|
|
555
|
+
if (input.permissionMode === "full_access") return { behavior: "allow", updatedInput: toolInput };
|
|
236
556
|
if (["Read", "Glob", "Grep", "LS", "NotebookRead", "TodoRead"].includes(toolName)) {
|
|
237
|
-
return { behavior: "allow" };
|
|
557
|
+
return { behavior: "allow", updatedInput: toolInput };
|
|
238
558
|
}
|
|
239
559
|
if (input.permissionMode === "read_only" && ["Write", "Edit", "MultiEdit", "Bash"].includes(toolName)) {
|
|
240
560
|
return { behavior: "deny", message: "Read-only mode is active." };
|
|
@@ -242,7 +562,7 @@ export class ClaudeSdkClient {
|
|
|
242
562
|
if (input.permissionMode === "workspace_write" && ["Write", "Edit", "MultiEdit"].includes(toolName)) {
|
|
243
563
|
const filePath = stringField(toolInput, ["file_path", "path", "notebook_path"]);
|
|
244
564
|
if (filePath && isInsideCwd(input.cwd ?? this.input.cwd, filePath)) {
|
|
245
|
-
return { behavior: "allow" };
|
|
565
|
+
return { behavior: "allow", updatedInput: toolInput };
|
|
246
566
|
}
|
|
247
567
|
}
|
|
248
568
|
const requestId = id("claude-perm");
|
|
@@ -259,7 +579,9 @@ export class ClaudeSdkClient {
|
|
|
259
579
|
{ id: "allow_once", label: "允许一次", kind: "allow" },
|
|
260
580
|
],
|
|
261
581
|
});
|
|
262
|
-
return
|
|
582
|
+
return outcomeFromPermissionResponse(response) === "allow"
|
|
583
|
+
? { behavior: "allow", updatedInput: toolInput }
|
|
584
|
+
: { behavior: "deny", message: "User denied this action from LinkShell." };
|
|
263
585
|
},
|
|
264
586
|
};
|
|
265
587
|
if (input.model) sdkOptions.model = input.model;
|
|
@@ -289,18 +611,18 @@ export class ClaudeSdkClient {
|
|
|
289
611
|
this.handleSdkMessage(message, {
|
|
290
612
|
cwd: input.cwd ?? this.input.cwd,
|
|
291
613
|
toolNames,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
614
|
+
currentToolId: (value?: string | null) => {
|
|
615
|
+
if (value !== undefined) currentToolId = value ?? undefined;
|
|
616
|
+
return currentToolId;
|
|
617
|
+
},
|
|
618
|
+
currentToolName: (value?: string | null) => {
|
|
619
|
+
if (value !== undefined) currentToolName = value ?? undefined;
|
|
620
|
+
return currentToolName;
|
|
621
|
+
},
|
|
622
|
+
currentMessageId: (value?: string | null) => {
|
|
623
|
+
if (value !== undefined) currentMessageId = value ?? undefined;
|
|
624
|
+
return currentMessageId;
|
|
625
|
+
},
|
|
304
626
|
});
|
|
305
627
|
}
|
|
306
628
|
return { sessionId: this.claudeSessionId, status: abortController.signal.aborted ? "cancelled" : "completed" };
|
|
@@ -333,21 +655,20 @@ export class ClaudeSdkClient {
|
|
|
333
655
|
}
|
|
334
656
|
|
|
335
657
|
async listSessions(): Promise<unknown> {
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
});
|
|
658
|
+
const sessions: Array<Record<string, unknown>> = [];
|
|
659
|
+
const seen = new Set<string>();
|
|
660
|
+
for (const projectDir of claudeProjectDirs(this.input.cwd)) {
|
|
661
|
+
try {
|
|
662
|
+
for (const entry of readdirSync(projectDir)) {
|
|
663
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
664
|
+
const sessionId = entry.replace(".jsonl", "");
|
|
665
|
+
if (seen.has(sessionId)) continue;
|
|
666
|
+
seen.add(sessionId);
|
|
667
|
+
sessions.push(claudeSessionMetadataFromFile(join(projectDir, entry), this.input.cwd, sessionId));
|
|
347
668
|
}
|
|
669
|
+
} catch {
|
|
670
|
+
// Ignore unreadable Claude project storage.
|
|
348
671
|
}
|
|
349
|
-
} catch {
|
|
350
|
-
// Ignore unreadable Claude project storage.
|
|
351
672
|
}
|
|
352
673
|
return { sessions };
|
|
353
674
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { readdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import type { AgentFraming, AgentProtocol } from "./provider-resolver.js";
|
|
7
|
+
import { parseClaudeJsonlSession } from "./claude-sdk-client.js";
|
|
7
8
|
|
|
8
9
|
type AgentPermissionMode = "read_only" | "workspace_write" | "full_access";
|
|
9
10
|
|
|
@@ -47,6 +48,43 @@ function projectHash(cwd: string): string {
|
|
|
47
48
|
);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function claudeProjectsRoot(): string {
|
|
52
|
+
return join(homedir(), ".claude", "projects");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function claudeProjectDir(cwd: string): string {
|
|
56
|
+
return join(claudeProjectsRoot(), projectHash(cwd));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function claudeProjectDirs(preferredCwd: string): string[] {
|
|
60
|
+
const root = claudeProjectsRoot();
|
|
61
|
+
const preferred = claudeProjectDir(preferredCwd);
|
|
62
|
+
const dirs: string[] = [];
|
|
63
|
+
if (existsSync(preferred)) dirs.push(preferred);
|
|
64
|
+
if (!existsSync(root)) return dirs;
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of readdirSync(root)) {
|
|
67
|
+
const fullPath = join(root, entry);
|
|
68
|
+
if (fullPath !== preferred && statSync(fullPath).isDirectory()) {
|
|
69
|
+
dirs.push(fullPath);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore unreadable Claude storage.
|
|
74
|
+
}
|
|
75
|
+
return dirs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stringField(value: unknown, keys: string[]): string | undefined {
|
|
79
|
+
if (!value || typeof value !== "object") return undefined;
|
|
80
|
+
const record = value as Record<string, unknown>;
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
const candidate = record[key];
|
|
83
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
function id(prefix: string): string {
|
|
51
89
|
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
52
90
|
}
|
|
@@ -474,27 +512,39 @@ export class ClaudeStreamJsonClient {
|
|
|
474
512
|
}
|
|
475
513
|
|
|
476
514
|
async listSessions(): Promise<unknown> {
|
|
477
|
-
const
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const sessions: Array<{ id: string; cwd: string; lastModified: number }> = [];
|
|
485
|
-
try {
|
|
486
|
-
for (const entry of readdirSync(projectDir)) {
|
|
487
|
-
if (entry.endsWith(".jsonl")) {
|
|
515
|
+
const sessions: Array<Record<string, unknown>> = [];
|
|
516
|
+
const seen = new Set<string>();
|
|
517
|
+
for (const projectDir of claudeProjectDirs(this.input.cwd)) {
|
|
518
|
+
try {
|
|
519
|
+
for (const entry of readdirSync(projectDir)) {
|
|
520
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
488
521
|
const sessionId = entry.replace(".jsonl", "");
|
|
522
|
+
if (seen.has(sessionId)) continue;
|
|
523
|
+
seen.add(sessionId);
|
|
524
|
+
const filePath = join(projectDir, entry);
|
|
525
|
+
const stat = statSync(filePath);
|
|
526
|
+
const parsed = parseClaudeJsonlSession({
|
|
527
|
+
text: readFileSync(filePath, "utf8"),
|
|
528
|
+
cwd: this.input.cwd,
|
|
529
|
+
sessionId,
|
|
530
|
+
fallbackUpdatedAt: stat.mtimeMs,
|
|
531
|
+
});
|
|
532
|
+
const thread = parsed.thread && typeof parsed.thread === "object"
|
|
533
|
+
? parsed.thread as Record<string, unknown>
|
|
534
|
+
: {};
|
|
489
535
|
sessions.push({
|
|
490
536
|
id: sessionId,
|
|
491
|
-
cwd: this.input.cwd,
|
|
492
|
-
|
|
537
|
+
cwd: stringField(thread, ["cwd", "workingDirectory", "workspacePath"]) ?? this.input.cwd,
|
|
538
|
+
title: stringField(thread, ["title", "preview"]),
|
|
539
|
+
model: stringField(thread, ["model"]),
|
|
540
|
+
createdAt: thread.createdAt,
|
|
541
|
+
lastActivityAt: thread.updatedAt ?? stat.mtimeMs,
|
|
542
|
+
lastModified: stat.mtimeMs,
|
|
493
543
|
});
|
|
494
544
|
}
|
|
545
|
+
} catch {
|
|
546
|
+
// directory read failed
|
|
495
547
|
}
|
|
496
|
-
} catch {
|
|
497
|
-
// directory read failed
|
|
498
548
|
}
|
|
499
549
|
|
|
500
550
|
return { sessions };
|
|
@@ -35,6 +35,7 @@ export class JsonRpcStdioTransport {
|
|
|
35
35
|
}
|
|
36
36
|
>();
|
|
37
37
|
private buffer = "";
|
|
38
|
+
private closed = false;
|
|
38
39
|
|
|
39
40
|
constructor(
|
|
40
41
|
private readonly command: string,
|
|
@@ -46,6 +47,7 @@ export class JsonRpcStdioTransport {
|
|
|
46
47
|
|
|
47
48
|
start(cwd: string): void {
|
|
48
49
|
if (this.child) return;
|
|
50
|
+
this.closed = false;
|
|
49
51
|
this.child = spawn(this.command, {
|
|
50
52
|
cwd,
|
|
51
53
|
shell: true,
|
|
@@ -169,6 +171,9 @@ export class JsonRpcStdioTransport {
|
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
private failAll(message: string): void {
|
|
174
|
+
if (this.closed) return;
|
|
175
|
+
this.closed = true;
|
|
176
|
+
this.child = undefined;
|
|
172
177
|
this.onExit(message);
|
|
173
178
|
for (const [, pending] of this.pending) {
|
|
174
179
|
if (pending.timer) clearTimeout(pending.timer);
|