linkshell-cli 0.5.30 → 0.5.31

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.
@@ -37,12 +37,20 @@ export declare class AgentWorkspaceProxy {
37
37
  handleEnvelope(envelope: Envelope): Promise<void>;
38
38
  stop(): void;
39
39
  private fsWatchers;
40
+ private watchedRoots;
40
41
  private fsSyncTimer;
41
42
  private fsSyncRunning;
43
+ private lastPushedConversationSignature;
42
44
  private static readonly FS_SYNC_THROTTLE_MS;
43
45
  private startSessionWatchers;
44
46
  private scheduleSessionSync;
45
47
  private syncAndPushSessions;
48
+ /** A compact fingerprint of the fields the session tree actually renders, so
49
+ * syncAndPushSessions can skip a broadcast when nothing visible changed.
50
+ * Deliberately excludes usage token counts / lastActivityAt timestamps that
51
+ * tick on every append without altering the tree — status, title, model, and
52
+ * membership are what matter. */
53
+ private conversationListSignature;
46
54
  private clientForProvider;
47
55
  private shouldRecycleProviderClient;
48
56
  private recycleProviderClient;
@@ -62,6 +70,8 @@ export declare class AgentWorkspaceProxy {
62
70
  private executeCommand;
63
71
  private executeNativeCommand;
64
72
  private handleRequest;
73
+ private mergeMcpServers;
74
+ private handleMcpStartupStatus;
65
75
  private handleNotification;
66
76
  private handleAgentMessageDelta;
67
77
  private handlePlanUpdated;
@@ -7,6 +7,63 @@ import { ClaudeSdkClient } from "./claude-sdk-client.js";
7
7
  import { ClaudeStreamJsonClient } from "./claude-stream-json-client.js";
8
8
  import { aggregateUsage } from "../usage-report.js";
9
9
  import { resolveAgentCommand } from "./provider-resolver.js";
10
+ // Normalize the many status spellings Claude/Codex emit into our enum. Unknown
11
+ // values fall back to "pending" (known-but-not-yet-up) rather than dropping the
12
+ // server. Kept liberal because the real event vocabulary isn't fully pinned.
13
+ function normalizeMcpStatus(raw) {
14
+ const s = typeof raw === "string" ? raw.trim().toLowerCase().replace(/[\s-]+/g, "_") : "";
15
+ if (s === "connected" || s === "ok" || s === "ready" || s === "running" || s === "success")
16
+ return "connected";
17
+ if (s === "failed" || s === "error" || s === "errored" || s === "disconnected")
18
+ return "failed";
19
+ if (s === "connecting" || s === "starting" || s === "pending_connection")
20
+ return "connecting";
21
+ if (s === "needs_auth" || s === "unauthorized" || s === "needs_authentication" || s === "auth_required" || s === "pending_auth")
22
+ return "needs_auth";
23
+ if (s === "disabled" || s === "off")
24
+ return "disabled";
25
+ return "pending";
26
+ }
27
+ // Parse an MCP server list from either an array of {name,status,...} objects or
28
+ // a {name: status} record — Claude's init `mcp_servers` shape isn't fixed.
29
+ function parseMcpServerList(value) {
30
+ const out = [];
31
+ if (Array.isArray(value)) {
32
+ for (const entry of value) {
33
+ const rec = asRecord(entry);
34
+ if (rec) {
35
+ const name = firstString(rec, ["name", "server", "serverName", "id"]);
36
+ if (!name)
37
+ continue;
38
+ out.push({
39
+ name,
40
+ status: normalizeMcpStatus(rec.status ?? rec.state),
41
+ error: firstString(rec, ["error", "message", "reason"]),
42
+ toolCount: firstNumber(rec, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(rec.tools) ? rec.tools.length : undefined),
43
+ });
44
+ }
45
+ else if (typeof entry === "string" && entry) {
46
+ out.push({ name: entry, status: "pending" });
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ const rec = asRecord(value);
52
+ if (rec) {
53
+ for (const [name, raw] of Object.entries(rec)) {
54
+ if (!name)
55
+ continue;
56
+ const inner = asRecord(raw);
57
+ out.push({
58
+ name,
59
+ status: normalizeMcpStatus(inner ? (inner.status ?? inner.state) : raw),
60
+ error: inner ? firstString(inner, ["error", "message", "reason"]) : undefined,
61
+ toolCount: inner ? (firstNumber(inner, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(inner.tools) ? inner.tools.length : undefined)) : undefined,
62
+ });
63
+ }
64
+ }
65
+ return out;
66
+ }
10
67
  const PERMISSION_TIMEOUT_MS = 5 * 60_000;
11
68
  const MAX_TIMELINE_ITEMS = 200;
12
69
  function id(prefix) {
@@ -682,6 +739,7 @@ const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
682
739
  "fork",
683
740
  "hooks",
684
741
  "ide",
742
+ "help",
685
743
  "init",
686
744
  "install-github-app",
687
745
  "install-slack-app",
@@ -711,6 +769,7 @@ const CLAUDE_REMOTE_HIDDEN_COMMANDS = new Set([
711
769
  "setup-bedrock",
712
770
  "setup-vertex",
713
771
  "statusline",
772
+ "status",
714
773
  "stickers",
715
774
  "tasks",
716
775
  "teleport",
@@ -1093,13 +1152,16 @@ function parseModelListCapabilities(provider, value) {
1093
1152
  ? ALL_REASONING_EFFORTS.filter((effort) => efforts.has(effort))
1094
1153
  : undefined;
1095
1154
  const commands = raw && Array.isArray(raw.commands) ? runtimeCommands(provider, raw.commands) : undefined;
1096
- if (models.length === 0 && !defaultModel && !reasoningEfforts?.length && !commands?.length)
1155
+ const mcpList = raw ? parseMcpServerList(raw.mcpServers ?? raw.mcp_servers) : [];
1156
+ const mcpServers = mcpList.length > 0 ? new Map(mcpList.map((s) => [s.name, s])) : undefined;
1157
+ if (models.length === 0 && !defaultModel && !reasoningEfforts?.length && !commands?.length && !mcpServers)
1097
1158
  return undefined;
1098
1159
  return {
1099
1160
  ...(models.length > 0 ? { models } : {}),
1100
1161
  ...(defaultModel ? { defaultModel } : {}),
1101
1162
  ...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
1102
1163
  ...(commands?.length ? { commands } : {}),
1164
+ ...(mcpServers ? { mcpServers } : {}),
1103
1165
  };
1104
1166
  }
1105
1167
  function parseTimestamp(value) {
@@ -1759,6 +1821,7 @@ export class AgentWorkspaceProxy {
1759
1821
  catch { /* already closed */ }
1760
1822
  }
1761
1823
  this.fsWatchers = [];
1824
+ this.watchedRoots.clear();
1762
1825
  if (this.fsSyncTimer) {
1763
1826
  clearTimeout(this.fsSyncTimer);
1764
1827
  this.fsSyncTimer = undefined;
@@ -1777,17 +1840,24 @@ export class AgentWorkspaceProxy {
1777
1840
  // zero cost while idle, throttled to at most one re-scan per window during a
1778
1841
  // burst of writes (an active turn appends constantly).
1779
1842
  fsWatchers = [];
1843
+ watchedRoots = new Set();
1780
1844
  fsSyncTimer;
1781
1845
  fsSyncRunning = false;
1846
+ lastPushedConversationSignature;
1782
1847
  static FS_SYNC_THROTTLE_MS = 1500;
1783
1848
  startSessionWatchers() {
1784
- if (this.fsWatchers.length > 0)
1785
- return; // already watching
1786
1849
  const roots = [
1787
1850
  join(homedir(), ".claude", "projects"),
1788
1851
  join(homedir(), ".codex", "sessions"),
1789
1852
  ];
1790
1853
  for (const root of roots) {
1854
+ // Retry per-root each time we're called, so a store directory created
1855
+ // AFTER startup (e.g. the user runs `codex` for the first time this
1856
+ // session) gets a watcher attached lazily — instead of being gated out
1857
+ // forever by a single "already watching" flag. Roots that don't exist yet
1858
+ // stay un-added so a later call retries them.
1859
+ if (this.watchedRoots.has(root))
1860
+ continue;
1791
1861
  if (!existsSync(root))
1792
1862
  continue;
1793
1863
  try {
@@ -1796,6 +1866,7 @@ export class AgentWorkspaceProxy {
1796
1866
  const watcher = watch(root, { recursive: true, persistent: false }, () => this.scheduleSessionSync());
1797
1867
  watcher.on("error", () => { });
1798
1868
  this.fsWatchers.push(watcher);
1869
+ this.watchedRoots.add(root);
1799
1870
  }
1800
1871
  catch {
1801
1872
  // recursive watch unavailable on this platform — skip silently.
@@ -1816,13 +1887,27 @@ export class AgentWorkspaceProxy {
1816
1887
  this.fsSyncRunning = true;
1817
1888
  try {
1818
1889
  await this.syncProviderSessions();
1890
+ // A store directory may have appeared since the last call — attach a
1891
+ // watcher to it now (no-op for roots already watched).
1892
+ this.startSessionWatchers();
1893
+ const conversations = [...this.conversations.values()].filter((c) => !c.archived);
1894
+ // Only broadcast when the list actually changed since the last push. An
1895
+ // fs-watch fires on every transcript append (once per streamed token for
1896
+ // an active session), but most of those don't change any field the tree
1897
+ // renders — re-sending the whole list each time is wasted relay traffic
1898
+ // and client re-renders. A cheap signature over the render-relevant fields
1899
+ // gates the push.
1900
+ const signature = this.conversationListSignature(conversations);
1901
+ if (signature === this.lastPushedConversationSignature)
1902
+ return;
1903
+ this.lastPushedConversationSignature = signature;
1819
1904
  // Push the conversation list (statuses) — lighter than a full snapshot and
1820
1905
  // exactly what refreshes the tree. The web handles an unsolicited
1821
1906
  // conversation.list.result the same as a requested one.
1822
1907
  this.input.send(createEnvelope({
1823
1908
  type: "agent.v2.conversation.list.result",
1824
1909
  sessionId: this.input.sessionId,
1825
- payload: { conversations: [...this.conversations.values()].filter((c) => !c.archived) },
1910
+ payload: { conversations },
1826
1911
  }));
1827
1912
  }
1828
1913
  catch (error) {
@@ -1834,6 +1919,17 @@ export class AgentWorkspaceProxy {
1834
1919
  this.fsSyncRunning = false;
1835
1920
  }
1836
1921
  }
1922
+ /** A compact fingerprint of the fields the session tree actually renders, so
1923
+ * syncAndPushSessions can skip a broadcast when nothing visible changed.
1924
+ * Deliberately excludes usage token counts / lastActivityAt timestamps that
1925
+ * tick on every append without altering the tree — status, title, model, and
1926
+ * membership are what matter. */
1927
+ conversationListSignature(conversations) {
1928
+ return conversations
1929
+ .map((c) => `${c.id}${c.status}${c.title ?? ""}${c.model ?? ""}${c.provider ?? ""}`)
1930
+ .sort()
1931
+ .join("");
1932
+ }
1837
1933
  clientForProvider(provider) {
1838
1934
  return this.clients.get(provider);
1839
1935
  }
@@ -2008,12 +2104,24 @@ export class AgentWorkspaceProxy {
2008
2104
  commands: runtimeCapabilities?.commands,
2009
2105
  modes: runtimeCapabilities?.modes,
2010
2106
  currentMode: runtimeCapabilities?.currentMode,
2107
+ // Preserve any MCP status already accumulated from startupStatus events.
2108
+ mcpServers: runtimeCapabilities?.mcpServers ?? this.providerCapabilities.get(provider)?.mcpServers,
2011
2109
  };
2012
2110
  this.providerCapabilities.set(provider, merged);
2013
2111
  return;
2014
2112
  }
2015
- if (runtimeCapabilities)
2113
+ if (runtimeCapabilities) {
2114
+ // Don't clobber MCP status that arrived via startupStatus before discovery;
2115
+ // fold both sources into one map.
2116
+ const priorMcp = this.providerCapabilities.get(provider)?.mcpServers;
2117
+ if (priorMcp && priorMcp.size > 0) {
2118
+ const map = new Map(priorMcp);
2119
+ for (const [name, desc] of runtimeCapabilities.mcpServers ?? [])
2120
+ map.set(name, desc);
2121
+ runtimeCapabilities = { ...runtimeCapabilities, mcpServers: map };
2122
+ }
2016
2123
  this.providerCapabilities.set(provider, runtimeCapabilities);
2124
+ }
2017
2125
  }
2018
2126
  async syncProviderSessions() {
2019
2127
  await this.initialize();
@@ -2288,6 +2396,9 @@ export class AgentWorkspaceProxy {
2288
2396
  reasoningEffort: supportsReasoningEffort,
2289
2397
  streamJsonFallback: isClaudeFallback,
2290
2398
  },
2399
+ mcpServers: runtimeCapabilities?.mcpServers && runtimeCapabilities.mcpServers.size > 0
2400
+ ? [...runtimeCapabilities.mcpServers.values()]
2401
+ : undefined,
2291
2402
  };
2292
2403
  });
2293
2404
  const anyEnabled = providers.some((p) => p.enabled);
@@ -2349,6 +2460,44 @@ export class AgentWorkspaceProxy {
2349
2460
  }
2350
2461
  return this.openFailure(payload, message, cwd);
2351
2462
  }
2463
+ // Fork: seed a NEW conversation from an existing one's transcript, truncated
2464
+ // at forkFromTurnId. Claude only — the SDK client exposes forkSession(); the
2465
+ // stream-json fallback and Codex don't, so we notify and fall through to a
2466
+ // plain new conversation rather than pretend. On success we set agentSessionId
2467
+ // to the freshly-written transcript id and let the normal load path hydrate it.
2468
+ if (payload.forkFromConversationId) {
2469
+ const source = this.conversations.get(payload.forkFromConversationId);
2470
+ const sourceSessionId = source?.agentSessionId;
2471
+ const forkable = client.forkSession;
2472
+ if (provider === "claude" && typeof forkable === "function" && sourceSessionId) {
2473
+ if (source?.cwd && !payload.cwd)
2474
+ cwd = source.cwd;
2475
+ const forked = forkable.call(client, { sourceSessionId, uptoTurnId: payload.forkFromTurnId });
2476
+ if (forked?.sessionId) {
2477
+ agentSessionId = forked.sessionId;
2478
+ // Inherit the source's config so the fork opens like its parent.
2479
+ payload = {
2480
+ ...payload,
2481
+ model: payload.model ?? source?.model,
2482
+ reasoningEffort: payload.reasoningEffort ?? source?.reasoningEffort,
2483
+ permissionMode: payload.permissionMode ?? source?.permissionMode,
2484
+ collaborationMode: payload.collaborationMode ?? source?.collaborationMode,
2485
+ title: payload.title ?? (source?.title ? `${source.title} (分叉)` : undefined),
2486
+ };
2487
+ existingConversation = undefined; // force the new-conversation path
2488
+ }
2489
+ else {
2490
+ this.emitNotice({ kind: "warning", title: "分叉失败", detail: "无法复制该会话的历史记录,已新建空会话。" });
2491
+ }
2492
+ }
2493
+ else {
2494
+ this.emitNotice({
2495
+ kind: "native_unsupported",
2496
+ title: "暂不支持分叉",
2497
+ detail: provider === "claude" ? "该会话无法分叉。" : `${providerLabel(provider)} 暂不支持从历史分叉,已新建空会话。`,
2498
+ });
2499
+ }
2500
+ }
2352
2501
  if (existingConversation && existingConversation.status !== "error" && existingConversation.agentSessionId) {
2353
2502
  const requestedCanonicalId = makeAgentV2RemoteConversationId(provider, existingConversation.agentSessionId);
2354
2503
  if (payload.conversationId &&
@@ -2897,6 +3046,41 @@ export class AgentWorkspaceProxy {
2897
3046
  }
2898
3047
  return {};
2899
3048
  }
3049
+ // Merge MCP server descriptors into a provider's runtime capabilities,
3050
+ // last-writer-wins by server name. Creates the map lazily.
3051
+ mergeMcpServers(provider, servers) {
3052
+ if (servers.length === 0)
3053
+ return;
3054
+ const existing = this.providerCapabilities.get(provider) ?? {};
3055
+ const map = existing.mcpServers ?? new Map();
3056
+ for (const server of servers)
3057
+ map.set(server.name, server);
3058
+ this.providerCapabilities.set(provider, { ...existing, mcpServers: map });
3059
+ }
3060
+ // A live `mcpServer/startupStatus/<name>` notification. The server name is
3061
+ // presumed to be the method suffix (falls back to a name field in params);
3062
+ // the status/error/toolCount shape is parsed defensively since the real event
3063
+ // vocabulary isn't fully pinned. Re-emits capabilities so the console updates.
3064
+ handleMcpStartupStatus(method, params) {
3065
+ // MCP is Claude-only today; target the enabled provider that supports it.
3066
+ const provider = this.input.availableProviders.find((p) => p === "claude" && this.clients.has(p))
3067
+ ?? this.input.availableProviders.find((p) => this.clients.has(p));
3068
+ if (!provider)
3069
+ return;
3070
+ const raw = asRecord(params) ?? {};
3071
+ const suffix = method.slice(method.lastIndexOf("/") + 1);
3072
+ const name = (suffix && suffix !== "startupStatus" ? suffix : undefined)
3073
+ ?? firstString(raw, ["name", "server", "serverName"]);
3074
+ if (!name)
3075
+ return;
3076
+ this.mergeMcpServers(provider, [{
3077
+ name,
3078
+ status: normalizeMcpStatus(raw.status ?? raw.state),
3079
+ error: firstString(raw, ["error", "message", "reason"]),
3080
+ toolCount: firstNumber(raw, ["toolCount", "tools", "toolsCount"]) ?? (Array.isArray(raw.tools) ? raw.tools.length : undefined),
3081
+ }]);
3082
+ this.sendCapabilities();
3083
+ }
2900
3084
  handleNotification(method, params) {
2901
3085
  if (this.input.verbose) {
2902
3086
  process.stderr.write(`[agent:v2] ${method} ${stringify(params).slice(0, 500)}\n`);
@@ -2924,10 +3108,24 @@ export class AgentWorkspaceProxy {
2924
3108
  this.emitConversation(conversation);
2925
3109
  }
2926
3110
  }
3111
+ // Seed the MCP server list from the init payload (stream-json carries
3112
+ // `mcp_servers`; the SDK path may carry `mcpServers`). Late per-server
3113
+ // startupStatus events refine this afterward.
3114
+ if (provider) {
3115
+ const initMcp = parseMcpServerList((asRecord(params) ?? {}).mcpServers ??
3116
+ (asRecord(params) ?? {}).mcp_servers);
3117
+ if (initMcp.length > 0) {
3118
+ this.mergeMcpServers(provider, initMcp);
3119
+ this.sendCapabilities();
3120
+ }
3121
+ }
3122
+ return;
3123
+ }
3124
+ if (method.startsWith("mcpServer/startupStatus/")) {
3125
+ this.handleMcpStartupStatus(method, params);
2927
3126
  return;
2928
3127
  }
2929
3128
  if (method.startsWith("account/") ||
2930
- method.startsWith("mcpServer/startupStatus/") ||
2931
3129
  method === "serverRequest/resolved" ||
2932
3130
  method === "mcpServer/oauthLogin/completed") {
2933
3131
  return;
@@ -3202,6 +3400,14 @@ export class AgentWorkspaceProxy {
3202
3400
  if (bufferedOutput && !toolCall.output)
3203
3401
  toolCall.output = bufferedOutput;
3204
3402
  this.upsertTool(conversationId, toolCall);
3403
+ // The buffer only exists to stitch streaming deltas onto the final item.
3404
+ // Once the tool call reaches a terminal state its output is folded into the
3405
+ // toolCall above and will never be appended to again, so drop the buffer —
3406
+ // otherwise the map grows unbounded across a long-lived workspace (one entry
3407
+ // per tool call / exec ever seen).
3408
+ if (toolCall.status === "completed" || toolCall.status === "failed") {
3409
+ this.toolOutputBuffers.delete(toolCall.id);
3410
+ }
3205
3411
  }
3206
3412
  handleToolDelta(method, params) {
3207
3413
  const raw = asRecord(params);