linkshell-cli 0.5.2 → 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.
@@ -1597,6 +1597,9 @@ export class AgentWorkspaceProxy {
1597
1597
  private conversations = new Map<string, AgentConversation>();
1598
1598
  private conversationByAgentSessionId = new Map<string, string>();
1599
1599
  private timelines = new Map<string, AgentTimelineItem[]>();
1600
+ // Opaque codex turns cursor per conversation, pointing at OLDER history to
1601
+ // page through (captured at hydration, advanced on each history.request).
1602
+ private historyCursors = new Map<string, string | undefined>();
1600
1603
  private toolOutputBuffers = new Map<string, string>();
1601
1604
  private pendingPermissions = new Map<string, AgentPermission>();
1602
1605
  private permissionWaiters = new Map<string, PendingPermissionWaiter>();
@@ -1647,6 +1650,11 @@ export class AgentWorkspaceProxy {
1647
1650
  this.sendSnapshot(payload.conversationId);
1648
1651
  break;
1649
1652
  }
1653
+ case "agent.v2.history.request": {
1654
+ const payload = parseTypedPayload("agent.v2.history.request", envelope.payload);
1655
+ await this.loadOlderHistory(payload);
1656
+ break;
1657
+ }
1650
1658
  case "agent.v2.prompt": {
1651
1659
  const payload = parseTypedPayload("agent.v2.prompt", envelope.payload);
1652
1660
  await this.sendPrompt(payload);
@@ -1705,6 +1713,28 @@ export class AgentWorkspaceProxy {
1705
1713
  return this.clients.get(provider);
1706
1714
  }
1707
1715
 
1716
+ private shouldRecycleProviderClient(provider: AgentProvider, error: unknown): boolean {
1717
+ if (provider !== "codex") return false;
1718
+ const message = error instanceof Error ? error.message : String(error);
1719
+ return /ACP agent is not running|ACP request timed out|Transport channel closed|TokenRefreshFailed|Failed to parse server response|channel closed|EPIPE|ECONNRESET/i.test(message);
1720
+ }
1721
+
1722
+ private async recycleProviderClient(
1723
+ provider: AgentProvider,
1724
+ error: unknown,
1725
+ ): Promise<AcpClient | ClaudeSdkClient | ClaudeStreamJsonClient | undefined> {
1726
+ if (!this.shouldRecycleProviderClient(provider, error)) return undefined;
1727
+ const message = error instanceof Error ? error.message : String(error);
1728
+ const existing = this.clients.get(provider);
1729
+ if (existing) {
1730
+ try { existing.stop(); } catch {}
1731
+ }
1732
+ this.clients.delete(provider);
1733
+ this.agentProtocols.delete(provider);
1734
+ this.providerErrors.set(provider, message);
1735
+ return this.ensureProviderClient(provider);
1736
+ }
1737
+
1708
1738
  private protocolForProvider(provider: AgentProvider): AgentProtocol | undefined {
1709
1739
  return this.agentProtocols.get(provider);
1710
1740
  }
@@ -1866,7 +1896,16 @@ export class AgentWorkspaceProxy {
1866
1896
  await this.initialize();
1867
1897
  for (const [provider, client] of this.clients) {
1868
1898
  try {
1869
- const result = await client.listSessions();
1899
+ let activeClient = client;
1900
+ let result: unknown;
1901
+ try {
1902
+ result = await activeClient.listSessions();
1903
+ } catch (error) {
1904
+ const recovered = await this.recycleProviderClient(provider, error);
1905
+ if (!recovered) throw error;
1906
+ activeClient = recovered;
1907
+ result = await activeClient.listSessions();
1908
+ }
1870
1909
  for (const remote of parseRemoteSessions(result)) {
1871
1910
  const agentSessionId = remote.id;
1872
1911
  const existingId = this.conversationByAgentSessionId.get(agentSessionId);
@@ -1906,7 +1945,7 @@ export class AgentWorkspaceProxy {
1906
1945
  conversation: AgentConversation,
1907
1946
  seedResult?: unknown,
1908
1947
  ): Promise<void> {
1909
- const client = this.clientForProvider(conversation.provider);
1948
+ let client = this.clientForProvider(conversation.provider);
1910
1949
  if (!client || !conversation.agentSessionId) return;
1911
1950
  let source = seedResult;
1912
1951
  const hasSeedTurns = turnItemsFromThread(threadFromProviderResult(seedResult)).length > 0;
@@ -1919,6 +1958,8 @@ export class AgentWorkspaceProxy {
1919
1958
  includeTurns: true,
1920
1959
  });
1921
1960
  } catch (error) {
1961
+ const recovered = await this.recycleProviderClient(conversation.provider, error);
1962
+ if (recovered) client = recovered;
1922
1963
  if (this.input.verbose) {
1923
1964
  process.stderr.write(`[agent:v2] thread/read hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
1924
1965
  }
@@ -1934,14 +1975,33 @@ export class AgentWorkspaceProxy {
1934
1975
  }) => Promise<unknown> }).listTurns;
1935
1976
  if (typeof listTurns === "function") {
1936
1977
  try {
1937
- const turnsResult = await listTurns.call(client, {
1938
- sessionId: conversation.agentSessionId,
1939
- limit: MAX_TIMELINE_ITEMS,
1940
- sortDirection: "desc",
1941
- itemsView: "full",
1942
- });
1978
+ let turnsResult: unknown;
1979
+ try {
1980
+ turnsResult = await listTurns.call(client, {
1981
+ sessionId: conversation.agentSessionId,
1982
+ limit: MAX_TIMELINE_ITEMS,
1983
+ sortDirection: "desc",
1984
+ itemsView: "full",
1985
+ });
1986
+ } catch (error) {
1987
+ const recovered = await this.recycleProviderClient(conversation.provider, error);
1988
+ if (!recovered) throw error;
1989
+ client = recovered;
1990
+ const recoveredListTurns = (client as { listTurns?: typeof listTurns }).listTurns;
1991
+ if (typeof recoveredListTurns !== "function") throw error;
1992
+ turnsResult = await recoveredListTurns.call(client, {
1993
+ sessionId: conversation.agentSessionId,
1994
+ limit: MAX_TIMELINE_ITEMS,
1995
+ sortDirection: "desc",
1996
+ itemsView: "full",
1997
+ });
1998
+ }
1943
1999
  const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
1944
2000
  if (turnsThread) source = { thread: turnsThread };
2001
+ // Capture the opaque cursor pointing at OLDER turns so the client
2002
+ // can page further back via agent.v2.history.request.
2003
+ const olderCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
2004
+ this.historyCursors.set(conversation.id, olderCursor);
1945
2005
  } catch (error) {
1946
2006
  if (this.input.verbose) {
1947
2007
  process.stderr.write(`[agent:v2] thread/turns/list hydration failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`);
@@ -2077,7 +2137,7 @@ export class AgentWorkspaceProxy {
2077
2137
  (agentSessionId ? this.conversations.get(this.conversationByAgentSessionId.get(agentSessionId) ?? "") : undefined);
2078
2138
  if (!payload.cwd && existingConversation?.cwd) cwd = existingConversation.cwd;
2079
2139
 
2080
- const client = await this.ensureProviderClient(provider);
2140
+ let client = await this.ensureProviderClient(provider);
2081
2141
  if (!client) {
2082
2142
  const message = this.providerErrors.get(provider) ?? `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`;
2083
2143
  if (existingConversation) {
@@ -2113,7 +2173,15 @@ export class AgentWorkspaceProxy {
2113
2173
  if ((this.timelines.get(existingConversation.id) ?? []).length === 0 && existingConversation.status !== "running") {
2114
2174
  try {
2115
2175
  const existingAgentSessionId = existingConversation.agentSessionId!;
2116
- const result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2176
+ let result: unknown;
2177
+ try {
2178
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2179
+ } catch (error) {
2180
+ const recovered = await this.recycleProviderClient(provider, error);
2181
+ if (!recovered) throw error;
2182
+ client = recovered;
2183
+ result = await client.loadSession({ sessionId: existingAgentSessionId, cwd });
2184
+ }
2117
2185
  const nextAgentSessionId = this.extractSessionId(result);
2118
2186
  if (nextAgentSessionId && nextAgentSessionId !== existingAgentSessionId) {
2119
2187
  this.conversationByAgentSessionId.delete(existingAgentSessionId);
@@ -2141,9 +2209,19 @@ export class AgentWorkspaceProxy {
2141
2209
  }
2142
2210
 
2143
2211
  try {
2144
- const result = agentSessionId
2145
- ? await client.loadSession({ sessionId: agentSessionId, cwd })
2146
- : await client.newSession({ cwd });
2212
+ let result: unknown;
2213
+ try {
2214
+ result = agentSessionId
2215
+ ? await client.loadSession({ sessionId: agentSessionId, cwd })
2216
+ : await client.newSession({ cwd });
2217
+ } catch (error) {
2218
+ const recovered = await this.recycleProviderClient(provider, error);
2219
+ if (!recovered) throw error;
2220
+ client = recovered;
2221
+ result = agentSessionId
2222
+ ? await client.loadSession({ sessionId: agentSessionId, cwd })
2223
+ : await client.newSession({ cwd });
2224
+ }
2147
2225
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
2148
2226
  const now = Date.now();
2149
2227
  const conversationId = makeAgentV2RemoteConversationId(provider, agentSessionId);
@@ -3348,11 +3426,20 @@ export class AgentWorkspaceProxy {
3348
3426
  if (!conversationId) return waitForResponse ? Promise.resolve({ outcome: { outcome: "cancelled" } }) : undefined;
3349
3427
  const requestId = firstString(raw, ["requestId", "id", "permissionId"]) ?? id("perm");
3350
3428
  const rawToolCall = asRecord(raw.toolCall) ?? raw;
3429
+ // Codex app-server command/file approvals carry `command`/`reason`/`cwd`
3430
+ // (not toolCall/context). Surface them so the card actually shows what's
3431
+ // being approved; fall back to the Claude/MCP shape otherwise.
3432
+ const codexCommand = firstString(raw, ["command"]);
3433
+ const codexCwd = firstString(raw, ["cwd"]);
3351
3434
  const permission: AgentPermission = {
3352
3435
  requestId,
3353
- toolName: firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]),
3354
- toolInput: stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
3355
- context: firstString(raw, ["context", "description", "message", "title"]),
3436
+ toolName:
3437
+ firstString(rawToolCall, ["toolName", "tool", "name", "title", "kind"]) ??
3438
+ (codexCommand ? "shell" : undefined),
3439
+ toolInput: codexCommand
3440
+ ? (codexCwd ? `$ ${codexCommand}\n# cwd: ${codexCwd}` : `$ ${codexCommand}`)
3441
+ : stringify(rawToolCall.input ?? rawToolCall.toolInput ?? rawToolCall),
3442
+ context: firstString(raw, ["context", "description", "message", "title", "reason"]),
3356
3443
  options: parsePermissionOptions(raw.options),
3357
3444
  };
3358
3445
  this.pendingPermissions.set(requestId, permission);
@@ -3761,6 +3848,76 @@ export class AgentWorkspaceProxy {
3761
3848
  }));
3762
3849
  }
3763
3850
 
3851
+ /** Page OLDER transcript history for a conversation (client scrolling up).
3852
+ * Only Codex's app-server exposes paginated reads; other providers report
3853
+ * no more history. Pure pass-through — does not touch the bounded live
3854
+ * timeline. The client prepends the returned (ascending) items. */
3855
+ private async loadOlderHistory(payload: {
3856
+ conversationId: string;
3857
+ cursor?: string;
3858
+ limit: number;
3859
+ }): Promise<void> {
3860
+ const sendResult = (
3861
+ items: AgentTimelineItem[],
3862
+ nextCursor?: string,
3863
+ ): void => {
3864
+ this.input.send(createEnvelope({
3865
+ type: "agent.v2.history.result",
3866
+ sessionId: this.input.sessionId,
3867
+ payload: {
3868
+ conversationId: payload.conversationId,
3869
+ items,
3870
+ nextCursor,
3871
+ hasMore: Boolean(nextCursor),
3872
+ },
3873
+ }));
3874
+ };
3875
+
3876
+ const conversation = this.conversations.get(payload.conversationId);
3877
+ if (!conversation?.agentSessionId) return sendResult([], undefined);
3878
+
3879
+ const client = this.clientForProvider(conversation.provider);
3880
+ const listTurns = (client as {
3881
+ listTurns?: (input: {
3882
+ sessionId: string;
3883
+ limit?: number;
3884
+ cursor?: string;
3885
+ sortDirection?: "asc" | "desc";
3886
+ itemsView?: "summary" | "full";
3887
+ }) => Promise<unknown>;
3888
+ } | undefined)?.listTurns;
3889
+ if (typeof listTurns !== "function") return sendResult([], undefined);
3890
+
3891
+ // Client-supplied cursor wins; else the one captured at hydration. Absent
3892
+ // cursor means we're already at the start of history.
3893
+ const cursor = payload.cursor ?? this.historyCursors.get(payload.conversationId);
3894
+ if (!cursor) return sendResult([], undefined);
3895
+
3896
+ try {
3897
+ const turnsResult = await listTurns.call(client, {
3898
+ sessionId: conversation.agentSessionId,
3899
+ limit: payload.limit,
3900
+ cursor,
3901
+ sortDirection: "desc",
3902
+ itemsView: "full",
3903
+ });
3904
+ const turnsThread = threadFromTurnsListResult(conversation.agentSessionId, turnsResult);
3905
+ const items = turnsThread
3906
+ ? timelineItemsFromProviderThread({ thread: turnsThread }, payload.conversationId)
3907
+ : [];
3908
+ const nextCursor = firstString(asRecord(turnsResult), ["nextCursor", "next_cursor", "cursor"]);
3909
+ this.historyCursors.set(payload.conversationId, nextCursor);
3910
+ sendResult(items, nextCursor);
3911
+ } catch (error) {
3912
+ if (this.input.verbose) {
3913
+ process.stderr.write(
3914
+ `[agent:v2] history page failed for ${conversation.provider}: ${error instanceof Error ? error.message : String(error)}\n`,
3915
+ );
3916
+ }
3917
+ sendResult([], undefined);
3918
+ }
3919
+ }
3920
+
3764
3921
  private conversationIdFromParams(params: unknown): string | undefined {
3765
3922
  const raw = asRecord(params);
3766
3923
  if (!raw) return undefined;
@@ -120,6 +120,7 @@ function splitImageDataUrl(value: string, fallbackMimeType = "image/png"): { dat
120
120
  }
121
121
 
122
122
  export class ClaudeStreamJsonClient {
123
+ private static warnedBypass = false;
123
124
  private child: ChildProcessWithoutNullStreams | undefined;
124
125
  private claudeSessionId: string | undefined;
125
126
  private pendingCancel = false;
@@ -177,6 +178,28 @@ export class ClaudeStreamJsonClient {
177
178
 
178
179
  this.pendingCancel = false;
179
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
+
180
203
  // Build Claude args
181
204
  const args: string[] = [
182
205
  "--print",