linkshell-cli 0.3.9 → 0.3.11

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.
@@ -4,6 +4,7 @@ import { listClaudeStoredSessions } from "./claude-sessions.js";
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";
7
8
 
8
9
  type AgentInputContentBlock = {
9
10
  type?: string;
@@ -69,6 +70,23 @@ function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { dat
69
70
  };
70
71
  }
71
72
 
73
+ function claudeEffort(value: string | undefined): "low" | "medium" | "high" | "xhigh" | undefined {
74
+ if (value === "low" || value === "medium" || value === "high" || value === "xhigh") return value;
75
+ if (value === "minimal") return "low";
76
+ return undefined;
77
+ }
78
+
79
+ function claudePermissionMode(
80
+ permissionMode: AgentPermissionMode | undefined,
81
+ collaborationMode: AgentCollaborationMode | undefined,
82
+ ): "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" {
83
+ if (collaborationMode === "plan") return "plan";
84
+ if (permissionMode === "full_access") return "bypassPermissions";
85
+ if (permissionMode === "workspace_write") return "acceptEdits";
86
+ if (permissionMode === "read_only") return "dontAsk";
87
+ return "default";
88
+ }
89
+
72
90
  export class ClaudeStreamJsonClient {
73
91
  private child: ChildProcessWithoutNullStreams | undefined;
74
92
  private claudeSessionId: string | undefined;
@@ -118,7 +136,7 @@ export class ClaudeStreamJsonClient {
118
136
  model?: string;
119
137
  reasoningEffort?: string;
120
138
  permissionMode?: AgentPermissionMode;
121
- collaborationMode?: "default" | "plan";
139
+ collaborationMode?: AgentCollaborationMode;
122
140
  cwd: string;
123
141
  }): Promise<unknown> {
124
142
  if (this.child) {
@@ -133,8 +151,11 @@ export class ClaudeStreamJsonClient {
133
151
  "--output-format", "stream-json",
134
152
  "--input-format", "stream-json",
135
153
  "--verbose",
136
- "--permission-mode", "bypassPermissions",
154
+ "--permission-mode", claudePermissionMode(input.permissionMode, input.collaborationMode),
137
155
  ];
156
+ if (input.permissionMode === "read_only") {
157
+ args.push("--disallowedTools", "Bash,Write,Edit,MultiEdit,NotebookEdit");
158
+ }
138
159
 
139
160
  // Use stored session for --resume (only when we have a real session ID from system.init)
140
161
  if (this.claudeSessionId) {
@@ -148,6 +169,10 @@ export class ClaudeStreamJsonClient {
148
169
  if (input.model) {
149
170
  args.push("--model", input.model);
150
171
  }
172
+ const effort = claudeEffort(input.reasoningEffort);
173
+ if (effort) {
174
+ args.push("--effort", effort);
175
+ }
151
176
 
152
177
  // Build the user message
153
178
  const contentBlocks = (input.content as AgentInputContentBlock[]).map(
@@ -468,6 +493,7 @@ export class ClaudeStreamJsonClient {
468
493
  async listModels(): Promise<unknown> {
469
494
  return {
470
495
  defaultModel: "default",
496
+ reasoningEfforts: ["low", "medium", "high", "xhigh"],
471
497
  models: [
472
498
  { id: "sonnet", label: "Sonnet" },
473
499
  { id: "opus", label: "Opus" },
@@ -19,10 +19,38 @@ export interface CodexStoredSession {
19
19
  export interface StoredAgentTimelineItem {
20
20
  id: string;
21
21
  conversationId: string;
22
- type: "message";
23
- role: "user" | "assistant" | "system";
24
- content: Array<{ type: "text"; text: string }>;
25
- text: string;
22
+ type: "message" | "tool_call";
23
+ kind?: "tool_activity" | "command_execution" | "file_change";
24
+ itemId?: string;
25
+ role?: "user" | "assistant" | "system";
26
+ content?: Array<{ type: "text"; text: string }>;
27
+ text?: string;
28
+ toolCall?: {
29
+ id: string;
30
+ name: string;
31
+ input?: string;
32
+ output?: string;
33
+ createdAt?: number;
34
+ status: "pending" | "running" | "completed" | "failed";
35
+ };
36
+ commandExecution?: {
37
+ command?: string;
38
+ cwd?: string;
39
+ output?: string;
40
+ exitCode?: number | null;
41
+ status?: "pending" | "running" | "completed" | "failed";
42
+ };
43
+ fileChange?: {
44
+ entries: Array<{
45
+ path: string;
46
+ kind?: string;
47
+ added?: number;
48
+ removed?: number;
49
+ }>;
50
+ diff?: string;
51
+ summary?: string;
52
+ status?: "pending" | "running" | "completed" | "failed";
53
+ };
26
54
  createdAt: number;
27
55
  updatedAt?: number;
28
56
  metadata?: Record<string, unknown>;
@@ -276,6 +304,40 @@ function normalizeHistoryText(value: unknown): string | undefined {
276
304
  return normalizeHistoryText(record.content ?? record.text ?? record.message);
277
305
  }
278
306
 
307
+ function stringifyHistoryValue(value: unknown): string | undefined {
308
+ if (value === undefined || value === null || value === "") return undefined;
309
+ if (typeof value === "string") return value;
310
+ try {
311
+ return JSON.stringify(value, null, 2);
312
+ } catch {
313
+ return String(value);
314
+ }
315
+ }
316
+
317
+ function visibleCodexRole(value: unknown): "user" | "assistant" | undefined {
318
+ return value === "user" || value === "assistant" ? value : undefined;
319
+ }
320
+
321
+ function isInjectedCodexContext(text: string): boolean {
322
+ const trimmed = text.trimStart();
323
+ return (
324
+ trimmed.startsWith("<permissions instructions>") ||
325
+ trimmed.startsWith("<app-context>") ||
326
+ trimmed.startsWith("<environment_context>") ||
327
+ trimmed.startsWith("<skill>") ||
328
+ trimmed.startsWith("<turn_aborted>") ||
329
+ trimmed.startsWith("<developer") ||
330
+ trimmed.startsWith("# AGENTS.md instructions") ||
331
+ trimmed.startsWith("AGENTS.md instructions for ") ||
332
+ trimmed.includes("\n# AGENTS.md\n") ||
333
+ trimmed.includes("\n<INSTRUCTIONS>")
334
+ );
335
+ }
336
+
337
+ function messageDedupeKey(role: "user" | "assistant" | "system", text: string): string {
338
+ return `${role}:${text.replace(/\s+/g, " ").trim().slice(0, 400)}`;
339
+ }
340
+
279
341
  function historyMessage(
280
342
  conversationId: string,
281
343
  index: number,
@@ -297,6 +359,207 @@ function historyMessage(
297
359
  };
298
360
  }
299
361
 
362
+ function historyToolName(name: string | undefined): string {
363
+ if (!name) return "工具";
364
+ if (name.endsWith("exec_command") || name.endsWith("write_stdin")) return "命令";
365
+ if (name === "apply_patch") return "文件修改";
366
+ return name;
367
+ }
368
+
369
+ function historyToolKind(name: string | undefined): "tool_activity" | "command_execution" {
370
+ return name?.endsWith("exec_command") || name?.endsWith("write_stdin") ? "command_execution" : "tool_activity";
371
+ }
372
+
373
+ function commandFromCodexTool(name: string | undefined, rawInput: unknown, output?: string): StoredAgentTimelineItem["commandExecution"] {
374
+ if (!name?.endsWith("exec_command") && !name?.endsWith("write_stdin")) return undefined;
375
+ let input = asRecord(rawInput);
376
+ if (!input && typeof rawInput === "string" && rawInput.trim().startsWith("{")) {
377
+ try {
378
+ input = asRecord(JSON.parse(rawInput));
379
+ } catch {
380
+ input = undefined;
381
+ }
382
+ }
383
+ const command = typeof input?.cmd === "string"
384
+ ? input.cmd
385
+ : typeof input?.chars === "string"
386
+ ? input.chars
387
+ : undefined;
388
+ const cwd = typeof input?.workdir === "string" ? input.workdir : undefined;
389
+ if (!command && !cwd && !output) return undefined;
390
+ return { command, cwd, output, status: output === undefined ? "running" : "completed" };
391
+ }
392
+
393
+ function upsertHistoryTool(
394
+ itemsById: Map<string, StoredAgentTimelineItem>,
395
+ conversationId: string,
396
+ callId: string,
397
+ name: string | undefined,
398
+ rawInput: unknown,
399
+ input: string | undefined,
400
+ createdAt: number,
401
+ ): void {
402
+ const id = `history-tool:${callId}`;
403
+ const existing = itemsById.get(id);
404
+ const commandExecution = commandFromCodexTool(name, rawInput, existing?.commandExecution?.output);
405
+ itemsById.set(id, {
406
+ id,
407
+ conversationId,
408
+ type: "tool_call",
409
+ kind: historyToolKind(name),
410
+ itemId: callId,
411
+ toolCall: {
412
+ id: callId,
413
+ name: historyToolName(name),
414
+ input: input ?? existing?.toolCall?.input,
415
+ output: existing?.toolCall?.output,
416
+ createdAt: existing?.toolCall?.createdAt ?? createdAt,
417
+ status: existing?.toolCall?.status ?? "running",
418
+ },
419
+ commandExecution: commandExecution ?? existing?.commandExecution,
420
+ createdAt: existing?.createdAt ?? createdAt,
421
+ updatedAt: createdAt,
422
+ metadata: { source: "device-history", provider: "codex" },
423
+ });
424
+ }
425
+
426
+ function completeHistoryTool(
427
+ itemsById: Map<string, StoredAgentTimelineItem>,
428
+ conversationId: string,
429
+ callId: string,
430
+ output: string | undefined,
431
+ createdAt: number,
432
+ ): void {
433
+ const id = `history-tool:${callId}`;
434
+ const existing = itemsById.get(id);
435
+ const commandExecution = existing?.commandExecution
436
+ ? { ...existing.commandExecution, output, status: "completed" as const }
437
+ : undefined;
438
+ itemsById.set(id, {
439
+ id,
440
+ conversationId,
441
+ type: "tool_call",
442
+ kind: existing?.kind ?? "tool_activity",
443
+ itemId: callId,
444
+ toolCall: {
445
+ id: callId,
446
+ name: existing?.toolCall?.name ?? "工具",
447
+ input: existing?.toolCall?.input,
448
+ output,
449
+ createdAt: existing?.toolCall?.createdAt ?? createdAt,
450
+ status: "completed",
451
+ },
452
+ commandExecution,
453
+ createdAt: existing?.createdAt ?? createdAt,
454
+ updatedAt: createdAt,
455
+ metadata: { source: "device-history", provider: "codex" },
456
+ });
457
+ }
458
+
459
+ function relativeHistoryPath(path: string, cwd: string): string {
460
+ const normalized = path.replace(/\\/g, "/").replace(/^["']|["']$/g, "");
461
+ const normalizedCwd = cwd.replace(/\\/g, "/").replace(/\/+$/, "");
462
+ return normalized.startsWith(`${normalizedCwd}/`)
463
+ ? normalized.slice(normalizedCwd.length + 1)
464
+ : normalized;
465
+ }
466
+
467
+ function countDiffLines(diff: string | undefined): { added: number; removed: number } {
468
+ if (!diff) return { added: 0, removed: 0 };
469
+ let added = 0;
470
+ let removed = 0;
471
+ for (const line of diff.split("\n")) {
472
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
473
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
474
+ }
475
+ return { added, removed };
476
+ }
477
+
478
+ function countContentLines(content: unknown): number {
479
+ if (typeof content !== "string") return 0;
480
+ if (!content) return 0;
481
+ return content.split(/\r?\n/).filter((line) => line.length > 0).length;
482
+ }
483
+
484
+ function changeKind(value: string | undefined): string | undefined {
485
+ if (value === "add") return "create";
486
+ if (value === "delete") return "delete";
487
+ if (value === "update" || value === "move") return "update";
488
+ return value;
489
+ }
490
+
491
+ function patchApplyFileChange(
492
+ conversationId: string,
493
+ payload: Record<string, unknown>,
494
+ cwd: string,
495
+ createdAt: number,
496
+ ): StoredAgentTimelineItem | undefined {
497
+ const changes = asRecord(payload.changes);
498
+ if (!changes) return undefined;
499
+ const entries: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"] = [];
500
+ const diffParts: string[] = [];
501
+ for (const [absolutePath, rawChange] of Object.entries(changes)) {
502
+ const change = asRecord(rawChange);
503
+ if (!change) continue;
504
+ const path = relativeHistoryPath(absolutePath, cwd);
505
+ const kind = changeKind(typeof change.type === "string" ? change.type : undefined);
506
+ const diff = typeof change.unified_diff === "string" ? change.unified_diff : undefined;
507
+ const stats = countDiffLines(diff);
508
+ const added = stats.added || (kind === "create" ? countContentLines(change.content) : 0);
509
+ const removed = stats.removed;
510
+ const entry: NonNullable<StoredAgentTimelineItem["fileChange"]>["entries"][number] = { path };
511
+ if (kind) entry.kind = kind;
512
+ if (added > 0) entry.added = added;
513
+ if (removed > 0) entry.removed = removed;
514
+ entries.push(entry);
515
+ if (diff) {
516
+ diffParts.push([
517
+ `Path: ${path}`,
518
+ kind ? `Kind: ${kind}` : undefined,
519
+ `Totals: +${added} -${removed}`,
520
+ "",
521
+ "```diff",
522
+ diff.trimEnd(),
523
+ "```",
524
+ ].filter((line): line is string => line !== undefined).join("\n"));
525
+ }
526
+ }
527
+ if (entries.length === 0) return undefined;
528
+ const callId = typeof payload.call_id === "string" ? payload.call_id : `patch-${createdAt}`;
529
+ const status = payload.success === false ? "failed" : "completed";
530
+ const totalAdded = entries.reduce((sum, entry) => sum + (entry.added ?? 0), 0);
531
+ const totalRemoved = entries.reduce((sum, entry) => sum + (entry.removed ?? 0), 0);
532
+ const summary = entries
533
+ .map((entry) => [entry.kind, entry.path].filter(Boolean).join(" "))
534
+ .join("\n");
535
+ const diff = diffParts.length > 0 ? diffParts.join("\n\n---\n\n") : undefined;
536
+ return {
537
+ id: `history-file-change:${callId}`,
538
+ conversationId,
539
+ type: "tool_call",
540
+ kind: "file_change",
541
+ itemId: callId,
542
+ toolCall: {
543
+ id: callId,
544
+ name: "文件修改",
545
+ input: summary,
546
+ output: diff ?? (typeof payload.stdout === "string" ? payload.stdout : undefined),
547
+ createdAt,
548
+ status,
549
+ },
550
+ fileChange: {
551
+ entries,
552
+ diff,
553
+ summary,
554
+ status,
555
+ },
556
+ text: `已编辑 ${entries.length} 个文件 +${totalAdded} -${totalRemoved}`,
557
+ createdAt,
558
+ updatedAt: createdAt,
559
+ metadata: { source: "device-history", provider: "codex" },
560
+ };
561
+ }
562
+
300
563
  export function listCodexStoredSessions(inputCwd: string): { sessions: CodexStoredSession[] } {
301
564
  const root = join(homedir(), ".codex");
302
565
  if (!existsSync(root)) return { sessions: [] };
@@ -360,7 +623,8 @@ export function loadCodexStoredTimeline(
360
623
  return { items: [] };
361
624
  }
362
625
 
363
- const items: StoredAgentTimelineItem[] = [];
626
+ const itemsById = new Map<string, StoredAgentTimelineItem>();
627
+ const seenMessages = new Set<string>();
364
628
  let index = 0;
365
629
  for (const line of readHistorySample(file.path, statSize).split(/\r?\n/)) {
366
630
  const trimmed = line.trim();
@@ -368,21 +632,76 @@ export function loadCodexStoredTimeline(
368
632
  try {
369
633
  const entry = asRecord(JSON.parse(trimmed));
370
634
  const payload = asRecord(entry?.payload);
371
- if (entry?.type !== "event_msg" || !payload) continue;
372
- const eventType = typeof payload.type === "string" ? payload.type : undefined;
373
635
  const createdAt =
374
- parseTimestamp(entry.timestamp ?? payload.created_at ?? payload.started_at ?? payload.completed_at) ??
636
+ parseTimestamp(entry?.timestamp ?? payload?.created_at ?? payload?.started_at ?? payload?.completed_at) ??
375
637
  statMtime + index;
376
- if (eventType === "user_message") {
377
- const text = normalizeHistoryText(payload.message);
378
- if (!text || text.startsWith("<turn_aborted>")) continue;
379
- items.push(historyMessage(conversationId, index, "user", text, createdAt, sessionId));
380
- index += 1;
381
- } else if (eventType === "agent_message") {
638
+ if (entry?.type === "event_msg" && payload) {
639
+ const eventType = typeof payload.type === "string" ? payload.type : undefined;
640
+ if (eventType === "user_message") {
641
+ const text = normalizeHistoryText(payload.message);
642
+ if (!text || isInjectedCodexContext(text)) continue;
643
+ const dedupeKey = messageDedupeKey("user", text);
644
+ if (seenMessages.has(dedupeKey)) continue;
645
+ seenMessages.add(dedupeKey);
646
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, "user", text, createdAt, sessionId));
647
+ index += 1;
648
+ } else if (eventType === "agent_message") {
649
+ const text = normalizeHistoryText(payload.message);
650
+ if (!text || isInjectedCodexContext(text)) continue;
651
+ const dedupeKey = messageDedupeKey("assistant", text);
652
+ if (seenMessages.has(dedupeKey)) continue;
653
+ seenMessages.add(dedupeKey);
654
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
655
+ index += 1;
656
+ } else if (eventType === "patch_apply_end") {
657
+ const item = patchApplyFileChange(conversationId, payload, inputCwd, createdAt);
658
+ if (item) itemsById.set(item.id, item);
659
+ }
660
+ continue;
661
+ }
662
+
663
+ if (entry?.type !== "response_item" || !payload) continue;
664
+ const responseType = typeof payload.type === "string" ? payload.type : undefined;
665
+ if (responseType === "message") {
666
+ const role = visibleCodexRole(payload.role);
667
+ if (!role) continue;
382
668
  const text = normalizeHistoryText(payload.message);
383
- if (!text) continue;
384
- items.push(historyMessage(conversationId, index, "assistant", text, createdAt, sessionId));
669
+ const contentText = text ?? normalizeHistoryText(payload.content);
670
+ if (!contentText || isInjectedCodexContext(contentText)) continue;
671
+ const dedupeKey = messageDedupeKey(role, contentText);
672
+ if (seenMessages.has(dedupeKey)) continue;
673
+ seenMessages.add(dedupeKey);
674
+ itemsById.set(`history:${sessionId}:${index}`, historyMessage(conversationId, index, role, contentText, createdAt, sessionId));
385
675
  index += 1;
676
+ continue;
677
+ }
678
+ if (responseType === "function_call") {
679
+ const callId = typeof payload.call_id === "string"
680
+ ? payload.call_id
681
+ : typeof payload.id === "string"
682
+ ? payload.id
683
+ : undefined;
684
+ if (!callId) continue;
685
+ const name = typeof payload.name === "string" ? payload.name : undefined;
686
+ upsertHistoryTool(
687
+ itemsById,
688
+ conversationId,
689
+ callId,
690
+ name,
691
+ payload.arguments,
692
+ stringifyHistoryValue(payload.arguments),
693
+ createdAt,
694
+ );
695
+ continue;
696
+ }
697
+ if (responseType === "function_call_output") {
698
+ const callId = typeof payload.call_id === "string"
699
+ ? payload.call_id
700
+ : typeof payload.id === "string"
701
+ ? payload.id
702
+ : undefined;
703
+ if (!callId) continue;
704
+ completeHistoryTool(itemsById, conversationId, callId, stringifyHistoryValue(payload.output), createdAt);
386
705
  }
387
706
  } catch {
388
707
  // Ignore malformed or partial JSONL lines in the history window.
@@ -390,7 +709,7 @@ export function loadCodexStoredTimeline(
390
709
  }
391
710
 
392
711
  return {
393
- items: items
712
+ items: [...itemsById.values()]
394
713
  .sort((a, b) => a.createdAt - b.createdAt)
395
714
  .slice(-MAX_HISTORY_ITEMS),
396
715
  };