pi-maestro-teammate 2.1.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 (141) hide show
  1. package/README.md +11 -1
  2. package/bin/pi-teammate-outbox.mjs +12 -0
  3. package/package.json +19 -6
  4. package/src/backends/acp-cli-catalog.ts +6 -0
  5. package/src/backends/acp-cli.ts +25 -8
  6. package/src/cli-tools/cli-tools-config.ts +117 -40
  7. package/src/cli-tools/local-acp.ts +8 -4
  8. package/src/completion-outbox/cli.ts +123 -0
  9. package/src/completion-outbox/coordinator.ts +7 -12
  10. package/src/completion-outbox/file-store.ts +635 -169
  11. package/src/completion-outbox/types.ts +2 -0
  12. package/src/extension/herdr-window-provider.ts +794 -0
  13. package/src/extension/index.ts +1473 -337
  14. package/src/extension/mailbox/file-store.ts +29 -17
  15. package/src/extension/mailbox/host.ts +29 -10
  16. package/src/extension/mailbox/rollout.ts +15 -5
  17. package/src/extension/mailbox/router.ts +79 -33
  18. package/src/extension/mailbox/service.ts +4 -0
  19. package/src/extension/mailbox/types.ts +11 -2
  20. package/src/extension/monitor-window-lifecycle.ts +278 -0
  21. package/src/extension/monitor-window-state.ts +514 -0
  22. package/src/extension/monitor.ts +616 -15
  23. package/src/extension/remote-window-monitor.ts +357 -0
  24. package/src/extension/remote-window-session.ts +50 -0
  25. package/src/extension/runtime-actor-host.ts +113 -2
  26. package/src/extension/schemas.ts +125 -10
  27. package/src/extension/session-endpoints.ts +168 -20
  28. package/src/extension/task-delegation.ts +2 -2
  29. package/src/extension/teammate-core.ts +14 -6
  30. package/src/extension/teammate-helpers.ts +19 -6
  31. package/src/extension/teammate-proxy.ts +40 -3
  32. package/src/extension/tool-error-signal.ts +42 -0
  33. package/src/extension/workspace-peers.ts +8 -2848
  34. package/src/extension/workspace-turns-observation.ts +8 -5
  35. package/src/models/cli-add.ts +8 -2
  36. package/src/models/model-routing.ts +92 -0
  37. package/src/public/v1/index.ts +3 -0
  38. package/src/public/v1/model-routing.ts +6 -0
  39. package/src/public/v1/monitor-window-state.ts +525 -0
  40. package/src/public/v1/session-history.ts +2 -0
  41. package/src/public/v1/ssh-hosts.ts +209 -0
  42. package/src/public/v2/runtime-broker.ts +6 -0
  43. package/src/public/v2/runtime.ts +3 -0
  44. package/src/remote/child-security.ts +30 -4
  45. package/src/remote/cli.ts +3 -1
  46. package/src/remote/config.ts +131 -17
  47. package/src/remote/driver.ts +15 -0
  48. package/src/remote/process-tree.ts +12 -28
  49. package/src/remote/protocol.ts +164 -1
  50. package/src/remote/server.ts +93 -1
  51. package/src/remote/ssh-exec.ts +17 -1
  52. package/src/remote/ssh.ts +162 -17
  53. package/src/remote/types.ts +29 -2
  54. package/src/remote/window-protocol.ts +501 -0
  55. package/src/remote/window-service.ts +566 -0
  56. package/src/runs/execution-infra.ts +110 -11
  57. package/src/runs/execution.ts +17 -12
  58. package/src/runs/pi-subprocess-attempt.ts +2960 -2755
  59. package/src/runtime-broker/client.ts +63 -21
  60. package/src/runtime-broker/mailbox-commit.ts +4 -3
  61. package/src/runtime-broker/session-commit.ts +121 -0
  62. package/src/runtime-broker/sqlite-store.ts +52 -2
  63. package/src/runtime-broker/transport.ts +2 -1
  64. package/src/runtime-v2/session-broker-read-model.ts +101 -0
  65. package/src/runtime-v2/session-domain.ts +397 -0
  66. package/src/runtime-v2/session-rollout.ts +90 -0
  67. package/src/sessions/session-core.ts +155 -22
  68. package/src/sessions/workspace-peer-core.ts +2993 -0
  69. package/src/shared/agent-status.ts +4 -1
  70. package/src/shared/turn-ledger.ts +30 -6
  71. package/src/shared/types.ts +7 -4
  72. package/src/transcript/session-history.ts +758 -0
  73. package/src/tui/attach-overlay.ts +23 -14
  74. package/src/tui/connection-wizards.ts +177 -16
  75. package/src/tui/locale-catalog-model.ts +36 -0
  76. package/src/tui/model-mapping-overlay.ts +60 -3
  77. package/src/tui/remote-config-pane.ts +114 -16
  78. package/src/tui/render.ts +504 -25
  79. package/src/tui/session-send-overlay.ts +6 -1
  80. package/types/backends/acp-cli.d.ts +1 -0
  81. package/types/cli-tools/cli-tools-config.d.ts +8 -3
  82. package/types/completion-outbox/cli.d.ts +1 -0
  83. package/types/completion-outbox/file-store.d.ts +21 -5
  84. package/types/completion-outbox/types.d.ts +2 -0
  85. package/types/extension/herdr-window-provider.d.ts +165 -0
  86. package/types/extension/mailbox/host.d.ts +2 -1
  87. package/types/extension/mailbox/rollout.d.ts +9 -3
  88. package/types/extension/mailbox/router.d.ts +4 -0
  89. package/types/extension/mailbox/service.d.ts +4 -0
  90. package/types/extension/mailbox/types.d.ts +9 -2
  91. package/types/extension/monitor-window-lifecycle.d.ts +96 -0
  92. package/types/extension/monitor-window-state.d.ts +45 -0
  93. package/types/extension/monitor.d.ts +105 -0
  94. package/types/extension/remote-window-monitor.d.ts +52 -0
  95. package/types/extension/remote-window-session.d.ts +12 -0
  96. package/types/extension/runtime-actor-host.d.ts +3 -0
  97. package/types/extension/schemas.d.ts +24 -12
  98. package/types/extension/session-endpoints.d.ts +27 -3
  99. package/types/extension/teammate-core.d.ts +2 -2
  100. package/types/extension/teammate-helpers.d.ts +2 -0
  101. package/types/extension/teammate-proxy.d.ts +3 -3
  102. package/types/extension/tool-error-signal.d.ts +7 -0
  103. package/types/extension/workspace-peers.d.ts +4 -558
  104. package/types/models/model-routing.d.ts +17 -0
  105. package/types/public/v1/index.d.ts +3 -0
  106. package/types/public/v1/model-routing.d.ts +1 -0
  107. package/types/public/v1/monitor-window-state.d.ts +183 -0
  108. package/types/public/v1/session-history.d.ts +2 -0
  109. package/types/public/v1/ssh-hosts.d.ts +24 -0
  110. package/types/public/v2/runtime-broker.d.ts +1 -0
  111. package/types/public/v2/runtime.d.ts +3 -0
  112. package/types/remote/child-security.d.ts +1 -0
  113. package/types/remote/config.d.ts +12 -4
  114. package/types/remote/driver.d.ts +8 -2
  115. package/types/remote/process-tree.d.ts +4 -4
  116. package/types/remote/protocol.d.ts +40 -4
  117. package/types/remote/server.d.ts +9 -1
  118. package/types/remote/ssh-exec.d.ts +3 -0
  119. package/types/remote/ssh.d.ts +9 -1
  120. package/types/remote/types.d.ts +22 -2
  121. package/types/remote/window-protocol.d.ts +127 -0
  122. package/types/remote/window-service.d.ts +21 -0
  123. package/types/runs/execution-infra.d.ts +20 -0
  124. package/types/runs/pi-subprocess-attempt.d.ts +1 -1
  125. package/types/runtime-broker/client.d.ts +2 -0
  126. package/types/runtime-broker/mailbox-commit.d.ts +2 -1
  127. package/types/runtime-broker/session-commit.d.ts +33 -0
  128. package/types/runtime-broker/transport.d.ts +2 -2
  129. package/types/runtime-v2/session-broker-read-model.d.ts +16 -0
  130. package/types/runtime-v2/session-domain.d.ts +84 -0
  131. package/types/runtime-v2/session-rollout.d.ts +24 -0
  132. package/types/sessions/session-core.d.ts +48 -8
  133. package/types/sessions/workspace-peer-core.d.ts +603 -0
  134. package/types/shared/types.d.ts +5 -3
  135. package/types/transcript/session-history.d.ts +170 -0
  136. package/types/tui/attach-overlay.d.ts +2 -0
  137. package/types/tui/connection-wizards.d.ts +13 -2
  138. package/types/tui/locale-catalog-model.d.ts +36 -0
  139. package/types/tui/model-mapping-overlay.d.ts +2 -0
  140. package/types/tui/remote-config-pane.d.ts +20 -0
  141. 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
 
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { createJiti } from "jiti";
3
+
4
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
5
+ const { main } = await jiti.import("../src/completion-outbox/cli.ts");
6
+
7
+ try {
8
+ process.exitCode = await main(process.argv.slice(2));
9
+ } catch (error) {
10
+ process.stderr.write(`pi-teammate-outbox: ${error instanceof Error ? error.message : String(error)}\n`);
11
+ process.exitCode = 1;
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-teammate",
3
- "version": "2.1.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": {
@@ -9,6 +9,7 @@
9
9
  "bin": {
10
10
  "pi-teammate-broker": "./bin/pi-teammate-broker.mjs",
11
11
  "pi-teammate-models": "./bin/pi-teammate-models.mjs",
12
+ "pi-teammate-outbox": "./bin/pi-teammate-outbox.mjs",
12
13
  "pi-teammate-remote": "./bin/pi-teammate-remote.mjs"
13
14
  },
14
15
  "keywords": [
@@ -19,7 +20,7 @@
19
20
  "agents"
20
21
  ],
21
22
  "scripts": {
22
- "test": "node --experimental-transform-types --import ./test/setup.ts --test --test-concurrency=4 \"test/*.test.ts\"",
23
+ "test": "node --experimental-transform-types --import ./test/setup.ts --test --test-concurrency=2 \"test/*.test.ts\"",
23
24
  "typecheck": "tsc -p tsconfig.build.json",
24
25
  "build:declarations": "tsc -p tsconfig.declarations.json",
25
26
  "check:declarations": "node scripts/check-declarations.mjs",
@@ -72,6 +73,10 @@
72
73
  "types": "./types/public/v1/model-routing.d.ts",
73
74
  "default": "./src/public/v1/model-routing.ts"
74
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
+ },
75
80
  "./v1/cli-tools": {
76
81
  "types": "./types/public/v1/cli-tools.d.ts",
77
82
  "default": "./src/public/v1/cli-tools.ts"
@@ -100,6 +105,10 @@
100
105
  "types": "./types/public/v1/retry.d.ts",
101
106
  "default": "./src/public/v1/retry.ts"
102
107
  },
108
+ "./v1/session-history": {
109
+ "types": "./types/public/v1/session-history.d.ts",
110
+ "default": "./src/public/v1/session-history.ts"
111
+ },
103
112
  "./v1/scheduler": {
104
113
  "types": "./types/public/v1/scheduler.d.ts",
105
114
  "default": "./src/public/v1/scheduler.ts"
@@ -112,6 +121,10 @@
112
121
  "types": "./types/public/v1/supervision.d.ts",
113
122
  "default": "./src/public/v1/supervision.ts"
114
123
  },
124
+ "./v1/ssh-hosts": {
125
+ "types": "./types/public/v1/ssh-hosts.d.ts",
126
+ "default": "./src/public/v1/ssh-hosts.ts"
127
+ },
115
128
  "./v1/workspace-projections": {
116
129
  "types": "./types/public/v1/workspace-projections.d.ts",
117
130
  "default": "./src/public/v1/workspace-projections.ts"
@@ -192,11 +205,11 @@
192
205
  "pi-maestro-settings-core"
193
206
  ],
194
207
  "devDependencies": {
195
- "@earendil-works/pi-agent-core": "0.83.0",
208
+ "@earendil-works/pi-agent-core": "0.84.4",
196
209
  "@types/cross-spawn": "6.0.6",
197
- "@earendil-works/pi-ai": "0.83.0",
198
- "@earendil-works/pi-coding-agent": "0.83.0",
199
- "@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",
200
213
  "@types/ssh2": "1.15.5",
201
214
  "typebox": "1.3.7"
202
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);
@@ -796,6 +812,7 @@ export function createAcpCliBackend(
796
812
  * registration ship fields whose keys have no text.
797
813
  */
798
814
  export { ACP_CLI_SETTINGS_CATALOGS } from "./acp-cli-catalog.ts";
815
+ export { probeSshCliExecutable } from "../remote/ssh-exec.ts";
799
816
 
800
817
  /**
801
818
  * The registered instance.
@@ -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
@@ -17,13 +17,16 @@
17
17
  * ~/.maestro/cli-tools.json for pi-maestro-flow's original provider registration.
18
18
  */
19
19
 
20
- import { execFileSync } from "node:child_process";
21
20
  import * as fs from "node:fs";
22
21
  import * as os from "node:os";
23
22
  import * as path from "node:path";
24
23
  import type { AvailableModelEntry } from "../models/model-catalog.ts";
25
24
  import type { RemoteHostConfig } from "../remote/types.ts";
26
- 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";
27
30
 
28
31
  /** Launch configuration for a teammate-managed CLI tool. */
29
32
  export interface CliToolConfig {
@@ -38,7 +41,8 @@ export interface CliToolConfig {
38
41
  cwd?: string;
39
42
  /** Trusted environment-variable names forwarded from the parent process. */
40
43
  env?: readonly string[];
41
- // SSH connection fields (mode "ssh" only):
44
+ // SSH source (mode "ssh" only): a manager reference OR embedded fields.
45
+ sshHostRef?: string;
42
46
  host?: string;
43
47
  user?: string;
44
48
  port?: number;
@@ -82,7 +86,6 @@ export interface MaestroDelegateConfig {
82
86
 
83
87
  const GLOBAL_CONFIG_FILE = "teammate-cli-tools.json";
84
88
  const LEGACY_CONFIG_PATH = path.join(os.homedir(), ".maestro", "cli-tools.json");
85
- const PROBE_TIMEOUT_MS = 2_000;
86
89
  const PROBE_CACHE_TTL_MS = 30_000;
87
90
 
88
91
  export function getGlobalCliToolsConfigPath(): string {
@@ -237,24 +240,73 @@ export function cliToolArgv(name: string, config: CliToolConfig): [string, ...st
237
240
  return [cliToolCommand(name, config), ...(config.args ?? [])];
238
241
  }
239
242
 
240
- /** SSH connection fields lifted from a tool config; null if incomplete. */
241
- export function sshHostConfigOf(config: CliToolConfig): RemoteHostConfig | null {
242
- 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
+
243
271
  const host = config.host?.trim() ?? "";
244
272
  const user = config.user?.trim() ?? "";
245
- const port = config.port ?? 22;
246
273
  const hostKeySha256 = config.hostKeySha256?.trim() ?? "";
247
- if (!host || !user || !hostKeySha256) return null;
248
- 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;
249
288
  return {
250
- host,
251
- user,
252
- port,
253
- hostKeySha256,
289
+ host: config.host!.trim(),
290
+ user: config.user!.trim(),
291
+ port: config.port ?? 22,
292
+ hostKeySha256: config.hostKeySha256!.trim(),
254
293
  ...(config.identityFile?.trim() ? { identityFile: config.identityFile.trim() } : {}),
255
294
  };
256
295
  }
257
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
+
258
310
  export interface CliToolProbeResult {
259
311
  ok: boolean;
260
312
  command: string;
@@ -280,6 +332,10 @@ const sshProbeInflight = new Map<string, Promise<void>>();
280
332
  */
281
333
  function probeCacheKey(command: string, config: CliToolConfig): string {
282
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
+ }
283
339
  const hostConfig = sshHostConfigOf(config);
284
340
  // An incomplete ssh config has no target to name; the verdict is the same
285
341
  // refusal for every such registration, so they share one entry.
@@ -326,6 +382,34 @@ function adapterInstallHint(command: string): string {
326
382
  return pkg === undefined ? "" : `; install it with: npm i -g ${pkg}`;
327
383
  }
328
384
 
385
+ function localExecutableCandidates(command: string): string[] {
386
+ const hasPathSeparator = command.includes("/") || command.includes("\\");
387
+ const directories = hasPathSeparator
388
+ ? [process.cwd()]
389
+ : (process.env.PATH ?? "").split(path.delimiter).map((entry) => {
390
+ const trimmed = entry.trim();
391
+ return trimmed.replace(/^"|"$/g, "") || process.cwd();
392
+ });
393
+ const extensions = process.platform === "win32" && path.extname(command) === ""
394
+ ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";")]
395
+ : [""];
396
+ return [...new Set(directories.flatMap((directory) =>
397
+ extensions.map((extension) => path.resolve(directory, `${command}${extension.toLowerCase()}`)),
398
+ ))];
399
+ }
400
+
401
+ function localExecutableExists(command: string): boolean {
402
+ return localExecutableCandidates(command).some((candidate) => {
403
+ try {
404
+ if (!fs.statSync(candidate).isFile()) return false;
405
+ fs.accessSync(candidate, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
406
+ return true;
407
+ } catch {
408
+ return false;
409
+ }
410
+ });
411
+ }
412
+
329
413
  function probeLocalExecutable(key: string, command: string): CliToolProbeResult {
330
414
  const quotedCommand = JSON.stringify(command);
331
415
  let result: CliToolProbeResult;
@@ -351,40 +435,29 @@ function probeLocalExecutable(key: string, command: string): CliToolProbeResult
351
435
  };
352
436
  }
353
437
  } else {
354
- try {
355
- const lookup = process.platform === "win32" ? "where" : "which";
356
- execFileSync(lookup, [command], {
357
- encoding: "utf8",
358
- timeout: PROBE_TIMEOUT_MS,
359
- stdio: ["ignore", "ignore", "pipe"],
360
- windowsHide: true,
361
- });
362
- result = { ok: true, command };
363
- } catch {
364
- result = { ok: false, command, error: `executable ${quotedCommand} not found on PATH${adapterInstallHint(command)}` };
365
- }
438
+ result = localExecutableExists(command)
439
+ ? { ok: true, command }
440
+ : { ok: false, command, error: `executable ${quotedCommand} not found on PATH${adapterInstallHint(command)}` };
366
441
  }
367
442
  probeCache.set(key, { result, at: Date.now() });
368
443
  return result;
369
444
  }
370
445
 
371
446
  function probeSshTool(key: string, command: string, config: CliToolConfig): CliToolProbeResult {
372
- const hostConfig = sshHostConfigOf(config);
373
- if (!hostConfig) {
374
- const result: CliToolProbeResult = {
375
- ok: false,
376
- command,
377
- error: "ssh mode requires host, user and hostKeySha256 in teammate-cli-tools.json",
378
- };
447
+ const issue = sshHostConfigIssue(config);
448
+ if (issue !== undefined) {
449
+ const result: CliToolProbeResult = { ok: false, command, error: issue };
379
450
  probeCache.set(key, { result, at: Date.now() });
380
451
  return result;
381
452
  }
382
- // Optimistically list a complete configuration; warm the cache asynchronously
383
- // 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.
384
456
  const result: CliToolProbeResult = { ok: true, command };
385
457
  probeCache.set(key, { result, at: Date.now() });
386
458
  if (!sshProbeInflight.has(key)) {
387
- const probe = probeSshCliExecutable(hostConfig, command)
459
+ const probe = resolveSshHostConfigOf(config)
460
+ .then((hostConfig) => probeSshCliExecutable(hostConfig, command))
388
461
  .then((outcome) => {
389
462
  probeCache.set(key, {
390
463
  result: outcome.ok
@@ -393,9 +466,13 @@ function probeSshTool(key: string, command: string, config: CliToolConfig): CliT
393
466
  at: Date.now(),
394
467
  });
395
468
  })
396
- .catch(() => {
469
+ .catch((error: unknown) => {
397
470
  probeCache.set(key, {
398
- 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
+ },
399
476
  at: Date.now(),
400
477
  });
401
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
 
@@ -0,0 +1,123 @@
1
+ import * as path from "node:path";
2
+ import { CompletionOutboxFileStore } from "./file-store.ts";
3
+ import { getRuntimeWorkspaceIdentity } from "../runtime-broker/private-state.ts";
4
+
5
+ interface ParsedOptions {
6
+ command?: "cleanup";
7
+ workspace: string;
8
+ rootDir?: string;
9
+ apply: boolean;
10
+ json: boolean;
11
+ maxEntries?: number;
12
+ help: boolean;
13
+ }
14
+
15
+ function usage(): string {
16
+ return [
17
+ "Usage: pi-teammate-outbox cleanup [options]",
18
+ "",
19
+ "Safely removes obsolete atomic-replacement remnants while preserving the",
20
+ "latest recoverable transaction for every canonical outbox file.",
21
+ "",
22
+ "Options:",
23
+ " --workspace <path> Workspace whose outbox is cleaned (default: cwd)",
24
+ " --root <path> Completion outbox root directory",
25
+ " --apply Delete candidates; without this flag cleanup is dry-run",
26
+ " --max-entries <n> Refuse scans larger than n entries (default: 100000)",
27
+ " --json Emit one JSON result",
28
+ " --help Show this help",
29
+ "",
30
+ "Exit codes: 0 success, 2 usage error, 3 workspace outbox is busy.",
31
+ ].join("\n");
32
+ }
33
+
34
+ function parsePositiveInteger(value: string | undefined, option: string): number {
35
+ if (!value || !/^\d+$/.test(value)) throw new Error(`${option} requires a positive integer`);
36
+ const parsed = Number(value);
37
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1_000_000) {
38
+ throw new Error(`${option} must be between 1 and 1000000`);
39
+ }
40
+ return parsed;
41
+ }
42
+
43
+ function parseOptions(args: readonly string[]): ParsedOptions {
44
+ const first = args[0];
45
+ const command = first === "cleanup" ? first : undefined;
46
+ const help = args.includes("--help") || args.includes("-h");
47
+ const options: ParsedOptions = {
48
+ command,
49
+ workspace: process.cwd(),
50
+ apply: false,
51
+ json: false,
52
+ help,
53
+ };
54
+ for (let index = command ? 1 : (help ? 1 : 0); index < args.length; index += 1) {
55
+ const argument = args[index];
56
+ if (argument === "--help" || argument === "-h") continue;
57
+ if (argument === "--apply") {
58
+ options.apply = true;
59
+ continue;
60
+ }
61
+ if (argument === "--json") {
62
+ options.json = true;
63
+ continue;
64
+ }
65
+ const value = args[++index];
66
+ if (argument === "--workspace") {
67
+ if (!value) throw new Error("--workspace requires a path");
68
+ options.workspace = path.resolve(value);
69
+ } else if (argument === "--root") {
70
+ if (!value) throw new Error("--root requires a path");
71
+ options.rootDir = path.resolve(value);
72
+ } else if (argument === "--max-entries") {
73
+ options.maxEntries = parsePositiveInteger(value, argument);
74
+ } else {
75
+ throw new Error(`Unknown option: ${argument}`);
76
+ }
77
+ }
78
+ return options;
79
+ }
80
+
81
+ export async function main(args = process.argv.slice(2)): Promise<number> {
82
+ const options = parseOptions(args);
83
+ if (options.help || !options.command) {
84
+ process.stdout.write(`${usage()}\n`);
85
+ return options.help ? 0 : 2;
86
+ }
87
+
88
+ const identity = getRuntimeWorkspaceIdentity(options.workspace);
89
+ const store = new CompletionOutboxFileStore({
90
+ ...(options.rootDir === undefined ? {} : { rootDir: options.rootDir }),
91
+ });
92
+ const result = await store.cleanupRemnants(identity.workspaceId, {
93
+ apply: options.apply,
94
+ ...(options.maxEntries === undefined ? {} : { maxEntries: options.maxEntries }),
95
+ });
96
+ const output = {
97
+ workspace: identity.canonicalPath,
98
+ workspaceId: identity.workspaceId,
99
+ rootDir: store.rootDir,
100
+ ...result,
101
+ };
102
+
103
+ if (options.json) {
104
+ process.stdout.write(`${JSON.stringify(output)}\n`);
105
+ } else if (result.busy) {
106
+ process.stderr.write(`Completion outbox is busy for ${identity.canonicalPath}; nothing was removed.\n`);
107
+ } else {
108
+ process.stdout.write([
109
+ `Completion outbox cleanup: ${result.apply ? "applied" : "dry-run"}`,
110
+ `Workspace: ${identity.canonicalPath}`,
111
+ `Scanned: ${result.scannedEntries} entries (${result.replacementFiles} replacement remnants)`,
112
+ `Preserved: ${result.preservedFiles}`,
113
+ `Candidates: ${result.candidateFiles} files, ${result.candidateBytes} bytes`,
114
+ `Removed: ${result.removedFiles} files, ${result.removedBytes} bytes`,
115
+ ...(result.candidateSample.length === 0 ? [] : [
116
+ "Candidate sample:",
117
+ ...result.candidateSample.map((candidate) => ` ${candidate}`),
118
+ ]),
119
+ ...(!result.apply && result.candidateFiles > 0 ? ["Re-run with --apply to remove these candidates."] : []),
120
+ ].join("\n") + "\n");
121
+ }
122
+ return result.busy ? 3 : 0;
123
+ }
@@ -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();