linkshell-cli 0.5.1 → 0.5.4

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 (47) hide show
  1. package/dist/cli/src/index.js +23 -1
  2. package/dist/cli/src/index.js.map +1 -1
  3. package/dist/cli/src/runtime/acp/acp-client.d.ts +16 -0
  4. package/dist/cli/src/runtime/acp/acp-client.js +81 -21
  5. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
  6. package/dist/cli/src/runtime/acp/agent-workspace-git.d.ts +63 -0
  7. package/dist/cli/src/runtime/acp/agent-workspace-git.js +297 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace-git.js.map +1 -0
  9. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +14 -0
  10. package/dist/cli/src/runtime/acp/agent-workspace.js +845 -88
  11. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  12. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +17 -1
  13. package/dist/cli/src/runtime/acp/claude-sdk-client.js +331 -19
  14. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
  15. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
  16. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +87 -14
  17. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
  18. package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -0
  19. package/dist/cli/src/runtime/acp/json-rpc.js +6 -0
  20. package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
  21. package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
  22. package/dist/cli/src/runtime/acp-relay.js +73 -0
  23. package/dist/cli/src/runtime/acp-relay.js.map +1 -0
  24. package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
  25. package/dist/cli/src/runtime/bridge-session.js +318 -69
  26. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  27. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  28. package/dist/shared-protocol/src/index.d.ts +5436 -3886
  29. package/dist/shared-protocol/src/index.js +127 -13
  30. package/dist/shared-protocol/src/index.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/index.ts +29 -1
  33. package/src/runtime/acp/acp-client.ts +96 -21
  34. package/src/runtime/acp/agent-workspace.ts +916 -83
  35. package/src/runtime/acp/claude-sdk-client.ts +352 -31
  36. package/src/runtime/acp/claude-stream-json-client.ts +89 -16
  37. package/src/runtime/acp/json-rpc.ts +5 -0
  38. package/src/runtime/bridge-session.ts +343 -75
  39. package/dist/cli/src/runtime/acp/claude-sessions.d.ts +0 -57
  40. package/dist/cli/src/runtime/acp/claude-sessions.js +0 -506
  41. package/dist/cli/src/runtime/acp/claude-sessions.js.map +0 -1
  42. package/dist/cli/src/runtime/acp/codex-rpc-bridge.d.ts +0 -34
  43. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js +0 -204
  44. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js.map +0 -1
  45. package/dist/cli/src/runtime/acp/codex-sessions.d.ts +0 -60
  46. package/dist/cli/src/runtime/acp/codex-sessions.js +0 -731
  47. 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?: "default" | "plan";
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 (input.permissionMode === "full_access") return { behavior: "allow" };
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 { behavior: outcomeFromPermissionResponse(response) === "allow" ? "allow" : "deny" };
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
- currentToolId: (value?: string | null) => {
293
- if (value !== undefined) currentToolId = value ?? undefined;
294
- return currentToolId;
295
- },
296
- currentToolName: (value?: string | null) => {
297
- if (value !== undefined) currentToolName = value ?? undefined;
298
- return currentToolName;
299
- },
300
- currentMessageId: (value?: string | null) => {
301
- if (value !== undefined) currentMessageId = value ?? undefined;
302
- return currentMessageId;
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 projectDir = join(homedir(), ".claude", "projects", projectHash(this.input.cwd));
337
- if (!existsSync(projectDir)) return { sessions: [] };
338
- const sessions: Array<{ id: string; cwd: string; lastModified: number }> = [];
339
- try {
340
- for (const entry of readdirSync(projectDir)) {
341
- if (entry.endsWith(".jsonl")) {
342
- sessions.push({
343
- id: entry.replace(".jsonl", ""),
344
- cwd: this.input.cwd,
345
- lastModified: 0,
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
  }
@@ -82,6 +120,7 @@ function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { dat
82
120
  }
83
121
 
84
122
  export class ClaudeStreamJsonClient {
123
+ private static warnedBypass = false;
85
124
  private child: ChildProcessWithoutNullStreams | undefined;
86
125
  private claudeSessionId: string | undefined;
87
126
  private pendingCancel = false;
@@ -139,6 +178,28 @@ export class ClaudeStreamJsonClient {
139
178
 
140
179
  this.pendingCancel = false;
141
180
 
181
+ // SECURITY: the headless stream-json fallback can only run with
182
+ // --permission-mode bypassPermissions (there is no TTY to prompt on), so
183
+ // remotely-submitted prompts execute tools — including shell — without
184
+ // confirmation. The SDK path (preferred when @anthropic-ai/claude-agent-sdk
185
+ // is installed) routes tool approvals to the mobile UI instead.
186
+ if (/^(1|true|yes)$/i.test(process.env.LINKSHELL_REQUIRE_AGENT_PERMISSIONS ?? "")) {
187
+ throw new Error(
188
+ "Agent permission enforcement is required (LINKSHELL_REQUIRE_AGENT_PERMISSIONS) " +
189
+ "but the Claude stream-json fallback can only run with bypassPermissions. " +
190
+ "Install @anthropic-ai/claude-agent-sdk to enable permission-gated tool approvals.",
191
+ );
192
+ }
193
+ if (!ClaudeStreamJsonClient.warnedBypass) {
194
+ ClaudeStreamJsonClient.warnedBypass = true;
195
+ process.stderr.write(
196
+ "[bridge] WARNING: Claude stream-json fallback runs with bypassPermissions — " +
197
+ "remotely submitted prompts can execute tools (including shell) without confirmation. " +
198
+ "Install @anthropic-ai/claude-agent-sdk for permission-gated control, or set " +
199
+ "LINKSHELL_REQUIRE_AGENT_PERMISSIONS=true to refuse the unsafe fallback.\n",
200
+ );
201
+ }
202
+
142
203
  // Build Claude args
143
204
  const args: string[] = [
144
205
  "--print",
@@ -474,27 +535,39 @@ export class ClaudeStreamJsonClient {
474
535
  }
475
536
 
476
537
  async listSessions(): Promise<unknown> {
477
- const home = homedir();
478
- const projectDir = join(home, ".claude", "projects", projectHash(this.input.cwd));
479
-
480
- if (!existsSync(projectDir)) {
481
- return { sessions: [] };
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")) {
538
+ const sessions: Array<Record<string, unknown>> = [];
539
+ const seen = new Set<string>();
540
+ for (const projectDir of claudeProjectDirs(this.input.cwd)) {
541
+ try {
542
+ for (const entry of readdirSync(projectDir)) {
543
+ if (!entry.endsWith(".jsonl")) continue;
488
544
  const sessionId = entry.replace(".jsonl", "");
545
+ if (seen.has(sessionId)) continue;
546
+ seen.add(sessionId);
547
+ const filePath = join(projectDir, entry);
548
+ const stat = statSync(filePath);
549
+ const parsed = parseClaudeJsonlSession({
550
+ text: readFileSync(filePath, "utf8"),
551
+ cwd: this.input.cwd,
552
+ sessionId,
553
+ fallbackUpdatedAt: stat.mtimeMs,
554
+ });
555
+ const thread = parsed.thread && typeof parsed.thread === "object"
556
+ ? parsed.thread as Record<string, unknown>
557
+ : {};
489
558
  sessions.push({
490
559
  id: sessionId,
491
- cwd: this.input.cwd,
492
- lastModified: 0, // would need fs.statSync for accurate time
560
+ cwd: stringField(thread, ["cwd", "workingDirectory", "workspacePath"]) ?? this.input.cwd,
561
+ title: stringField(thread, ["title", "preview"]),
562
+ model: stringField(thread, ["model"]),
563
+ createdAt: thread.createdAt,
564
+ lastActivityAt: thread.updatedAt ?? stat.mtimeMs,
565
+ lastModified: stat.mtimeMs,
493
566
  });
494
567
  }
568
+ } catch {
569
+ // directory read failed
495
570
  }
496
- } catch {
497
- // directory read failed
498
571
  }
499
572
 
500
573
  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);