linkshell-cli 0.2.89 → 0.2.90

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.
@@ -235,6 +235,7 @@ function summarizeFileChanges(changes: unknown[]): string | undefined {
235
235
 
236
236
  export class AgentSessionProxy {
237
237
  private client: AcpClient | undefined;
238
+ private activeProvider: AgentProvider | undefined;
238
239
  private agentSessionId: string | undefined;
239
240
  private status: AgentStatus = "unavailable";
240
241
  private error: string | undefined;
@@ -253,7 +254,7 @@ export class AgentSessionProxy {
253
254
  private readonly input: {
254
255
  sessionId: string;
255
256
  cwd: string;
256
- provider: AgentProvider;
257
+ availableProviders: AgentProvider[];
257
258
  command?: string;
258
259
  send: (envelope: Envelope) => void;
259
260
  verbose?: boolean;
@@ -347,45 +348,53 @@ export class AgentSessionProxy {
347
348
  this.sendCapabilities();
348
349
  return;
349
350
  }
350
- await this.ensureClient();
351
+ await this.tryStartFirstAvailable();
351
352
  }
352
353
 
353
- private async ensureClient(): Promise<void> {
354
+ private async tryStartFirstAvailable(): Promise<void> {
354
355
  if (this.client) return;
355
356
 
356
- const resolved = resolveAgentCommand({
357
- provider: this.input.provider,
358
- command: this.input.command,
359
- });
360
- if (!resolved) {
361
- this.status = "unavailable";
362
- this.error = `Agent GUI requires --agent-command for ${this.input.provider}`;
363
- this.sendCapabilities();
364
- return;
365
- }
366
-
367
- try {
368
- this.client = new AcpClient({
369
- command: resolved.command,
370
- protocol: resolved.protocol,
371
- framing: resolved.framing,
372
- cwd: this.input.cwd,
373
- onNotification: (method, params) => this.handleNotification(method, params),
374
- onRequest: (method, params) => this.handleRequest(method, params),
375
- onExit: (message) => this.handleExit(message),
357
+ for (const provider of this.input.availableProviders) {
358
+ const resolved = resolveAgentCommand({
359
+ provider,
360
+ command: this.input.command,
376
361
  });
377
- await this.client.initialize();
378
- this.initialized = true;
379
- this.status = "idle";
380
- this.error = undefined;
381
- this.sendCapabilities();
382
- } catch (error) {
383
- this.client?.stop();
384
- this.client = undefined;
385
- this.status = "error";
386
- this.error = error instanceof Error ? error.message : String(error);
387
- this.sendCapabilities();
362
+ if (!resolved) continue;
363
+
364
+ try {
365
+ this.client = new AcpClient({
366
+ command: resolved.command,
367
+ protocol: resolved.protocol,
368
+ framing: resolved.framing,
369
+ cwd: this.input.cwd,
370
+ onNotification: (method, params) => this.handleNotification(method, params),
371
+ onRequest: (method, params) => this.handleRequest(method, params),
372
+ onExit: (message) => this.handleExit(message),
373
+ });
374
+ await this.client.initialize();
375
+ this.activeProvider = provider;
376
+ this.initialized = true;
377
+ this.status = "idle";
378
+ this.error = undefined;
379
+ this.sendCapabilities();
380
+ return;
381
+ } catch (error) {
382
+ this.client?.stop();
383
+ this.client = undefined;
384
+ if (this.input.verbose) {
385
+ process.stderr.write(`[agent] failed to start ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
386
+ }
387
+ }
388
388
  }
389
+
390
+ this.status = "unavailable";
391
+ this.error = "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。";
392
+ this.sendCapabilities();
393
+ }
394
+
395
+ private async ensureClient(): Promise<void> {
396
+ if (this.client) return;
397
+ await this.tryStartFirstAvailable();
389
398
  }
390
399
 
391
400
  private async ensureSession(
@@ -950,12 +959,13 @@ export class AgentSessionProxy {
950
959
 
951
960
  private sendCapabilities(): void {
952
961
  const enabled = Boolean(this.client && this.initialized && !this.error);
962
+ const activeProvider = this.activeProvider ?? this.input.availableProviders[0];
953
963
  this.input.send(createEnvelope({
954
964
  type: "agent.capabilities",
955
965
  sessionId: this.input.sessionId,
956
966
  payload: {
957
967
  enabled,
958
- provider: this.input.provider,
968
+ provider: activeProvider ?? "codex",
959
969
  protocolVersion: 1,
960
970
  error: enabled ? undefined : this.error,
961
971
  supportsSessionList: enabled,
@@ -656,21 +656,9 @@ function providerLabel(provider: AgentProvider): string {
656
656
  return "Custom";
657
657
  }
658
658
 
659
- function providerSetupReason(provider: AgentProvider, activeProvider: AgentProvider, error?: string): string {
660
- if (provider === activeProvider) {
661
- return error ?? `${providerLabel(provider)} Agent 正在初始化或不可用。`;
662
- }
663
- if (provider === "codex") {
664
- return `当前 CLI 启用的是 ${providerLabel(activeProvider)} Agent。`;
665
- }
666
- if (provider === "claude") {
667
- return "Claude ACP adapter 尚未启用,请用 --agent-provider claude --agent-command 配置。";
668
- }
669
- return "Custom Agent 需要用 --agent-provider custom --agent-command 配置后才能使用。";
670
- }
671
-
672
659
  export class AgentWorkspaceProxy {
673
- private client: AcpClient | undefined;
660
+ private clients = new Map<AgentProvider, AcpClient>();
661
+ private agentProtocols = new Map<AgentProvider, AgentProtocol>();
674
662
  private initialized = false;
675
663
  private status: AgentStatus = "unavailable";
676
664
  private error: string | undefined;
@@ -686,13 +674,12 @@ export class AgentWorkspaceProxy {
686
674
  private pendingStructuredInputs = new Map<string, { conversationId: string; input: AgentStructuredInput }>();
687
675
  private structuredInputWaiters = new Map<string, PendingStructuredInputWaiter>();
688
676
  private toolConversationIds = new Map<string, string>();
689
- private agentProtocol: AgentProtocol | undefined;
690
677
 
691
678
  constructor(
692
679
  private readonly input: {
693
680
  sessionId: string;
694
681
  cwd: string;
695
- provider: AgentProvider;
682
+ availableProviders: AgentProvider[];
696
683
  command?: string;
697
684
  send: (envelope: Envelope) => void;
698
685
  verbose?: boolean;
@@ -736,7 +723,8 @@ export class AgentWorkspaceProxy {
736
723
  const payload = parseTypedPayload("agent.v2.cancel", envelope.payload);
737
724
  const conversation = this.conversations.get(payload.conversationId);
738
725
  this.cancelPendingPermissions(payload.conversationId);
739
- this.client?.cancel({
726
+ const cancelClient = conversation ? this.clientForProvider(conversation.provider) : undefined;
727
+ cancelClient?.cancel({
740
728
  sessionId: conversation?.agentSessionId,
741
729
  turnId: this.currentTurnId,
742
730
  });
@@ -759,89 +747,104 @@ export class AgentWorkspaceProxy {
759
747
  }
760
748
 
761
749
  stop(): void {
762
- this.client?.stop();
763
- this.client = undefined;
750
+ for (const client of this.clients.values()) {
751
+ client.stop();
752
+ }
753
+ this.clients.clear();
754
+ }
755
+
756
+ private clientForProvider(provider: AgentProvider): AcpClient | undefined {
757
+ return this.clients.get(provider);
758
+ }
759
+
760
+ private protocolForProvider(provider: AgentProvider): AgentProtocol | undefined {
761
+ return this.agentProtocols.get(provider);
764
762
  }
765
763
 
766
764
  private async initialize(): Promise<void> {
767
765
  if (this.initialized) return;
768
- await this.ensureClient();
766
+ // trigger capability report immediately, lazy-start providers on first use
767
+ this.initialized = true;
768
+ this.status = "idle";
769
+ this.error = undefined;
770
+ this.sendCapabilities();
769
771
  }
770
772
 
771
- private async ensureClient(): Promise<void> {
772
- if (this.client) return;
773
+ private async ensureProviderClient(provider: AgentProvider): Promise<AcpClient | undefined> {
774
+ const existing = this.clients.get(provider);
775
+ if (existing) return existing;
773
776
 
774
777
  const resolved = resolveAgentCommand({
775
- provider: this.input.provider,
778
+ provider,
776
779
  command: this.input.command,
777
780
  });
778
781
  if (!resolved) {
779
- this.status = "unavailable";
780
- this.error = `Agent Workspace requires --agent-command for ${this.input.provider}`;
781
- return;
782
+ if (this.input.verbose) {
783
+ process.stderr.write(`[agent:v2] no command for provider ${provider}\n`);
784
+ }
785
+ return undefined;
782
786
  }
783
787
 
784
788
  try {
785
- this.agentProtocol = resolved.protocol;
786
- this.client = new AcpClient({
789
+ this.agentProtocols.set(provider, resolved.protocol);
790
+ const client = new AcpClient({
787
791
  command: resolved.command,
788
792
  protocol: resolved.protocol,
789
793
  framing: resolved.framing,
790
794
  cwd: this.input.cwd,
791
795
  onNotification: (method, params) => this.handleNotification(method, params),
792
796
  onRequest: (method, params) => this.handleRequest(method, params),
793
- onExit: (message) => this.handleExit(message),
797
+ onExit: (message) => this.handleProviderExit(provider, message),
794
798
  });
795
- await this.client.initialize();
796
- this.initialized = true;
799
+ await client.initialize();
800
+ this.clients.set(provider, client);
797
801
  this.status = "idle";
798
802
  this.error = undefined;
803
+ this.sendCapabilities();
804
+ return client;
799
805
  } catch (error) {
800
- this.client?.stop();
801
- this.client = undefined;
802
- this.status = "error";
803
- this.error = error instanceof Error ? error.message : String(error);
806
+ if (this.input.verbose) {
807
+ process.stderr.write(`[agent:v2] failed to start ${provider}: ${error instanceof Error ? error.message : String(error)}\n`);
808
+ }
809
+ return undefined;
804
810
  }
805
811
  }
806
812
 
807
813
  private sendCapabilities(): void {
808
- const enabled = Boolean(this.client && this.initialized && !this.error);
809
- const supportsImages = enabled && this.agentProtocol === "codex-app-server";
810
- const activeProvider = this.input.provider;
811
- const providerIds: AgentProvider[] = ["codex", "claude"];
812
- if (activeProvider === "custom") providerIds.push("custom");
814
+ const providers = this.input.availableProviders.map((provider) => {
815
+ const client = this.clients.get(provider);
816
+ const protocol = this.agentProtocols.get(provider);
817
+ const enabled = Boolean(client);
818
+ const supportsImages = enabled && protocol === "codex-app-server";
819
+ return {
820
+ id: provider,
821
+ label: providerLabel(provider),
822
+ enabled,
823
+ reason: enabled ? undefined : `${providerLabel(provider)} 未安装或启动失败`,
824
+ supportsImages,
825
+ supportsPermission: enabled,
826
+ supportsPlan: enabled,
827
+ supportsCancel: enabled,
828
+ };
829
+ });
830
+ const anyEnabled = providers.some((p) => p.enabled);
813
831
  this.input.send(createEnvelope({
814
832
  type: "agent.v2.capabilities",
815
833
  sessionId: this.input.sessionId,
816
834
  payload: {
817
- enabled,
818
- provider: activeProvider,
819
- providers: providerIds.map((provider) => {
820
- const isActive = provider === activeProvider;
821
- const canUse = isActive && enabled;
822
- return {
823
- id: provider,
824
- label: providerLabel(provider),
825
- enabled: canUse,
826
- reason: canUse
827
- ? undefined
828
- : providerSetupReason(provider, activeProvider, isActive ? this.error : undefined),
829
- supportsImages: canUse && supportsImages,
830
- supportsPermission: canUse,
831
- supportsPlan: canUse,
832
- supportsCancel: canUse,
833
- };
834
- }),
835
+ enabled: anyEnabled,
836
+ provider: this.input.availableProviders[0] ?? "codex",
837
+ providers,
835
838
  protocolVersion: 1,
836
839
  workspaceProtocolVersion: 2,
837
- error: enabled ? undefined : this.error,
838
- supportsSessionList: enabled,
839
- supportsSessionLoad: enabled,
840
- supportsImages,
840
+ error: anyEnabled ? undefined : "没有可用的 Agent provider。请安装 Claude Code 或 Codex CLI。",
841
+ supportsSessionList: anyEnabled,
842
+ supportsSessionLoad: anyEnabled,
843
+ supportsImages: providers.some((p) => p.supportsImages),
841
844
  supportsAudio: false,
842
- supportsPermission: enabled,
843
- supportsPlan: enabled,
844
- supportsCancel: enabled,
845
+ supportsPermission: anyEnabled,
846
+ supportsPlan: anyEnabled,
847
+ supportsCancel: anyEnabled,
845
848
  },
846
849
  }));
847
850
  }
@@ -856,18 +859,22 @@ export class AgentWorkspaceProxy {
856
859
  permissionMode?: AgentPermissionMode;
857
860
  title?: string;
858
861
  }): Promise<AgentConversation | undefined> {
859
- await this.ensureClient();
860
- this.sendCapabilities();
861
- if (payload.provider && payload.provider !== this.input.provider) {
862
+ const provider = payload.provider ?? this.input.availableProviders[0];
863
+ if (!provider) {
864
+ return this.openFailure(payload, "没有可用的 Agent provider。");
865
+ }
866
+ if (!this.input.availableProviders.includes(provider)) {
862
867
  return this.openFailure(
863
868
  payload,
864
- `当前 CLI 只启用了 ${providerLabel(this.input.provider)} Agent,不能在这个会话里启动 ${providerLabel(payload.provider)}。`,
869
+ `${providerLabel(provider)} 未安装或不可用。`,
865
870
  );
866
871
  }
867
- if (!this.client) {
872
+
873
+ const client = await this.ensureProviderClient(provider);
874
+ if (!client) {
868
875
  return this.openFailure(
869
876
  payload,
870
- this.error ?? "Agent Workspace 不可用,请确认 CLI 已使用 --agent-ui 启动。",
877
+ `${providerLabel(provider)} 启动失败。请确认 CLI 已安装并可用。`,
871
878
  );
872
879
  }
873
880
 
@@ -895,8 +902,8 @@ export class AgentWorkspaceProxy {
895
902
 
896
903
  try {
897
904
  const result = agentSessionId
898
- ? await this.client.loadSession({ sessionId: agentSessionId, cwd })
899
- : await this.client.newSession({ cwd });
905
+ ? await client.loadSession({ sessionId: agentSessionId, cwd })
906
+ : await client.newSession({ cwd });
900
907
  agentSessionId = this.extractSessionId(result) ?? agentSessionId ?? id("agent-session");
901
908
  const now = Date.now();
902
909
  const conversationId = payload.conversationId ?? `agent:${agentSessionId}`;
@@ -904,7 +911,7 @@ export class AgentWorkspaceProxy {
904
911
  ...existingConversation,
905
912
  id: conversationId,
906
913
  agentSessionId,
907
- provider: payload.provider ?? this.input.provider,
914
+ provider,
908
915
  cwd,
909
916
  title: payload.title ?? existingConversation?.title ?? titleFromCwd(cwd),
910
917
  model: payload.model ?? existingConversation?.model,
@@ -949,7 +956,7 @@ export class AgentWorkspaceProxy {
949
956
  const now = Date.now();
950
957
  const conversation: AgentConversation = {
951
958
  id: fallbackId,
952
- provider: payload.provider ?? this.input.provider,
959
+ provider: payload.provider ?? this.input.availableProviders[0] ?? "codex",
953
960
  cwd,
954
961
  title: payload.title ?? titleFromCwd(cwd),
955
962
  model: payload.model,
@@ -989,9 +996,12 @@ export class AgentWorkspaceProxy {
989
996
  const conversation =
990
997
  this.conversations.get(payload.conversationId) ??
991
998
  await this.openConversation({ conversationId: payload.conversationId });
992
- if (!conversation || !this.client || !conversation.agentSessionId) return;
999
+ if (!conversation || !conversation.agentSessionId) return;
1000
+ const client = this.clientForProvider(conversation.provider);
1001
+ if (!client) return;
993
1002
 
994
- if (payload.contentBlocks.some((block) => block.type === "image") && this.agentProtocol !== "codex-app-server") {
1003
+ const protocol = this.protocolForProvider(conversation.provider);
1004
+ if (payload.contentBlocks.some((block) => block.type === "image") && protocol !== "codex-app-server") {
995
1005
  conversation.status = "idle";
996
1006
  conversation.lastActivityAt = Date.now();
997
1007
  this.emitConversation(conversation);
@@ -1025,7 +1035,7 @@ export class AgentWorkspaceProxy {
1025
1035
  this.emitConversation(conversation);
1026
1036
 
1027
1037
  try {
1028
- const result = await this.client.prompt({
1038
+ const result = await client.prompt({
1029
1039
  sessionId: conversation.agentSessionId,
1030
1040
  content: payload.contentBlocks,
1031
1041
  clientMessageId: payload.clientMessageId,
@@ -1651,8 +1661,10 @@ export class AgentWorkspaceProxy {
1651
1661
  ));
1652
1662
  this.permissionSources.delete(payload.requestId);
1653
1663
  } else {
1654
- this.client?.respondPermission({
1655
- sessionId: this.conversations.get(payload.conversationId)?.agentSessionId,
1664
+ const conversation = this.conversations.get(payload.conversationId);
1665
+ const respondClient = conversation ? this.clientForProvider(conversation.provider) : undefined;
1666
+ respondClient?.respondPermission({
1667
+ sessionId: conversation?.agentSessionId,
1656
1668
  requestId: payload.requestId,
1657
1669
  outcome: payload.outcome === "cancelled" ? "deny" : payload.outcome,
1658
1670
  optionId: selectedOptionId,
@@ -1919,12 +1931,12 @@ export class AgentWorkspaceProxy {
1919
1931
  return undefined;
1920
1932
  }
1921
1933
 
1922
- private handleExit(message: string): void {
1934
+ private handleProviderExit(provider: AgentProvider, message: string): void {
1935
+ this.clients.delete(provider);
1936
+ this.agentProtocols.delete(provider);
1923
1937
  this.cancelPendingPermissions();
1924
- this.status = "error";
1925
- this.error = message;
1926
- this.client = undefined;
1927
1938
  for (const conversation of this.conversations.values()) {
1939
+ if (conversation.provider !== provider) continue;
1928
1940
  conversation.status = "error";
1929
1941
  conversation.lastMessagePreview = message;
1930
1942
  conversation.lastActivityAt = Date.now();
@@ -1937,6 +1949,7 @@ export class AgentWorkspaceProxy {
1937
1949
  createdAt: Date.now(),
1938
1950
  });
1939
1951
  }
1952
+ this.sendCapabilities();
1940
1953
  }
1941
1954
 
1942
1955
  private cancelPendingPermissions(conversationId?: string): void {
@@ -1,3 +1,5 @@
1
+ import { execSync } from "node:child_process";
2
+
1
3
  export type AgentProvider = "codex" | "claude" | "custom";
2
4
  export type AgentProtocol = "acp" | "codex-app-server";
3
5
  export type AgentFraming = "content-length" | "newline";
@@ -33,5 +35,32 @@ export function resolveAgentCommand(input: {
33
35
  };
34
36
  }
35
37
 
38
+ if (input.provider === "claude") {
39
+ return {
40
+ provider: "claude",
41
+ command: "claude --acp",
42
+ protocol: "acp",
43
+ framing: "content-length",
44
+ };
45
+ }
46
+
47
+ // custom: caller must provide --agent-command
36
48
  return null;
37
49
  }
50
+
51
+ export function detectAvailableProviders(): AgentProvider[] {
52
+ const available: AgentProvider[] = [];
53
+ const bins = [
54
+ ["claude", "claude"] as const,
55
+ ["codex", "codex"] as const,
56
+ ];
57
+ for (const [, bin] of bins) {
58
+ try {
59
+ execSync(`which ${bin}`, { stdio: "ignore" });
60
+ available.push(bin as AgentProvider);
61
+ } catch {
62
+ // not installed
63
+ }
64
+ }
65
+ return available.length > 0 ? available : [];
66
+ }
@@ -21,7 +21,7 @@ import { getLanIp } from "../utils/lan-ip.js";
21
21
  import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
22
22
  import { AgentSessionProxy } from "./acp/agent-session.js";
23
23
  import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
24
- import type { AgentProvider } from "./acp/provider-resolver.js";
24
+ import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
25
25
 
26
26
  export interface BridgeSessionOptions {
27
27
  gatewayUrl: string;
@@ -330,13 +330,16 @@ export class BridgeSession {
330
330
  }
331
331
  if (this.options.agentUi) {
332
332
  process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
333
- const agentProvider = normalizeAgentProvider(
334
- this.options.agentProvider ?? "codex",
335
- );
333
+ const availableProviders = this.options.agentProvider
334
+ ? [normalizeAgentProvider(this.options.agentProvider)]
335
+ : detectAvailableProviders();
336
+ if (availableProviders.length === 0) {
337
+ availableProviders.push("codex"); // last-resort fallback
338
+ }
336
339
  const agentOptions = {
337
340
  sessionId: this.sessionId,
338
341
  cwd: process.cwd(),
339
- provider: agentProvider,
342
+ availableProviders,
340
343
  command: this.options.agentCommand,
341
344
  verbose: this.options.verbose,
342
345
  send: (envelope: Envelope) => this.send(envelope),
@@ -347,7 +350,7 @@ export class BridgeSession {
347
350
  this.agentWorkspace = new AgentWorkspaceProxy({
348
351
  ...agentOptions,
349
352
  });
350
- process.stderr.write("[bridge] agent workspace channel enabled\n");
353
+ process.stderr.write(`[bridge] agent workspace channel enabled (providers: ${availableProviders.join(", ")})\n`);
351
354
  }
352
355
  await this.spawnTerminal(DEFAULT_TERMINAL_ID, process.cwd());
353
356
  this.connectGateway();
@@ -1274,13 +1277,15 @@ export class BridgeSession {
1274
1277
  if (!term?.hookPort) return;
1275
1278
  const marker = term.hookMarker;
1276
1279
  const curlCmd = `curl -s -X POST "http://127.0.0.1:${term.hookPort}/hook?m=${marker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @-`;
1277
- const agentProvider = normalizeAgentProvider(this.options.agentProvider ?? "codex");
1280
+ const providers = this.options.agentProvider
1281
+ ? [normalizeAgentProvider(this.options.agentProvider)]
1282
+ : detectAvailableProviders();
1278
1283
  try {
1279
- if (agentProvider === "claude") {
1280
- this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
1281
- } else {
1282
- this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
1283
- if (agentProvider === "custom") {
1284
+ for (const provider of providers) {
1285
+ if (provider === "codex") {
1286
+ this.setupCodexHooks(DEFAULT_TERMINAL_ID, curlCmd, marker);
1287
+ } else {
1288
+ // claude, custom
1284
1289
  this.setupClaudeHooks(DEFAULT_TERMINAL_ID, curlCmd, [], marker);
1285
1290
  }
1286
1291
  }