@kontextmind/kxm 0.7.0 → 0.7.10

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 (94) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/writer.yaml +2 -0
  3. package/docs/README.md +3 -0
  4. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  5. package/docs/agent-skills.md +19 -2
  6. package/docs/browser-automation.md +116 -0
  7. package/docs/configuration.md +10 -1
  8. package/docs/getting-started.md +21 -0
  9. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  10. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  11. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  12. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  13. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  14. package/docs/kb/how-to-take-over-session.md +32 -0
  15. package/docs/kb/why-authentication-disappeared.md +32 -0
  16. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  17. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  18. package/docs/operations.md +24 -0
  19. package/docs/prompts/browser-annotate-feedback.md +41 -0
  20. package/docs/prompts/browser-diagnose-recover.md +38 -0
  21. package/docs/prompts/browser-explore.md +42 -0
  22. package/docs/prompts/browser-repro-fix.md +48 -0
  23. package/docs/prompts/browser-start.md +41 -0
  24. package/docs/prompts/browser-takeover.md +50 -0
  25. package/docs/skills/repo-work-delivery.md +5 -0
  26. package/docs/skills.md +2 -0
  27. package/docs/troubleshooting.md +22 -1
  28. package/package.json +1 -1
  29. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  30. package/plugins/kxm/dist/cli.js +41203 -38364
  31. package/plugins/kxm/dist/core.js +57 -0
  32. package/plugins/kxm/dist/extension.js +40 -3
  33. package/plugins/kxm/dist/mcp-server.js +1 -1
  34. package/plugins/kxm/dist/runtime.js +1582 -81
  35. package/plugins/kxm/dist/server.js +129 -4
  36. package/plugins/kxm/dist/vnext-runtime-supervisor.js +221 -41
  37. package/plugins/kxm/package.json +1 -1
  38. package/plugins/kxm/skills/hints.json +30 -0
  39. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  40. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  41. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  42. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  43. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  44. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  45. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  46. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
  47. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
  48. package/plugins/kxm/src/autocomplete.ts +1 -1
  49. package/plugins/kxm/src/browser.ts +603 -0
  50. package/plugins/kxm/src/cli/context-skills.ts +373 -0
  51. package/plugins/kxm/src/cli/hub.ts +614 -0
  52. package/plugins/kxm/src/cli/roles.ts +615 -0
  53. package/plugins/kxm/src/cli/system.ts +906 -0
  54. package/plugins/kxm/src/cli/tasks.ts +364 -0
  55. package/plugins/kxm/src/cli/types.ts +270 -0
  56. package/plugins/kxm/src/cli/vnext.ts +698 -0
  57. package/plugins/kxm/src/cli/workflows.ts +699 -0
  58. package/plugins/kxm/src/cli.ts +238 -3791
  59. package/plugins/kxm/src/completion-install.ts +223 -0
  60. package/plugins/kxm/src/database.ts +1 -1
  61. package/plugins/kxm/src/external-effects.ts +1 -1
  62. package/plugins/kxm/src/hub-env.ts +193 -0
  63. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  64. package/plugins/kxm/src/local-snapshot.ts +1 -1
  65. package/plugins/kxm/src/mcp-server.ts +1 -1
  66. package/plugins/kxm/src/model-inventory.ts +8 -8
  67. package/plugins/kxm/src/modes.ts +348 -0
  68. package/plugins/kxm/src/protocol.ts +111 -0
  69. package/plugins/kxm/src/role.ts +335 -0
  70. package/plugins/kxm/src/runtime.ts +4 -0
  71. package/plugins/kxm/src/safety-integrity.ts +76 -0
  72. package/plugins/kxm/src/sqlite.ts +76 -0
  73. package/plugins/kxm/src/ssh-remote.ts +560 -0
  74. package/plugins/kxm/src/store.ts +1 -1
  75. package/plugins/kxm/src/subagent-control.ts +312 -0
  76. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  77. package/plugins/kxm/src/vnext-config.ts +38 -1
  78. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  79. package/plugins/kxm/src/vnext-engine.ts +16 -0
  80. package/plugins/kxm/src/vnext-harness.ts +92 -22
  81. package/plugins/kxm/src/vnext-oneshot-evidence.ts +39 -7
  82. package/plugins/kxm/src/vnext-oneshot-process.ts +46 -8
  83. package/plugins/kxm/src/vnext-oneshot-producer.ts +22 -1
  84. package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
  85. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  86. package/plugins/kxm/src/vnext-runtime-supervisor.ts +5 -3
  87. package/plugins/kxm/src/workflow-tui.ts +1 -1
  88. package/plugins/kxm/src/workflow.ts +144 -0
  89. package/schemas/vnext/modes.schema.json +56 -0
  90. package/scripts/kxm-bump-version.mjs +146 -0
  91. package/scripts/kxm-hub.mjs +145 -3
  92. package/scripts/kxm-publish-npm.mjs +3 -1
  93. package/scripts/kxm-release-github.mjs +3 -1
  94. package/scripts/kxm.mjs +0 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Supervisory agent control, in-process real-time steering, and work-splitting.
3
+ *
4
+ * Implements:
5
+ * - Dual-tool control plane: `agent` (spawn) and `agent_control` (manage, poll, steer, stop).
6
+ * - Strict capability narrowing via `allowed_tools[]` allowlists.
7
+ * - Mid-flight steering instruction injection.
8
+ * - Subagent state lifecycle management (working, blocked, idle, done, error).
9
+ */
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import { assertCommandSeatbelt } from "./safety-integrity.ts";
13
+
14
+ export type SubagentType = "general" | "Explore" | "Plan" | "Reviewer" | "Auditor";
15
+ export type SubagentStatus = "pending" | "running" | "blocked" | "completed" | "stopped" | "failed";
16
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
17
+
18
+ export interface AgentSpawnParams {
19
+ prompt: string;
20
+ description: string;
21
+ type?: SubagentType | undefined;
22
+ model?: string | undefined;
23
+ thinking?: ThinkingLevel | undefined;
24
+ allowed_tools?: string[] | undefined;
25
+ turns?: number | undefined;
26
+ background?: boolean | undefined;
27
+ parent_tools?: readonly string[] | undefined;
28
+ }
29
+
30
+ export interface AgentControlParams {
31
+ action: "info" | "result" | "steer" | "stop";
32
+ kind?: "types" | "models" | "active" | undefined;
33
+ agent_id?: string | undefined;
34
+ message?: string | undefined;
35
+ turns?: number | undefined;
36
+ verbose?: boolean | undefined;
37
+ }
38
+
39
+ export interface SubagentRecord {
40
+ id: string;
41
+ description: string;
42
+ type: SubagentType;
43
+ model: string;
44
+ thinking: ThinkingLevel;
45
+ allowedTools: string[];
46
+ status: SubagentStatus;
47
+ turnsMax: number;
48
+ turnsCompleted: number;
49
+ prompt: string;
50
+ createdAt: string;
51
+ updatedAt: string;
52
+ steeringMessages: Array<{ text: string; injectedAt: string; processed: boolean }>;
53
+ output?: string | undefined;
54
+ error?: string | undefined;
55
+ }
56
+
57
+ export interface SubagentExecutionReceipt {
58
+ ok: boolean;
59
+ action: string;
60
+ agent_id?: string | undefined;
61
+ status?: SubagentStatus | undefined;
62
+ activeAgents?: Array<{ id: string; description: string; type: SubagentType; status: SubagentStatus }> | undefined;
63
+ types?: readonly SubagentType[] | undefined;
64
+ result?: string | undefined;
65
+ steered?: boolean | undefined;
66
+ stopped?: boolean | undefined;
67
+ error?: string | undefined;
68
+ }
69
+
70
+ export const SUBAGENT_TYPES: readonly SubagentType[] = Object.freeze([
71
+ "general",
72
+ "Explore",
73
+ "Plan",
74
+ "Reviewer",
75
+ "Auditor",
76
+ ]);
77
+
78
+ export const DEFAULT_SUBAGENT_MODELS: Readonly<Record<SubagentType, string>> = Object.freeze({
79
+ general: "grok/grok-4.6",
80
+ Explore: "openrouter/qwen/qwen3-coder-plus",
81
+ Plan: "claude/fable",
82
+ Reviewer: "openai/gpt-5.6-sol",
83
+ Auditor: "claude/fable",
84
+ });
85
+
86
+ /**
87
+ * Validates and intersects child allowed tools against the parent tool surface.
88
+ * Enforces the safety invariant that a child can only narrow, never expand authority.
89
+ */
90
+ export function narrowSubagentTools(
91
+ requestedTools: readonly string[] | undefined,
92
+ parentTools?: readonly string[] | undefined,
93
+ ): string[] {
94
+ if (!requestedTools || requestedTools.length === 0) {
95
+ return parentTools ? [...parentTools] : ["read", "grep", "find"];
96
+ }
97
+
98
+ if (!parentTools || parentTools.length === 0) {
99
+ return [...requestedTools];
100
+ }
101
+
102
+ const parentSet = new Set(parentTools);
103
+ const narrowed: string[] = [];
104
+
105
+ for (const tool of requestedTools) {
106
+ if (parentSet.has(tool)) {
107
+ narrowed.push(tool);
108
+ }
109
+ }
110
+
111
+ if (narrowed.length === 0 && requestedTools.length > 0) {
112
+ throw new Error(
113
+ `Cannot spawn subagent: requested tools [${requestedTools.join(", ")}] expand beyond parent capabilities.`,
114
+ );
115
+ }
116
+
117
+ return narrowed;
118
+ }
119
+
120
+ /**
121
+ * In-Process Subagent Manager.
122
+ */
123
+ export class SubagentManager {
124
+ private readonly subagents = new Map<string, SubagentRecord>();
125
+
126
+ /**
127
+ * Spawns a new managed subagent with strict capability boundaries.
128
+ */
129
+ spawn(params: AgentSpawnParams): SubagentRecord {
130
+ if (!params.prompt || !params.prompt.trim()) {
131
+ throw new Error('Subagent "prompt" is required.');
132
+ }
133
+ if (!params.description || !params.description.trim()) {
134
+ throw new Error('Subagent "description" is required.');
135
+ }
136
+
137
+ const type = params.type ?? "general";
138
+ const model = params.model ?? DEFAULT_SUBAGENT_MODELS[type] ?? "grok/grok-4.6";
139
+ const thinking = params.thinking ?? "low";
140
+ const allowedTools = narrowSubagentTools(params.allowed_tools, params.parent_tools);
141
+ const id = `ag_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
142
+ const now = new Date().toISOString();
143
+
144
+ const record: SubagentRecord = {
145
+ id,
146
+ description: params.description.trim(),
147
+ type,
148
+ model,
149
+ thinking,
150
+ allowedTools,
151
+ status: params.background === false ? "completed" : "running",
152
+ turnsMax: params.turns ?? 10,
153
+ turnsCompleted: 0,
154
+ prompt: params.prompt.trim(),
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ steeringMessages: [],
158
+ };
159
+
160
+ this.subagents.set(id, record);
161
+ return record;
162
+ }
163
+
164
+ /**
165
+ * Dispatches an action from the `agent_control` tool.
166
+ */
167
+ control(params: AgentControlParams): SubagentExecutionReceipt {
168
+ const action = params.action;
169
+
170
+ // 1. Action: "info"
171
+ if (action === "info") {
172
+ const kind = params.kind ?? "active";
173
+ if (kind === "types") {
174
+ return {
175
+ ok: true,
176
+ action: "info",
177
+ types: SUBAGENT_TYPES,
178
+ };
179
+ }
180
+ if (kind === "models") {
181
+ return {
182
+ ok: true,
183
+ action: "info",
184
+ result: JSON.stringify(DEFAULT_SUBAGENT_MODELS),
185
+ };
186
+ }
187
+ const active = Array.from(this.subagents.values()).map((ag) => ({
188
+ id: ag.id,
189
+ description: ag.description,
190
+ type: ag.type,
191
+ status: ag.status,
192
+ }));
193
+ return {
194
+ ok: true,
195
+ action: "info",
196
+ activeAgents: active,
197
+ };
198
+ }
199
+
200
+ if (!params.agent_id) {
201
+ return {
202
+ ok: false,
203
+ action,
204
+ error: 'Parameter "agent_id" is required for result, steer, and stop actions.',
205
+ };
206
+ }
207
+
208
+ const agent = this.subagents.get(params.agent_id);
209
+ if (!agent) {
210
+ return {
211
+ ok: false,
212
+ action,
213
+ agent_id: params.agent_id,
214
+ error: `Subagent "${params.agent_id}" not found.`,
215
+ };
216
+ }
217
+
218
+ // 2. Action: "result"
219
+ if (action === "result") {
220
+ return {
221
+ ok: true,
222
+ action: "result",
223
+ agent_id: agent.id,
224
+ status: agent.status,
225
+ result: agent.output ?? `[Subagent is currently ${agent.status}]`,
226
+ };
227
+ }
228
+
229
+ // 3. Action: "steer"
230
+ if (action === "steer") {
231
+ if (!params.message || !params.message.trim()) {
232
+ return {
233
+ ok: false,
234
+ action: "steer",
235
+ agent_id: agent.id,
236
+ error: 'Parameter "message" is required for steer action.',
237
+ };
238
+ }
239
+
240
+ if (agent.status === "completed" || agent.status === "stopped" || agent.status === "failed") {
241
+ return {
242
+ ok: false,
243
+ action: "steer",
244
+ agent_id: agent.id,
245
+ error: `Cannot steer subagent "${agent.id}" because it is already ${agent.status}.`,
246
+ };
247
+ }
248
+
249
+ agent.steeringMessages.push({
250
+ text: params.message.trim(),
251
+ injectedAt: new Date().toISOString(),
252
+ processed: false,
253
+ });
254
+ agent.updatedAt = new Date().toISOString();
255
+
256
+ return {
257
+ ok: true,
258
+ action: "steer",
259
+ agent_id: agent.id,
260
+ status: agent.status,
261
+ steered: true,
262
+ };
263
+ }
264
+
265
+ // 4. Action: "stop"
266
+ if (action === "stop") {
267
+ agent.status = "stopped";
268
+ agent.updatedAt = new Date().toISOString();
269
+ return {
270
+ ok: true,
271
+ action: "stop",
272
+ agent_id: agent.id,
273
+ status: "stopped",
274
+ stopped: true,
275
+ };
276
+ }
277
+
278
+ return {
279
+ ok: false,
280
+ action,
281
+ error: `Unknown control action "${action}".`,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Retrieves an agent record by ID.
287
+ */
288
+ get(agentId: string): SubagentRecord | undefined {
289
+ return this.subagents.get(agentId);
290
+ }
291
+
292
+ /**
293
+ * Marks a subagent as completed with output.
294
+ */
295
+ complete(agentId: string, output: string): void {
296
+ const agent = this.subagents.get(agentId);
297
+ if (agent) {
298
+ agent.status = "completed";
299
+ agent.output = output;
300
+ agent.updatedAt = new Date().toISOString();
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Returns all active running subagents.
306
+ */
307
+ listActive(): SubagentRecord[] {
308
+ return Array.from(this.subagents.values()).filter(
309
+ (ag) => ag.status === "running" || ag.status === "pending" || ag.status === "blocked",
310
+ );
311
+ }
312
+ }
@@ -15,7 +15,7 @@ import {
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
17
  import { homedir } from "node:os";
18
- import { DatabaseSync } from "node:sqlite";
18
+ import { DatabaseSync } from "./sqlite.ts";
19
19
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
20
20
  import {
21
21
  VnextConfigError,
@@ -1033,6 +1033,7 @@ function validateBundle(
1033
1033
  environments: readonly VnextResource[],
1034
1034
  options: VnextConfigOptions,
1035
1035
  gateRegistry?: VnextResource,
1036
+ projectRoot?: string,
1036
1037
  ): VnextConfigIssue[] {
1037
1038
  const issues: VnextConfigIssue[] = [];
1038
1039
  const entries = valuesOf(project.value, "repositories").map((candidate) => objectValue(candidate)).filter((candidate): candidate is JsonObject => Boolean(candidate));
@@ -1105,6 +1106,42 @@ function validateBundle(
1105
1106
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
1106
1107
  if (!workflows.has(defaultWorkflow)) issues.push(issue("reference", "default_workflow_unknown", project.logicalPath, `default workflow ${defaultWorkflow} does not exist`));
1107
1108
  for (const workflow of workflows.values()) validateWorkflow(workflow, agents, models, repositoryIds, gates, issues);
1109
+
1110
+ if (projectRoot) {
1111
+ const writerRolePath = join(projectRoot, ".kxm", "roles", "writer.yaml");
1112
+ const implementerAgent = agents.get("implementer") ?? agents.get("writer");
1113
+ if (existsSync(writerRolePath) && implementerAgent) {
1114
+ try {
1115
+ const rawRole = parseRestrictedYaml(readFileSync(writerRolePath, "utf8"));
1116
+ const roleObj = objectValue(rawRole);
1117
+ const rosterEntries = valuesOf(roleObj ?? {}, "roster")
1118
+ .map((candidate) => objectValue(candidate))
1119
+ .filter((entry): entry is JsonObject => Boolean(entry));
1120
+ const enabledRosterModels = rosterEntries
1121
+ .filter((entry) => entry.enabled !== false)
1122
+ .map((entry) => stringValue(entry.model))
1123
+ .filter((m): m is string => Boolean(m));
1124
+
1125
+ const agentModelObj = objectValue(implementerAgent.value.model);
1126
+ const agentModelStr = stringValue(implementerAgent.value.model);
1127
+ const agentProvider = agentModelObj ? stringValue(agentModelObj.provider) : undefined;
1128
+ const agentModel = agentModelObj ? stringValue(agentModelObj.model) : agentModelStr;
1129
+ const canonicalAgentModel = agentProvider && agentModel ? `${agentProvider}/${agentModel}` : agentModel;
1130
+
1131
+ if (enabledRosterModels.length > 0 && canonicalAgentModel) {
1132
+ const matches = enabledRosterModels.some((rm) => rm === canonicalAgentModel || rm === agentModel || rm.endsWith(`/${agentModel}`));
1133
+ if (!matches) {
1134
+ issues.push(issue("semantic", "role_roster_conflicts_with_agent", ".kxm/roles/writer.yaml", `role roster in .kxm/roles/writer.yaml does not include agent model ${canonicalAgentModel} from ${implementerAgent.logicalPath}`));
1135
+ }
1136
+ }
1137
+ } catch (error) {
1138
+ if (error instanceof VnextConfigError) {
1139
+ issues.push(...error.issues);
1140
+ }
1141
+ }
1142
+ }
1143
+ }
1144
+
1108
1145
  return sortIssues(issues);
1109
1146
  }
1110
1147
 
@@ -1306,7 +1343,7 @@ export function loadVnextProject(projectRoot: string, options: VnextConfigOption
1306
1343
  }
1307
1344
  if (loadIssues.length > 0) throw new VnextConfigError(loadIssues);
1308
1345
 
1309
- const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry);
1346
+ const issues = validateBundle(project, repositories, agents, models, workflows, environments, options, gateRegistry, root);
1310
1347
  if (issues.length > 0) throw new VnextConfigError(issues);
1311
1348
  const resources = [project, ...repositories.values(), ...agents.values(), ...models.values(), ...workflows.values(), ...environments, ...(gateRegistry ? [gateRegistry] : [])]
1312
1349
  .sort((left, right) => compareCodeUnits(left.logicalPath, right.logicalPath));
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import type { VnextGateObservationInput } from "./vnext-engine-gate-records.ts";
4
4
  import type { VnextGateErrorClass, VnextGateStopCause } from "./vnext-runtime-store.ts";
5
5
  import { runtimeError } from "./vnext-runtime-store.ts";
6
+ import { assertCommandSeatbelt } from "./safety-integrity.ts";
6
7
 
7
8
  const MAX_DIRECT_TIMER_MS = 2_147_483_647;
8
9
  export const COMMAND_TERM_GRACE_MS = 2000;
@@ -176,6 +177,7 @@ class CommandObserver implements VnextCommandObserver {
176
177
  ) {
177
178
  throw runtimeError("run_events_illegal", "command", "command timeoutMs exceeds the direct timer bound");
178
179
  }
180
+ assertCommandSeatbelt(this.definition.argv.join(" "));
179
181
  this.startedAt = new Date().toISOString();
180
182
  let child: ChildProcess;
181
183
  try {
@@ -94,6 +94,21 @@ import {
94
94
  parseRoutingRecordV2,
95
95
  behavioralConfigHash,
96
96
  } from "./routing.ts";
97
+ import {
98
+ assertCommandSeatbelt,
99
+ DESTRUCTIVE_COMMAND_PATTERNS,
100
+ isRtkBypassRequired,
101
+ assertPinnedSshHostKeyPolicy,
102
+ type RtkBypassContext,
103
+ } from "./safety-integrity.ts";
104
+
105
+ export {
106
+ assertCommandSeatbelt,
107
+ DESTRUCTIVE_COMMAND_PATTERNS,
108
+ isRtkBypassRequired,
109
+ assertPinnedSshHostKeyPolicy,
110
+ type RtkBypassContext,
111
+ };
97
112
 
98
113
  const trustedProducers = new WeakSet<object>();
99
114
  const CAPABILITY_PREFIX = "kxm-attempt-capability\0";
@@ -557,6 +572,7 @@ async function runPreparedCommandGate(
557
572
  definition: { readonly kind: "command"; readonly argv: readonly string[]; readonly timeoutMs: number; readonly cwd?: "control" },
558
573
  token: string,
559
574
  ): Promise<VnextRunDriveResult> {
575
+ assertCommandSeatbelt(definition.argv.join(" "));
560
576
  const storePath = context.eventStore.path;
561
577
  const observer = createCommandObserver({
562
578
  definition,
@@ -58,14 +58,22 @@ export interface HarnessCatalogEntry {
58
58
  oneShot?: HarnessOneShotConfig | undefined;
59
59
  }
60
60
 
61
+ export type HarnessRunCommand = (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult;
62
+ export type HarnessRunCommandAsync = (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult | Promise<HarnessCommandResult>;
63
+
61
64
  export interface HarnessProbeOptions {
62
65
  env?: NodeJS.ProcessEnv | undefined;
63
- runCommand?: ((command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult) | undefined;
66
+ runCommand?: HarnessRunCommand | undefined;
64
67
  timeoutMs?: number | undefined;
65
68
  platform?: NodeJS.Platform | undefined;
66
69
  existsSync?: ((path: string) => boolean) | undefined;
67
70
  }
68
71
 
72
+ export interface AsyncHarnessProbeOptions extends Omit<HarnessProbeOptions, "runCommand"> {
73
+ signal?: AbortSignal | undefined;
74
+ runCommand?: HarnessRunCommandAsync | undefined;
75
+ }
76
+
69
77
  export interface HarnessDispatchStatus {
70
78
  status: "yes" | "no";
71
79
  supported: boolean;
@@ -414,6 +422,8 @@ const READ_ONLY_ONESHOT_ARGS = Object.freeze({
414
422
  claude: Object.freeze(["--tools", "Read,Glob,Grep", "--restricted", "--safe-mode", "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands", "--no-session-persistence"]),
415
423
  codex: Object.freeze(["--sandbox", "read-only", "--ignore-user-config", "-c", 'approval_policy="never"']),
416
424
  grok: Object.freeze(["--sandbox", "read-only", "--permission-mode", "plan", "--tools", "Read,Glob,Grep", "--no-subagents", "--disable-web-search"]),
425
+ agy: Object.freeze(["--mode", "plan", "--sandbox", "--disable-slash-commands"]),
426
+ kimi: Object.freeze(["--plan"]),
417
427
  });
418
428
 
419
429
  export function oneShotReadOnlyArgs(harness: string): readonly string[] | undefined {
@@ -469,7 +479,7 @@ export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
469
479
  authArgs: ["provider", "list"],
470
480
  update: { self: ["upgrade"] },
471
481
  oneShot: {
472
- argv: ["--output-format", "stream-json", "-p"],
482
+ argv: [...oneShotReadOnlyArgs("kimi")!, "--output-format", "stream-json", "-p"],
473
483
  promptVia: "arg",
474
484
  outputFormat: "stream-json",
475
485
  usageParser: parseKimiOneShotUsage,
@@ -532,7 +542,7 @@ export const BUILTIN_HARNESSES: readonly HarnessCatalogEntry[] = Object.freeze([
532
542
  authArgs: ["models"],
533
543
  update: { self: ["update"] },
534
544
  oneShot: {
535
- argv: ["--output-format", "json", "-p"],
545
+ argv: [...oneShotReadOnlyArgs("agy")!, "--output-format", "json", "-p"],
536
546
  promptVia: "arg",
537
547
  outputFormat: "json",
538
548
  usageParser: parseAgyOneShotUsage,
@@ -596,7 +606,7 @@ export function findWinNpmInnerExe(
596
606
  return undefined;
597
607
  }
598
608
 
599
- function defaultRunner(env: NodeJS.ProcessEnv): (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult {
609
+ function defaultRunner(env: NodeJS.ProcessEnv): HarnessRunCommand {
600
610
  return (command, args, timeoutMs) => {
601
611
  try {
602
612
  const result = spawnSync(command, [...args], {
@@ -622,6 +632,37 @@ function defaultRunner(env: NodeJS.ProcessEnv): (command: string, args: readonly
622
632
  };
623
633
  }
624
634
 
635
+ function commandResultFromSpawn(result: Awaited<ReturnType<typeof defaultSpawn>>): HarnessCommandResult {
636
+ const errno = result.error && "code" in result.error
637
+ ? (result.error as NodeJS.ErrnoException).code
638
+ : undefined;
639
+ const error = (typeof errno === "string" && errno ? errno : undefined)
640
+ ?? result.error?.message
641
+ ?? (result.observedChildExit !== true ? "auth_probe_exit_unobserved" : undefined);
642
+ return {
643
+ ok: result.code === 0 && !error,
644
+ code: result.code,
645
+ stdout: result.stdout,
646
+ stderr: result.stderr,
647
+ ...(error ? { error } : {}),
648
+ };
649
+ }
650
+
651
+ function defaultAsyncRunner(env: NodeJS.ProcessEnv, signal?: AbortSignal): HarnessRunCommandAsync {
652
+ return async (command, args, timeoutMs) => {
653
+ try {
654
+ return commandResultFromSpawn(await defaultSpawn(command, args, {
655
+ env,
656
+ timeoutMs,
657
+ signal,
658
+ shell: harnessSpawnUsesShell(command),
659
+ }));
660
+ } catch (error) {
661
+ return { ok: false, code: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : "spawn_failed" };
662
+ }
663
+ };
664
+ }
665
+
625
666
  function firstLine(text: string): string | undefined {
626
667
  const line = text.split(/\r?\n/).map((candidate) => candidate.trim()).find(Boolean);
627
668
  return line && line.length <= 200 ? line : undefined;
@@ -856,6 +897,18 @@ export function probeHarnesses(options: HarnessProbeOptions = {}): HarnessInvent
856
897
  };
857
898
  }
858
899
 
900
+ /**
901
+ * Inventory probe that never uses spawnSync. Product CLI/hub/extension paths
902
+ * must await this so capability/auth checks cannot block the event loop.
903
+ */
904
+ export async function probeHarnessesAsync(options: AsyncHarnessProbeOptions = {}): Promise<HarnessInventory> {
905
+ return replayHarnessProbe(
906
+ options.runCommand ?? defaultAsyncRunner(options.env ?? process.env, options.signal),
907
+ (runCommand) => probeHarnesses({ ...options, runCommand }),
908
+ 256,
909
+ );
910
+ }
911
+
859
912
  export function eligibleHarnesses(inventory: HarnessInventory): readonly string[] {
860
913
  const eligible = inventory.harnesses
861
914
  .filter((entry) => entry !== undefined && entry.detected && entry.authenticated === true)
@@ -1116,7 +1169,7 @@ export function probeHarnessAssignment(options: HarnessAssignmentProbeOptions):
1116
1169
 
1117
1170
  export interface AsyncHarnessAssignmentProbeOptions extends Omit<HarnessAssignmentProbeOptions, "runCommand"> {
1118
1171
  signal?: AbortSignal | undefined;
1119
- runCommand?: ((command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult | Promise<HarnessCommandResult>) | undefined;
1172
+ runCommand?: HarnessRunCommandAsync | undefined;
1120
1173
  }
1121
1174
 
1122
1175
  class PendingHarnessCommand {
@@ -1132,37 +1185,43 @@ class PendingHarnessCommand {
1132
1185
  }
1133
1186
  }
1134
1187
 
1135
- /**
1136
- * Execute the existing probe policy without blocking the Runtime event loop.
1137
- * Replay pure policy decisions from per-call command observations, yielding for
1138
- * every missing observation. This reuses the exact auth/model/brake parsers,
1139
- * deduplicates repeated detection, and never caches auth across assignments.
1140
- */
1141
- export async function probeHarnessAssignmentAsync(options: AsyncHarnessAssignmentProbeOptions): Promise<HarnessStatus> {
1188
+ async function replayHarnessProbe<T>(
1189
+ run: HarnessRunCommandAsync,
1190
+ execute: (runCommand: HarnessRunCommand) => T,
1191
+ limit: number,
1192
+ ): Promise<T> {
1142
1193
  const observed = new Map<string, HarnessCommandResult>();
1143
- const run = options.runCommand ?? (async (command, args, timeoutMs) => {
1144
- const result = await defaultSpawn(command, args, { env: options.env, timeoutMs, signal: options.signal });
1145
- const error = result.error?.message ?? (result.observedChildExit !== true ? "auth_probe_exit_unobserved" : undefined);
1146
- return { ok: result.code === 0 && !error, code: result.code, stdout: result.stdout, stderr: result.stderr,
1147
- ...(error ? { error } : {}) };
1148
- });
1149
- for (let commands = 0; commands <= 32; commands++) {
1194
+ for (let commands = 0; commands <= limit; commands++) {
1150
1195
  try {
1151
- return probeHarnessAssignment({ ...options, runCommand(command, args, timeoutMs) {
1196
+ return execute((command, args, timeoutMs) => {
1152
1197
  const key = JSON.stringify([command, args, timeoutMs]);
1153
1198
  const result = observed.get(key);
1154
1199
  if (result) return result;
1155
1200
  throw new PendingHarnessCommand(command, args, timeoutMs, key);
1156
- } });
1201
+ });
1157
1202
  } catch (pending) {
1158
1203
  if (!(pending instanceof PendingHarnessCommand)) throw pending;
1159
- if (commands === 32) throw new Error("auth_probe_command_limit");
1204
+ if (commands === limit) throw new Error("auth_probe_command_limit");
1160
1205
  observed.set(pending.key, await run(pending.command, pending.args, pending.timeoutMs));
1161
1206
  }
1162
1207
  }
1163
1208
  throw new Error("auth_probe_command_limit");
1164
1209
  }
1165
1210
 
1211
+ /**
1212
+ * Execute the existing probe policy without blocking the Runtime event loop.
1213
+ * Replay pure policy decisions from per-call command observations, yielding for
1214
+ * every missing observation. This reuses the exact auth/model/brake parsers,
1215
+ * deduplicates repeated detection, and never caches auth across assignments.
1216
+ */
1217
+ export async function probeHarnessAssignmentAsync(options: AsyncHarnessAssignmentProbeOptions): Promise<HarnessStatus> {
1218
+ return replayHarnessProbe(
1219
+ options.runCommand ?? defaultAsyncRunner(options.env ?? process.env, options.signal),
1220
+ (runCommand) => probeHarnessAssignment({ ...options, runCommand }),
1221
+ 32,
1222
+ );
1223
+ }
1224
+
1166
1225
  export function probeHarnessesForModel(
1167
1226
  modelSpec: HarnessModelSpec,
1168
1227
  options: HarnessProbeOptions = {},
@@ -1187,6 +1246,17 @@ export function probeHarnessesForModel(
1187
1246
  };
1188
1247
  }
1189
1248
 
1249
+ export async function probeHarnessesForModelAsync(
1250
+ modelSpec: HarnessModelSpec,
1251
+ options: AsyncHarnessProbeOptions = {},
1252
+ ): Promise<HarnessInventory> {
1253
+ return replayHarnessProbe(
1254
+ options.runCommand ?? defaultAsyncRunner(options.env ?? process.env, options.signal),
1255
+ (runCommand) => probeHarnessesForModel(modelSpec, { ...options, runCommand }),
1256
+ 256,
1257
+ );
1258
+ }
1259
+
1190
1260
  function scopesFor(scope: HarnessUpdateScope, entry: HarnessCatalogEntry): Exclude<HarnessUpdateScope, "all">[] {
1191
1261
  if (scope === "self") return ["self"];
1192
1262
  if (scope === "extensions") return entry.update.extensions ? ["extensions"] : [];