linkshell-cli 0.3.11 → 0.3.13

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.
@@ -46,6 +46,7 @@ export declare class AgentWorkspaceProxy {
46
46
  private commandForConversation;
47
47
  private executeCommand;
48
48
  private executeNativeCommand;
49
+ private executeGitNativeCommand;
49
50
  private handleRequest;
50
51
  private handleNotification;
51
52
  private handleAgentMessageDelta;
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { spawn } from "node:child_process";
2
3
  import { homedir } from "node:os";
3
4
  import { basename, join, relative } from "node:path";
4
5
  import { createEnvelope, parseTypedPayload, } from "@linkshell/protocol";
@@ -10,6 +11,8 @@ import { listCodexStoredSessions, loadCodexStoredTimeline } from "./codex-sessio
10
11
  import { resolveAgentCommand } from "./provider-resolver.js";
11
12
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
12
13
  const MAX_TIMELINE_ITEMS = 200;
14
+ const MAX_SNAPSHOT_ITEMS = 80;
15
+ const MAX_SNAPSHOT_TEXT_BYTES = 128 * 1024;
13
16
  function id(prefix) {
14
17
  return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
15
18
  }
@@ -23,6 +26,62 @@ function stringify(value) {
23
26
  return String(value);
24
27
  }
25
28
  }
29
+ function truncateUtf8(value, maxBytes = MAX_SNAPSHOT_TEXT_BYTES) {
30
+ if (!value)
31
+ return value;
32
+ if (Buffer.byteLength(value, "utf8") <= maxBytes)
33
+ return value;
34
+ let end = Math.min(value.length, maxBytes);
35
+ while (end > 0 && Buffer.byteLength(value.slice(0, end), "utf8") > maxBytes) {
36
+ end = Math.floor(end * 0.9);
37
+ }
38
+ return `${value.slice(0, end)}\n\n[truncated by LinkShell: original ${Buffer.byteLength(value, "utf8")} bytes]`;
39
+ }
40
+ function snapshotContentBlocks(blocks, options = {}) {
41
+ if (!blocks)
42
+ return undefined;
43
+ return blocks.map((block) => block.type === "image" && options.stripImages !== false
44
+ ? { ...block, data: undefined, text: block.text || "图片附件" }
45
+ : { ...block, text: truncateUtf8(block.text) });
46
+ }
47
+ function snapshotTimelineItem(item, options = {}) {
48
+ return {
49
+ ...item,
50
+ content: snapshotContentBlocks(item.content, options),
51
+ text: truncateUtf8(item.text),
52
+ toolCall: item.toolCall
53
+ ? {
54
+ ...item.toolCall,
55
+ input: truncateUtf8(item.toolCall.input),
56
+ output: truncateUtf8(item.toolCall.output),
57
+ }
58
+ : undefined,
59
+ commandExecution: item.commandExecution
60
+ ? {
61
+ ...item.commandExecution,
62
+ command: truncateUtf8(item.commandExecution.command, 16 * 1024),
63
+ output: truncateUtf8(item.commandExecution.output),
64
+ }
65
+ : undefined,
66
+ fileChange: item.fileChange
67
+ ? {
68
+ ...item.fileChange,
69
+ diff: truncateUtf8(item.fileChange.diff),
70
+ summary: truncateUtf8(item.fileChange.summary),
71
+ }
72
+ : undefined,
73
+ permission: item.permission
74
+ ? {
75
+ ...item.permission,
76
+ toolInput: truncateUtf8(item.permission.toolInput),
77
+ context: truncateUtf8(item.permission.context),
78
+ }
79
+ : undefined,
80
+ };
81
+ }
82
+ function snapshotTimelineItems(items) {
83
+ return items.slice(-MAX_SNAPSHOT_ITEMS).map((item) => snapshotTimelineItem(item));
84
+ }
26
85
  function asRecord(value) {
27
86
  return typeof value === "object" && value ? value : undefined;
28
87
  }
@@ -558,6 +617,23 @@ function providerLabel(provider) {
558
617
  const ALL_REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
559
618
  const CLAUDE_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
560
619
  const AGENT_PERMISSION_MODES = ["read_only", "workspace_write", "full_access"];
620
+ const COMMAND_OUTPUT_MAX_BYTES = 96 * 1024;
621
+ const LINKSHELL_NATIVE_COMMANDS = [
622
+ { name: "status", description: "Show current Agent and workspace status", category: "LinkShell", argsMode: "none" },
623
+ { name: "plan", description: "Enter Plan mode for the next turn", category: "Agent", argsMode: "none" },
624
+ { name: "exit-plan", description: "Exit Plan mode", category: "Agent", argsMode: "none" },
625
+ { name: "review", description: "Ask the Agent to review current local changes", category: "Agent", argsMode: "optional" },
626
+ { name: "subagents", description: "Ask the Agent to split work across subagents when useful", category: "Agent", argsMode: "optional" },
627
+ { name: "compact", description: "Compact the active Codex context", category: "Codex", argsMode: "none", providers: ["codex"] },
628
+ { name: "clear", description: "Start a fresh Agent context for this conversation", category: "Agent", argsMode: "none", destructive: true },
629
+ { name: "git-status", description: "Show branch and working tree status", category: "Git", argsMode: "none" },
630
+ { name: "git-diff", description: "Show a compact diffstat for current changes", category: "Git", argsMode: "none" },
631
+ { name: "git-commit", description: "Commit staged changes with the given message", category: "Git", argsMode: "required" },
632
+ { name: "git-pull", description: "Pull with fast-forward only", category: "Git", argsMode: "none" },
633
+ { name: "git-push", description: "Push the current branch", category: "Git", argsMode: "none" },
634
+ { name: "git-stash", description: "Stash current working tree changes", category: "Git", argsMode: "optional" },
635
+ { name: "git-stash-pop", description: "Pop the latest stash", category: "Git", argsMode: "none" },
636
+ ];
561
637
  const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
562
638
  "add-dir",
563
639
  "agents",
@@ -819,17 +895,30 @@ function customClaudeCommands(cwd) {
819
895
  }
820
896
  function defaultProviderCommands(provider, cwd, enabled) {
821
897
  const disabledReason = enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`;
898
+ const linkshellCommands = LINKSHELL_NATIVE_COMMANDS
899
+ .filter((command) => !command.providers || command.providers.includes(provider))
900
+ .map((command) => makeCommand({
901
+ provider,
902
+ name: command.name,
903
+ description: command.description,
904
+ source: "linkshell",
905
+ category: command.category,
906
+ argsMode: command.argsMode ?? "optional",
907
+ destructive: command.destructive,
908
+ disabledReason,
909
+ executionKind: "native",
910
+ }));
822
911
  if (provider === "codex") {
823
- return [];
912
+ return linkshellCommands;
824
913
  }
825
914
  if (provider === "claude") {
826
915
  const custom = customClaudeCommands(cwd).map((command) => ({
827
916
  ...command,
828
917
  disabledReason: command.disabledReason ?? disabledReason,
829
918
  }));
830
- return custom;
919
+ return [...linkshellCommands, ...custom];
831
920
  }
832
- return [];
921
+ return linkshellCommands;
833
922
  }
834
923
  function mergeCommands(...groups) {
835
924
  const map = new Map();
@@ -846,6 +935,76 @@ function mergeCommands(...groups) {
846
935
  }
847
936
  return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
848
937
  }
938
+ function isGitNativeCommand(name) {
939
+ return name === "git-status" ||
940
+ name === "git-diff" ||
941
+ name === "git-commit" ||
942
+ name === "git-pull" ||
943
+ name === "git-push" ||
944
+ name === "git-stash" ||
945
+ name === "git-stash-pop";
946
+ }
947
+ function gitCommandArgs(name, args) {
948
+ const message = args?.trim();
949
+ switch (name) {
950
+ case "git-status":
951
+ return { display: "git status --short --branch", argv: ["status", "--short", "--branch"] };
952
+ case "git-diff":
953
+ return { display: "git diff --stat", argv: ["diff", "--stat"] };
954
+ case "git-commit":
955
+ if (!message)
956
+ throw new Error("请先输入提交信息,例如 /git-commit fix mobile agent timeline");
957
+ return { display: `git commit -m ${JSON.stringify(message)}`, argv: ["commit", "-m", message] };
958
+ case "git-pull":
959
+ return { display: "git pull --ff-only", argv: ["pull", "--ff-only"] };
960
+ case "git-push":
961
+ return { display: "git push", argv: ["push"] };
962
+ case "git-stash":
963
+ return {
964
+ display: "git stash push -u",
965
+ argv: ["stash", "push", "-u", "-m", message || "LinkShell mobile stash"],
966
+ };
967
+ case "git-stash-pop":
968
+ return { display: "git stash pop", argv: ["stash", "pop"] };
969
+ default:
970
+ throw new Error(`未知 Git 命令:/${name}`);
971
+ }
972
+ }
973
+ function runProcess(command, args, options) {
974
+ return new Promise((resolve, reject) => {
975
+ const child = spawn(command, args, {
976
+ cwd: options.cwd,
977
+ stdio: ["ignore", "pipe", "pipe"],
978
+ windowsHide: true,
979
+ });
980
+ const maxBytes = options.maxBytes ?? COMMAND_OUTPUT_MAX_BYTES;
981
+ let output = "";
982
+ let bytes = 0;
983
+ let truncated = false;
984
+ const append = (chunk) => {
985
+ if (bytes >= maxBytes) {
986
+ truncated = true;
987
+ return;
988
+ }
989
+ const remaining = maxBytes - bytes;
990
+ const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
991
+ output += slice.toString("utf8");
992
+ bytes += slice.byteLength;
993
+ if (slice.byteLength < chunk.byteLength)
994
+ truncated = true;
995
+ };
996
+ child.stdout.on("data", append);
997
+ child.stderr.on("data", append);
998
+ child.once("error", reject);
999
+ child.once("close", (exitCode, signal) => {
1000
+ resolve({
1001
+ output: `${output.trimEnd()}${truncated ? "\n\n[truncated by LinkShell]" : ""}`,
1002
+ exitCode,
1003
+ signal,
1004
+ });
1005
+ });
1006
+ });
1007
+ }
849
1008
  function runtimeCommands(provider, value) {
850
1009
  const raw = asRecord(value);
851
1010
  const commandsValue = Array.isArray(value) ? value :
@@ -1308,7 +1467,7 @@ export class AgentWorkspaceProxy {
1308
1467
  hostDeviceId: this.input.hostDeviceId,
1309
1468
  payload: {
1310
1469
  conversation: existingConversation,
1311
- snapshot: this.timelines.get(existingConversation.id) ?? [],
1470
+ snapshot: snapshotTimelineItems(this.timelines.get(existingConversation.id) ?? []),
1312
1471
  },
1313
1472
  }));
1314
1473
  return existingConversation;
@@ -1355,7 +1514,7 @@ export class AgentWorkspaceProxy {
1355
1514
  this.input.send(createEnvelope({
1356
1515
  type: "agent.v2.conversation.opened",
1357
1516
  hostDeviceId: this.input.hostDeviceId,
1358
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
1517
+ payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1359
1518
  }));
1360
1519
  return conversation;
1361
1520
  }
@@ -1394,7 +1553,7 @@ export class AgentWorkspaceProxy {
1394
1553
  this.input.send(createEnvelope({
1395
1554
  type: "agent.v2.conversation.opened",
1396
1555
  hostDeviceId: this.input.hostDeviceId,
1397
- payload: { conversation, snapshot: this.timelines.get(conversation.id) ?? [] },
1556
+ payload: { conversation, snapshot: snapshotTimelineItems(this.timelines.get(conversation.id) ?? []) },
1398
1557
  }));
1399
1558
  return conversation;
1400
1559
  }
@@ -1582,14 +1741,8 @@ export class AgentWorkspaceProxy {
1582
1741
  this.emitStatus(conversation.id, conversation.status, `${providerLabel(conversation.provider)} · ${conversation.collaborationMode === "plan" ? "Plan mode" : "Default mode"} · ${conversation.cwd}`);
1583
1742
  return;
1584
1743
  }
1585
- if (conversation.provider !== "codex") {
1586
- this.addItem(conversation.id, {
1587
- id: id("error"),
1588
- conversationId: conversation.id,
1589
- type: "error",
1590
- error: `${command.title} 暂无 ${providerLabel(conversation.provider)} 原生实现。`,
1591
- createdAt: now,
1592
- });
1744
+ if (isGitNativeCommand(command.name)) {
1745
+ await this.executeGitNativeCommand(conversation, command.name, args);
1593
1746
  return;
1594
1747
  }
1595
1748
  if (command.name === "plan" || command.name === "exit-plan") {
@@ -1604,6 +1757,21 @@ export class AgentWorkspaceProxy {
1604
1757
  : "已退出 Plan mode。");
1605
1758
  return;
1606
1759
  }
1760
+ if (command.name === "review" || command.name === "subagents") {
1761
+ const prompt = command.name === "review"
1762
+ ? args || "Review the current local changes."
1763
+ : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
1764
+ await this.sendPrompt({
1765
+ conversationId: conversation.id,
1766
+ clientMessageId: id(command.name),
1767
+ contentBlocks: [{ type: "text", text: prompt }],
1768
+ model: conversation.model,
1769
+ reasoningEffort: conversation.reasoningEffort,
1770
+ permissionMode: conversation.permissionMode,
1771
+ collaborationMode: conversation.collaborationMode,
1772
+ });
1773
+ return;
1774
+ }
1607
1775
  if (command.name === "compact") {
1608
1776
  if (!(client instanceof AcpClient))
1609
1777
  throw new Error("当前 Codex runtime 不支持原生 compact。");
@@ -1642,21 +1810,6 @@ export class AgentWorkspaceProxy {
1642
1810
  this.emitStatus(conversation.id, "idle", "上下文已重置,已创建新的 Codex thread。");
1643
1811
  return;
1644
1812
  }
1645
- if (command.name === "review" || command.name === "subagents") {
1646
- const prompt = command.name === "review"
1647
- ? args || "Review the current local changes."
1648
- : args || "Run subagents for distinct tasks in parallel when useful, then synthesize the results.";
1649
- await this.sendPrompt({
1650
- conversationId: conversation.id,
1651
- clientMessageId: id(command.name),
1652
- contentBlocks: [{ type: "text", text: prompt }],
1653
- model: conversation.model,
1654
- reasoningEffort: conversation.reasoningEffort,
1655
- permissionMode: conversation.permissionMode,
1656
- collaborationMode: conversation.collaborationMode,
1657
- });
1658
- return;
1659
- }
1660
1813
  throw new Error(`命令暂未实现:/${command.name}`);
1661
1814
  }
1662
1815
  catch (error) {
@@ -1671,6 +1824,41 @@ export class AgentWorkspaceProxy {
1671
1824
  });
1672
1825
  }
1673
1826
  }
1827
+ async executeGitNativeCommand(conversation, commandName, args) {
1828
+ const git = gitCommandArgs(commandName, args);
1829
+ const toolId = id(commandName);
1830
+ const now = Date.now();
1831
+ conversation.status = "running";
1832
+ conversation.lastMessagePreview = git.display;
1833
+ conversation.lastActivityAt = now;
1834
+ this.emitConversation(conversation);
1835
+ this.upsertTool(conversation.id, {
1836
+ id: toolId,
1837
+ name: "命令",
1838
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
1839
+ createdAt: now,
1840
+ status: "running",
1841
+ });
1842
+ const result = await runProcess("git", git.argv, {
1843
+ cwd: conversation.cwd,
1844
+ maxBytes: COMMAND_OUTPUT_MAX_BYTES,
1845
+ });
1846
+ const ok = result.exitCode === 0;
1847
+ this.upsertTool(conversation.id, {
1848
+ id: toolId,
1849
+ name: "命令",
1850
+ input: `${git.display}\n\ncwd: ${conversation.cwd}`,
1851
+ output: result.output || (ok ? "完成" : `退出码 ${result.exitCode ?? "unknown"}`),
1852
+ createdAt: now,
1853
+ status: ok ? "completed" : "failed",
1854
+ });
1855
+ conversation.status = ok ? "idle" : "error";
1856
+ conversation.lastMessagePreview = ok
1857
+ ? `${git.display} 完成`
1858
+ : `${git.display} 失败`;
1859
+ conversation.lastActivityAt = Date.now();
1860
+ this.emitConversation(conversation);
1861
+ }
1674
1862
  handleRequest(method, params) {
1675
1863
  if (method === "item/tool/requestUserInput" || method === "tool/requestUserInput") {
1676
1864
  return this.handleStructuredInput(params, true);
@@ -2550,7 +2738,7 @@ export class AgentWorkspaceProxy {
2550
2738
  this.input.send(createEnvelope({
2551
2739
  type: "agent.v2.event",
2552
2740
  hostDeviceId: this.input.hostDeviceId,
2553
- payload: { conversationId, conversation, item },
2741
+ payload: { conversationId, conversation, item: snapshotTimelineItem(item, { stripImages: false }) },
2554
2742
  }));
2555
2743
  }
2556
2744
  emitConversation(conversation) {
@@ -2603,8 +2791,8 @@ export class AgentWorkspaceProxy {
2603
2791
  }
2604
2792
  const conversations = [...this.conversations.values()];
2605
2793
  const items = conversationId
2606
- ? this.timelines.get(conversationId) ?? []
2607
- : [...this.timelines.values()].flat();
2794
+ ? snapshotTimelineItems(this.timelines.get(conversationId) ?? [])
2795
+ : [];
2608
2796
  this.input.send(createEnvelope({
2609
2797
  type: "agent.v2.snapshot",
2610
2798
  hostDeviceId: this.input.hostDeviceId,