pi-maestro-teammate 2.2.0 → 2.3.0

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.
Files changed (123) hide show
  1. package/README.md +11 -1
  2. package/package.json +17 -5
  3. package/src/backends/acp-cli-catalog.ts +6 -0
  4. package/src/backends/acp-cli.ts +24 -8
  5. package/src/cli-tools/cli-tools-config.ts +86 -26
  6. package/src/cli-tools/local-acp.ts +8 -4
  7. package/src/completion-outbox/coordinator.ts +7 -12
  8. package/src/completion-outbox/file-store.ts +195 -123
  9. package/src/completion-outbox/types.ts +2 -0
  10. package/src/extension/herdr-window-provider.ts +794 -0
  11. package/src/extension/index.ts +1387 -286
  12. package/src/extension/mailbox/file-store.ts +25 -8
  13. package/src/extension/mailbox/host.ts +2 -0
  14. package/src/extension/mailbox/rollout.ts +12 -2
  15. package/src/extension/mailbox/router.ts +77 -33
  16. package/src/extension/mailbox/service.ts +4 -0
  17. package/src/extension/mailbox/types.ts +8 -1
  18. package/src/extension/monitor-window-lifecycle.ts +278 -0
  19. package/src/extension/monitor-window-state.ts +514 -0
  20. package/src/extension/monitor.ts +616 -15
  21. package/src/extension/remote-window-monitor.ts +357 -0
  22. package/src/extension/remote-window-session.ts +50 -0
  23. package/src/extension/runtime-actor-host.ts +113 -2
  24. package/src/extension/schemas.ts +112 -6
  25. package/src/extension/session-endpoints.ts +168 -20
  26. package/src/extension/teammate-helpers.ts +6 -0
  27. package/src/extension/teammate-proxy.ts +37 -0
  28. package/src/extension/tool-error-signal.ts +42 -0
  29. package/src/extension/workspace-peers.ts +8 -2848
  30. package/src/extension/workspace-turns-observation.ts +8 -5
  31. package/src/models/cli-add.ts +8 -2
  32. package/src/models/model-routing.ts +92 -0
  33. package/src/public/v1/index.ts +3 -0
  34. package/src/public/v1/monitor-window-state.ts +525 -0
  35. package/src/public/v1/session-history.ts +2 -0
  36. package/src/public/v1/ssh-hosts.ts +209 -0
  37. package/src/public/v2/runtime-broker.ts +6 -0
  38. package/src/public/v2/runtime.ts +3 -0
  39. package/src/remote/child-security.ts +30 -4
  40. package/src/remote/cli.ts +3 -1
  41. package/src/remote/config.ts +131 -17
  42. package/src/remote/driver.ts +15 -0
  43. package/src/remote/process-tree.ts +12 -28
  44. package/src/remote/protocol.ts +164 -1
  45. package/src/remote/server.ts +93 -1
  46. package/src/remote/ssh-exec.ts +17 -1
  47. package/src/remote/ssh.ts +162 -17
  48. package/src/remote/types.ts +29 -2
  49. package/src/remote/window-protocol.ts +501 -0
  50. package/src/remote/window-service.ts +566 -0
  51. package/src/runs/execution-infra.ts +61 -6
  52. package/src/runs/execution.ts +2 -0
  53. package/src/runs/pi-subprocess-attempt.ts +246 -99
  54. package/src/runtime-broker/client.ts +59 -11
  55. package/src/runtime-broker/mailbox-commit.ts +4 -3
  56. package/src/runtime-broker/session-commit.ts +121 -0
  57. package/src/runtime-v2/session-broker-read-model.ts +101 -0
  58. package/src/runtime-v2/session-domain.ts +397 -0
  59. package/src/runtime-v2/session-rollout.ts +90 -0
  60. package/src/sessions/session-core.ts +152 -19
  61. package/src/sessions/workspace-peer-core.ts +2993 -0
  62. package/src/shared/agent-status.ts +4 -1
  63. package/src/shared/types.ts +6 -3
  64. package/src/transcript/session-history.ts +758 -0
  65. package/src/tui/connection-wizards.ts +177 -16
  66. package/src/tui/locale-catalog-model.ts +36 -0
  67. package/src/tui/model-mapping-overlay.ts +60 -3
  68. package/src/tui/remote-config-pane.ts +114 -16
  69. package/src/tui/render.ts +504 -25
  70. package/types/cli-tools/cli-tools-config.d.ts +8 -3
  71. package/types/completion-outbox/file-store.d.ts +3 -5
  72. package/types/completion-outbox/types.d.ts +2 -0
  73. package/types/extension/herdr-window-provider.d.ts +165 -0
  74. package/types/extension/mailbox/host.d.ts +1 -0
  75. package/types/extension/mailbox/rollout.d.ts +6 -0
  76. package/types/extension/mailbox/router.d.ts +4 -0
  77. package/types/extension/mailbox/service.d.ts +4 -0
  78. package/types/extension/mailbox/types.d.ts +7 -0
  79. package/types/extension/monitor-window-lifecycle.d.ts +96 -0
  80. package/types/extension/monitor-window-state.d.ts +45 -0
  81. package/types/extension/monitor.d.ts +105 -0
  82. package/types/extension/remote-window-monitor.d.ts +52 -0
  83. package/types/extension/remote-window-session.d.ts +12 -0
  84. package/types/extension/runtime-actor-host.d.ts +3 -0
  85. package/types/extension/schemas.d.ts +19 -8
  86. package/types/extension/session-endpoints.d.ts +27 -3
  87. package/types/extension/teammate-helpers.d.ts +2 -0
  88. package/types/extension/tool-error-signal.d.ts +7 -0
  89. package/types/extension/workspace-peers.d.ts +4 -558
  90. package/types/models/model-routing.d.ts +17 -0
  91. package/types/public/v1/index.d.ts +3 -0
  92. package/types/public/v1/monitor-window-state.d.ts +183 -0
  93. package/types/public/v1/session-history.d.ts +2 -0
  94. package/types/public/v1/ssh-hosts.d.ts +24 -0
  95. package/types/public/v2/runtime-broker.d.ts +1 -0
  96. package/types/public/v2/runtime.d.ts +3 -0
  97. package/types/remote/child-security.d.ts +1 -0
  98. package/types/remote/config.d.ts +12 -4
  99. package/types/remote/driver.d.ts +8 -2
  100. package/types/remote/process-tree.d.ts +4 -4
  101. package/types/remote/protocol.d.ts +40 -4
  102. package/types/remote/server.d.ts +9 -1
  103. package/types/remote/ssh-exec.d.ts +3 -0
  104. package/types/remote/ssh.d.ts +9 -1
  105. package/types/remote/types.d.ts +22 -2
  106. package/types/remote/window-protocol.d.ts +127 -0
  107. package/types/remote/window-service.d.ts +21 -0
  108. package/types/runs/execution-infra.d.ts +3 -0
  109. package/types/runtime-broker/client.d.ts +2 -0
  110. package/types/runtime-broker/mailbox-commit.d.ts +2 -1
  111. package/types/runtime-broker/session-commit.d.ts +33 -0
  112. package/types/runtime-v2/session-broker-read-model.d.ts +16 -0
  113. package/types/runtime-v2/session-domain.d.ts +84 -0
  114. package/types/runtime-v2/session-rollout.d.ts +24 -0
  115. package/types/sessions/session-core.d.ts +47 -7
  116. package/types/sessions/workspace-peer-core.d.ts +603 -0
  117. package/types/shared/types.d.ts +4 -2
  118. package/types/transcript/session-history.d.ts +170 -0
  119. package/types/tui/connection-wizards.d.ts +13 -2
  120. package/types/tui/locale-catalog-model.d.ts +36 -0
  121. package/types/tui/model-mapping-overlay.d.ts +2 -0
  122. package/types/tui/remote-config-pane.d.ts +20 -0
  123. package/types/tui/render.d.ts +32 -1
package/README.md CHANGED
@@ -187,7 +187,17 @@ project .pi/agents > project .agents > ~/.agents > legacy user directory > bundl
187
187
 
188
188
  ## Model Routing Profiles
189
189
 
190
- Configure task-type defaults with `Alt+M` or `/teammate-models`. The Control Center's **Profiles** tab manages named routing Profiles shared by every project. Its **Routing** tab edits the active global Profile and combines built-in task types, discovered agent types, and types already present in that Profile.
190
+ Configure task-type defaults with `Alt+M` or `/teammate-models`. The Control Center's **Profiles** tab is the saved-template system: it manages multiple named routing Profiles shared by every project. Its **Routing** tab edits the active global Profile and combines built-in task types, discovered agent types, and types already present in that Profile.
191
+
192
+ Switch the current project's template directly by stable Profile ID or display name. With no argument, the command opens the Control Center on **Profiles**:
193
+
194
+ ```text
195
+ /teammate-model fast-lane
196
+ /teammate-model Fast Lane
197
+ /teammate-model
198
+ ```
199
+
200
+ Command completion lists every saved template and marks the project-active and global-default entries. If display names are duplicated, use the stable ID. Direct switching disables but preserves project overrides, matching the Profiles UI.
191
201
 
192
202
  The **Roles** tab lists discovered roles and their effective durable route. Role-scoped settings can be edited through the unified Settings surface as `role.<role-name>.model`, `role.<role-name>.fallbacks`, and `role.<role-name>.thinking`. These settings are stored in the same global Profile or project override file; package updates never rewrite role definitions or user routing data.
193
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-teammate",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Pi extension — teammate agent dispatch with DAG task graphs, RPC messaging, and compact TUI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -73,6 +73,10 @@
73
73
  "types": "./types/public/v1/model-routing.d.ts",
74
74
  "default": "./src/public/v1/model-routing.ts"
75
75
  },
76
+ "./v1/monitor-window-state": {
77
+ "types": "./types/public/v1/monitor-window-state.d.ts",
78
+ "default": "./src/public/v1/monitor-window-state.ts"
79
+ },
76
80
  "./v1/cli-tools": {
77
81
  "types": "./types/public/v1/cli-tools.d.ts",
78
82
  "default": "./src/public/v1/cli-tools.ts"
@@ -101,6 +105,10 @@
101
105
  "types": "./types/public/v1/retry.d.ts",
102
106
  "default": "./src/public/v1/retry.ts"
103
107
  },
108
+ "./v1/session-history": {
109
+ "types": "./types/public/v1/session-history.d.ts",
110
+ "default": "./src/public/v1/session-history.ts"
111
+ },
104
112
  "./v1/scheduler": {
105
113
  "types": "./types/public/v1/scheduler.d.ts",
106
114
  "default": "./src/public/v1/scheduler.ts"
@@ -113,6 +121,10 @@
113
121
  "types": "./types/public/v1/supervision.d.ts",
114
122
  "default": "./src/public/v1/supervision.ts"
115
123
  },
124
+ "./v1/ssh-hosts": {
125
+ "types": "./types/public/v1/ssh-hosts.d.ts",
126
+ "default": "./src/public/v1/ssh-hosts.ts"
127
+ },
116
128
  "./v1/workspace-projections": {
117
129
  "types": "./types/public/v1/workspace-projections.d.ts",
118
130
  "default": "./src/public/v1/workspace-projections.ts"
@@ -193,11 +205,11 @@
193
205
  "pi-maestro-settings-core"
194
206
  ],
195
207
  "devDependencies": {
196
- "@earendil-works/pi-agent-core": "0.83.0",
208
+ "@earendil-works/pi-agent-core": "0.84.4",
197
209
  "@types/cross-spawn": "6.0.6",
198
- "@earendil-works/pi-ai": "0.83.0",
199
- "@earendil-works/pi-coding-agent": "0.83.0",
200
- "@earendil-works/pi-tui": "0.83.0",
210
+ "@earendil-works/pi-ai": "0.84.4",
211
+ "@earendil-works/pi-coding-agent": "0.84.4",
212
+ "@earendil-works/pi-tui": "0.84.4",
201
213
  "@types/ssh2": "1.15.5",
202
214
  "typebox": "1.3.7"
203
215
  },
@@ -43,6 +43,9 @@ export const ACP_CLI_SETTINGS_CATALOGS: TranslationCatalogs = {
43
43
  "acpCli.mode": "Launch mode",
44
44
  "acpCli.mode.local": "Local process",
45
45
  "acpCli.mode.ssh": "Remote over SSH",
46
+ "acpCli.sshHostRef": "/ssh host reference",
47
+ "acpCli.sshHostRef.description":
48
+ "Host id from the unlocked encrypted /ssh manager. Use it instead of every embedded SSH connection field; the host is resolved again for each run.",
46
49
  "acpCli.host": "SSH host",
47
50
  "acpCli.user": "SSH user",
48
51
  "acpCli.port": "SSH port",
@@ -92,6 +95,9 @@ export const ACP_CLI_SETTINGS_CATALOGS: TranslationCatalogs = {
92
95
  "acpCli.mode": "启动方式",
93
96
  "acpCli.mode.local": "本地进程",
94
97
  "acpCli.mode.ssh": "经 SSH 远程",
98
+ "acpCli.sshHostRef": "/ssh 主机引用",
99
+ "acpCli.sshHostRef.description":
100
+ "已解锁加密 /ssh 管理器中的主机 id。它必须替代全部内嵌 SSH 连接字段,并在每次运行时重新解析。",
95
101
  "acpCli.host": "SSH 主机",
96
102
  "acpCli.user": "SSH 用户",
97
103
  "acpCli.port": "SSH 端口",
@@ -52,6 +52,7 @@ import type {
52
52
  import {
53
53
  cliToolArgv,
54
54
  probeCliToolCommand,
55
+ sshHostConfigIssue,
55
56
  type CliToolConfig,
56
57
  } from "../cli-tools/cli-tools-config.ts";
57
58
  import {
@@ -282,9 +283,18 @@ export const ACP_CLI_CONFIG_FIELDS: readonly BackendConfigField[] = [
282
283
  labelKey: "acpCli.mode",
283
284
  default: "local",
284
285
  },
286
+ {
287
+ key: "sshHostRef",
288
+ kind: "text",
289
+ labelKey: "acpCli.sshHostRef",
290
+ descriptionKey: "acpCli.sshHostRef.description",
291
+ },
285
292
  { key: "host", kind: "text", labelKey: "acpCli.host" },
286
293
  { key: "user", kind: "text", labelKey: "acpCli.user" },
287
- { key: "port", kind: "integer", labelKey: "acpCli.port", default: 22 },
294
+ // Applied only to embedded SSH below. Leaving this declaration without a
295
+ // generic default lets validation distinguish an explicit override from a
296
+ // reference that must take its port exclusively from `/ssh`.
297
+ { key: "port", kind: "integer", labelKey: "acpCli.port" },
288
298
  { key: "hostKeySha256", kind: "text", labelKey: "acpCli.hostKeySha256" },
289
299
  { key: "identityFile", kind: "path", labelKey: "acpCli.identityFile" },
290
300
  {
@@ -420,6 +430,7 @@ async function installedLaunchConfig(
420
430
  function launchConfigOf(config: Record<string, ConfigValue>): CliToolConfig {
421
431
  const command = text(config, "command");
422
432
  const cwd = text(config, "cwd");
433
+ const sshHostRef = text(config, "sshHostRef");
423
434
  const host = text(config, "host");
424
435
  const user = text(config, "user");
425
436
  const port = count(config, "port");
@@ -432,6 +443,7 @@ function launchConfigOf(config: Record<string, ConfigValue>): CliToolConfig {
432
443
  args: list(config, "args"),
433
444
  ...(cwd === undefined ? {} : { cwd }),
434
445
  env: list(config, "env"),
446
+ ...(sshHostRef === undefined ? {} : { sshHostRef }),
435
447
  ...(host === undefined ? {} : { host }),
436
448
  ...(user === undefined ? {} : { user }),
437
449
  ...(port === undefined ? {} : { port }),
@@ -605,14 +617,18 @@ export function createAcpCliBackend(
605
617
  + "parent process forwards by name; a name=value entry puts the value in the registration document",
606
618
  );
607
619
  }
608
- // Checked at load rather than at launch: the runner's ssh path already
609
- // fails closed on an incomplete host, but it does so per run, long after
610
- // the operator stopped looking at the file that caused it.
620
+ // Checked at load rather than at launch: one SSH source must be complete
621
+ // and authoritative. A reference cannot be paired with even a seemingly
622
+ // harmless port override, because that would recreate two sources of
623
+ // truth for the connection.
611
624
  if (mode === "ssh") {
612
- for (const key of ["host", "user", "hostKeySha256"]) {
613
- if ((text(config, key) ?? "").trim().length > 0) continue;
614
- errors.push(`"${key}" is required when "mode" is "ssh"`);
625
+ const issue = sshHostConfigIssue(launchConfigOf(values));
626
+ if (issue !== undefined) errors.push(issue);
627
+ else if ((text(values, "sshHostRef") ?? "").trim().length === 0 && count(values, "port") === undefined) {
628
+ values.port = 22;
615
629
  }
630
+ } else if ((text(values, "sshHostRef") ?? "").trim().length > 0) {
631
+ errors.push('"sshHostRef" requires "mode" to be "ssh"');
616
632
  }
617
633
  return { values, errors };
618
634
  },
@@ -638,7 +654,7 @@ export function createAcpCliBackend(
638
654
  // beats returning the local machine's answer for a remote target.
639
655
  throw new Error(
640
656
  `teammate backend "acp-cli" cannot list "${field}" for an "ssh" registration: `
641
- + "the probe launches the agent locally, so the target host's catalogue is not reachable here",
657
+ + "whether embedded or selected by sshHostRef, the target host's catalogue is not reachable by the local probe",
642
658
  );
643
659
  }
644
660
  const route = routeOf(config);
@@ -6,8 +6,8 @@
6
6
  * models and executed over the Agent Client Protocol:
7
7
  *
8
8
  * - mode "local": the CLI is spawned on this machine (which/where reachability);
9
- * - mode "ssh": the CLI runs on a remote host over a direct ssh2 exec, with the
10
- * ssh connection fields embedded per tool (host/user/port/hostKeySha256/identityFile).
9
+ * - mode "ssh": the CLI runs on a remote host over a direct ssh2 exec, using
10
+ * either an `/ssh` manager reference or embedded connection fields.
11
11
  *
12
12
  * Discovery follows the same convention as teammate-models.json / teammate-remotes.json:
13
13
  * the global file is ~/.pi/agent/teammate-cli-tools.json and the project file is
@@ -22,7 +22,11 @@ import * as os from "node:os";
22
22
  import * as path from "node:path";
23
23
  import type { AvailableModelEntry } from "../models/model-catalog.ts";
24
24
  import type { RemoteHostConfig } from "../remote/types.ts";
25
- import { probeSshCliExecutable } from "../remote/ssh-exec.ts";
25
+ import { resolveSshHostRef } from "../public/v1/ssh-hosts.ts";
26
+ import {
27
+ probeSshCliExecutable,
28
+ remoteHostConfigFromSshProfile,
29
+ } from "../remote/ssh-exec.ts";
26
30
 
27
31
  /** Launch configuration for a teammate-managed CLI tool. */
28
32
  export interface CliToolConfig {
@@ -37,7 +41,8 @@ export interface CliToolConfig {
37
41
  cwd?: string;
38
42
  /** Trusted environment-variable names forwarded from the parent process. */
39
43
  env?: readonly string[];
40
- // SSH connection fields (mode "ssh" only):
44
+ // SSH source (mode "ssh" only): a manager reference OR embedded fields.
45
+ sshHostRef?: string;
41
46
  host?: string;
42
47
  user?: string;
43
48
  port?: number;
@@ -235,24 +240,73 @@ export function cliToolArgv(name: string, config: CliToolConfig): [string, ...st
235
240
  return [cliToolCommand(name, config), ...(config.args ?? [])];
236
241
  }
237
242
 
238
- /** SSH connection fields lifted from a tool config; null if incomplete. */
239
- export function sshHostConfigOf(config: CliToolConfig): RemoteHostConfig | null {
240
- if ((config.mode ?? "local") !== "ssh") return null;
243
+ const SSH_INLINE_FIELDS: readonly (keyof CliToolConfig)[] = [
244
+ "host",
245
+ "user",
246
+ "port",
247
+ "hostKeySha256",
248
+ "identityFile",
249
+ ];
250
+
251
+ function hasInlineSshField(config: CliToolConfig): boolean {
252
+ return SSH_INLINE_FIELDS.some((key) => {
253
+ const value = config[key];
254
+ return typeof value === "number" || (typeof value === "string" && value.trim().length > 0);
255
+ });
256
+ }
257
+
258
+ /** Safe configuration diagnostic for one ssh-mode tool, or undefined when complete. */
259
+ export function sshHostConfigIssue(config: CliToolConfig): string | undefined {
260
+ if ((config.mode ?? "local") !== "ssh") return undefined;
261
+ const rawRef = config.sshHostRef;
262
+ const hostRef = rawRef?.trim() ?? "";
263
+ if (rawRef !== undefined && hostRef.length === 0) {
264
+ return "sshHostRef must be a non-empty /ssh host id";
265
+ }
266
+ if (hostRef && hasInlineSshField(config)) {
267
+ return "sshHostRef cannot be combined with host, user, port, hostKeySha256 or identityFile";
268
+ }
269
+ if (hostRef) return undefined;
270
+
241
271
  const host = config.host?.trim() ?? "";
242
272
  const user = config.user?.trim() ?? "";
243
- const port = config.port ?? 22;
244
273
  const hostKeySha256 = config.hostKeySha256?.trim() ?? "";
245
- if (!host || !user || !hostKeySha256) return null;
246
- if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
274
+ if (!host || !user || !hostKeySha256) {
275
+ return "ssh mode requires sshHostRef or host, user and hostKeySha256";
276
+ }
277
+ const port = config.port ?? 22;
278
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
279
+ return "ssh port must be an integer between 1 and 65535";
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ /** Embedded SSH connection fields lifted from a tool config; null for references or invalid input. */
285
+ export function sshHostConfigOf(config: CliToolConfig): RemoteHostConfig | null {
286
+ if (sshHostConfigIssue(config) !== undefined || (config.sshHostRef?.trim() ?? "")) return null;
287
+ if ((config.mode ?? "local") !== "ssh") return null;
247
288
  return {
248
- host,
249
- user,
250
- port,
251
- hostKeySha256,
289
+ host: config.host!.trim(),
290
+ user: config.user!.trim(),
291
+ port: config.port ?? 22,
292
+ hostKeySha256: config.hostKeySha256!.trim(),
252
293
  ...(config.identityFile?.trim() ? { identityFile: config.identityFile.trim() } : {}),
253
294
  };
254
295
  }
255
296
 
297
+ /** Resolve either host source immediately before a new direct-SSH connection. */
298
+ export async function resolveSshHostConfigOf(config: CliToolConfig): Promise<RemoteHostConfig> {
299
+ const issue = sshHostConfigIssue(config);
300
+ if (issue !== undefined) throw new Error(issue);
301
+ const hostRef = config.sshHostRef?.trim();
302
+ if (hostRef) {
303
+ return remoteHostConfigFromSshProfile(await resolveSshHostRef(hostRef));
304
+ }
305
+ const inline = sshHostConfigOf(config);
306
+ if (!inline) throw new Error("ssh mode requires sshHostRef or complete embedded SSH fields");
307
+ return inline;
308
+ }
309
+
256
310
  export interface CliToolProbeResult {
257
311
  ok: boolean;
258
312
  command: string;
@@ -278,6 +332,10 @@ const sshProbeInflight = new Map<string, Promise<void>>();
278
332
  */
279
333
  function probeCacheKey(command: string, config: CliToolConfig): string {
280
334
  if ((config.mode ?? "local") !== "ssh") return JSON.stringify(["local", command]);
335
+ const hostRef = config.sshHostRef?.trim();
336
+ if (hostRef && sshHostConfigIssue(config) === undefined) {
337
+ return JSON.stringify(["ssh-ref", hostRef, command]);
338
+ }
281
339
  const hostConfig = sshHostConfigOf(config);
282
340
  // An incomplete ssh config has no target to name; the verdict is the same
283
341
  // refusal for every such registration, so they share one entry.
@@ -386,22 +444,20 @@ function probeLocalExecutable(key: string, command: string): CliToolProbeResult
386
444
  }
387
445
 
388
446
  function probeSshTool(key: string, command: string, config: CliToolConfig): CliToolProbeResult {
389
- const hostConfig = sshHostConfigOf(config);
390
- if (!hostConfig) {
391
- const result: CliToolProbeResult = {
392
- ok: false,
393
- command,
394
- error: "ssh mode requires host, user and hostKeySha256 in teammate-cli-tools.json",
395
- };
447
+ const issue = sshHostConfigIssue(config);
448
+ if (issue !== undefined) {
449
+ const result: CliToolProbeResult = { ok: false, command, error: issue };
396
450
  probeCache.set(key, { result, at: Date.now() });
397
451
  return result;
398
452
  }
399
- // Optimistically list a complete configuration; warm the cache asynchronously
400
- // so the next refresh filters unreachable hosts. Deduplicate concurrent probes.
453
+ // Optimistically list a complete source while an actual provider-resolution
454
+ // and remote executable probe warms the cache. This is the same bounded
455
+ // discovery behavior as embedded SSH, not a local catalogue result.
401
456
  const result: CliToolProbeResult = { ok: true, command };
402
457
  probeCache.set(key, { result, at: Date.now() });
403
458
  if (!sshProbeInflight.has(key)) {
404
- const probe = probeSshCliExecutable(hostConfig, command)
459
+ const probe = resolveSshHostConfigOf(config)
460
+ .then((hostConfig) => probeSshCliExecutable(hostConfig, command))
405
461
  .then((outcome) => {
406
462
  probeCache.set(key, {
407
463
  result: outcome.ok
@@ -410,9 +466,13 @@ function probeSshTool(key: string, command: string, config: CliToolConfig): CliT
410
466
  at: Date.now(),
411
467
  });
412
468
  })
413
- .catch(() => {
469
+ .catch((error: unknown) => {
414
470
  probeCache.set(key, {
415
- result: { ok: false, command, error: `remote executable "${command}" probe failed` },
471
+ result: {
472
+ ok: false,
473
+ command,
474
+ error: error instanceof Error ? error.message : `remote executable "${command}" probe failed`,
475
+ },
416
476
  at: Date.now(),
417
477
  });
418
478
  })
@@ -34,7 +34,7 @@ import type {
34
34
  import {
35
35
  cliToolArgv,
36
36
  probeCliToolCommand,
37
- sshHostConfigOf,
37
+ resolveSshHostConfigOf,
38
38
  type CliToolConfig,
39
39
  } from "./cli-tools-config.ts";
40
40
 
@@ -174,11 +174,15 @@ export async function runSshCliTool(
174
174
  params: RunLocalCliToolParams,
175
175
  ): Promise<CliToolRunResult> {
176
176
  const startedAt = Date.now();
177
- const hostConfig = sshHostConfigOf(params.config);
178
- if (!hostConfig) {
177
+ let hostConfig: Awaited<ReturnType<typeof resolveSshHostConfigOf>>;
178
+ try {
179
+ // Resolve for every new run: locking, deleting, or editing an `/ssh` host
180
+ // therefore affects new connections without disturbing an active channel.
181
+ hostConfig = await resolveSshHostConfigOf(params.config);
182
+ } catch (error) {
179
183
  return failedResult(
180
184
  startedAt,
181
- `CLI tool "${params.tool}" ssh mode requires host, user and hostKeySha256 in teammate-cli-tools.json`,
185
+ `CLI tool "${params.tool}" SSH host could not be resolved: ${error instanceof Error ? error.message : "unknown SSH host error"}`,
182
186
  );
183
187
  }
184
188
 
@@ -151,15 +151,10 @@ export class CompletionDeliveryCoordinator {
151
151
  #binding: CompletionSessionBinding | undefined;
152
152
  #unsubscribe: (() => void) | undefined;
153
153
  #disposed = false;
154
- // Throttle store.gc() inside reconcile(): a full workspace GC under the lock
155
- // competes with reserve()/importIntent() writers and is the main source of the
156
- // "Timed out acquiring completion outbox lock" warnings. Expired records are
157
- // inert (deliverDue/acquireClaim already reject expired entries), so skipping
158
- // a sweep between two reconciles is safe; the next reconcile past this gap
159
- // reclaims them. This is the only place store.gc() is called — bindSession()
160
- // relies on this reconcile path for the initial sweep on a fresh binding.
161
- // Seeded lazily on the first reconcile so the first sweep always runs (a zero
162
- // seed under a small mocked clock would otherwise skip the first sweep).
154
+ // Throttle GC attempts inside reconcile(). tryGc() also persists a cross-process
155
+ // page/expiry fence, but this local guard prevents one coordinator from
156
+ // immediately reacquiring the maintenance lock while a backlog remains.
157
+ // Seeded lazily so the first reconcile still attempts one non-blocking page.
163
158
  #lastReconcileGcAt: number | undefined;
164
159
 
165
160
  constructor(options: CompletionCoordinatorOptions = {}) {
@@ -331,12 +326,12 @@ export class CompletionDeliveryCoordinator {
331
326
  // failed" warning. Canonical and legacy roots remain isolated and are
332
327
  // swept independently; no alias root is merged or deleted.
333
328
  if (this.#lastReconcileGcAt === undefined || now - this.#lastReconcileGcAt >= RECONCILE_GC_MIN_INTERVAL_MS) {
334
- let swept = false;
329
+ let attempted = false;
335
330
  for (const target of targets) {
336
331
  const result = await this.store.tryGc(target.workspaceId);
337
- if (!result.busy && !result.skipped && !result.hasMore) swept = true;
332
+ if (!result.busy && !result.skipped) attempted = true;
338
333
  }
339
- if (swept) this.#lastReconcileGcAt = now;
334
+ if (attempted) this.#lastReconcileGcAt = now;
340
335
  }
341
336
  const providers = new Set<CompletionDurabilityProvider>();
342
337
  const currentProvider = this.registry.current();