muonroi-cli 1.8.5 → 1.9.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 (206) hide show
  1. package/dist/packages/agent-harness-core/src/event-filter.js +11 -0
  2. package/dist/packages/agent-harness-core/src/event-redact.js +7 -0
  3. package/dist/packages/agent-harness-core/src/event-tee.d.ts +20 -4
  4. package/dist/packages/agent-harness-core/src/event-tee.js +32 -5
  5. package/dist/packages/agent-harness-core/src/mcp-server.d.ts +14 -0
  6. package/dist/packages/agent-harness-core/src/mcp-server.js +86 -10
  7. package/dist/packages/agent-harness-core/src/predicate.d.ts +1 -1
  8. package/dist/packages/agent-harness-core/src/protocol.d.ts +24 -2
  9. package/dist/src/agent-harness/mock-model.d.ts +10 -0
  10. package/dist/src/agent-harness/mock-model.js +6 -2
  11. package/dist/src/chat/chat-keychain.d.ts +7 -12
  12. package/dist/src/chat/chat-keychain.js +19 -86
  13. package/dist/src/cli/keys.d.ts +8 -45
  14. package/dist/src/cli/keys.js +18 -324
  15. package/dist/src/council/clarifier.d.ts +4 -2
  16. package/dist/src/council/clarifier.js +158 -36
  17. package/dist/src/council/debate-planner.js +3 -2
  18. package/dist/src/council/debate.js +59 -5
  19. package/dist/src/council/index.d.ts +23 -0
  20. package/dist/src/council/index.js +37 -2
  21. package/dist/src/council/llm.d.ts +62 -0
  22. package/dist/src/council/llm.js +123 -23
  23. package/dist/src/council/panel-select.js +13 -3
  24. package/dist/src/council/planner.js +17 -0
  25. package/dist/src/council/preflight.d.ts +10 -0
  26. package/dist/src/council/preflight.js +36 -0
  27. package/dist/src/council/prompts.d.ts +9 -1
  28. package/dist/src/council/prompts.js +23 -6
  29. package/dist/src/council/types.d.ts +10 -0
  30. package/dist/src/ee/auth.d.ts +19 -0
  31. package/dist/src/ee/auth.js +39 -0
  32. package/dist/src/ee/client.js +28 -3
  33. package/dist/src/ee/ee-onboarding.js +6 -26
  34. package/dist/src/flow/compaction/compress.d.ts +2 -2
  35. package/dist/src/flow/compaction/compress.js +21 -8
  36. package/dist/src/flow/compaction/extract.d.ts +3 -3
  37. package/dist/src/flow/compaction/extract.js +6 -6
  38. package/dist/src/flow/compaction/index.d.ts +2 -1
  39. package/dist/src/flow/compaction/index.js +29 -3
  40. package/dist/src/flow/compaction/progress.d.ts +35 -0
  41. package/dist/src/flow/compaction/progress.js +35 -0
  42. package/dist/src/generated/version.d.ts +1 -1
  43. package/dist/src/generated/version.js +1 -1
  44. package/dist/src/gsd/flags.d.ts +11 -0
  45. package/dist/src/gsd/flags.js +19 -0
  46. package/dist/src/gsd/plan-council.js +104 -72
  47. package/dist/src/gsd/verdict-schema.d.ts +1 -1
  48. package/dist/src/headless/council-answers.js +4 -0
  49. package/dist/src/index.js +129 -260
  50. package/dist/src/lsp/builtins.js +3 -1
  51. package/dist/src/lsp/manager.d.ts +5 -1
  52. package/dist/src/lsp/manager.js +249 -3
  53. package/dist/src/lsp/npm-cache.d.ts +11 -1
  54. package/dist/src/lsp/npm-cache.js +17 -1
  55. package/dist/src/lsp/runtime.d.ts +6 -1
  56. package/dist/src/lsp/runtime.js +17 -1
  57. package/dist/src/lsp/types.d.ts +83 -1
  58. package/dist/src/lsp/types.js +10 -0
  59. package/dist/src/mcp/client-pool.js +43 -15
  60. package/dist/src/mcp/lsp-tools.d.ts +5 -1
  61. package/dist/src/mcp/lsp-tools.js +93 -2
  62. package/dist/src/mcp/mcp-keychain.d.ts +3 -5
  63. package/dist/src/mcp/mcp-keychain.js +9 -49
  64. package/dist/src/mcp/setup-guide-text.d.ts +1 -1
  65. package/dist/src/mcp/setup-guide-text.js +22 -2
  66. package/dist/src/mcp/tools-server.d.ts +10 -0
  67. package/dist/src/mcp/tools-server.js +10 -2
  68. package/dist/src/models/catalog.json +19 -19
  69. package/dist/src/orchestrator/ask-user.d.ts +61 -0
  70. package/dist/src/orchestrator/ask-user.js +65 -0
  71. package/dist/src/orchestrator/compaction.d.ts +2 -3
  72. package/dist/src/orchestrator/compaction.js +8 -8
  73. package/dist/src/orchestrator/council-manager.js +9 -8
  74. package/dist/src/orchestrator/council-request.d.ts +49 -0
  75. package/dist/src/orchestrator/council-request.js +62 -0
  76. package/dist/src/orchestrator/interactive-pause.d.ts +26 -0
  77. package/dist/src/orchestrator/interactive-pause.js +36 -0
  78. package/dist/src/orchestrator/message-processor.d.ts +4 -0
  79. package/dist/src/orchestrator/message-processor.js +26 -8
  80. package/dist/src/orchestrator/orchestrator.d.ts +25 -0
  81. package/dist/src/orchestrator/orchestrator.js +204 -50
  82. package/dist/src/orchestrator/preprocessor.js +2 -2
  83. package/dist/src/orchestrator/safety-askcard.d.ts +1 -1
  84. package/dist/src/orchestrator/safety-askcard.js +5 -2
  85. package/dist/src/orchestrator/safety-intercept.d.ts +5 -0
  86. package/dist/src/orchestrator/safety-intercept.js +7 -0
  87. package/dist/src/orchestrator/stall-watchdog.d.ts +8 -1
  88. package/dist/src/orchestrator/stall-watchdog.js +24 -3
  89. package/dist/src/orchestrator/stream-runner.d.ts +13 -3
  90. package/dist/src/orchestrator/stream-runner.js +54 -21
  91. package/dist/src/orchestrator/tool-engine.d.ts +19 -0
  92. package/dist/src/orchestrator/tool-engine.js +241 -25
  93. package/dist/src/orchestrator/turn-watchdog.d.ts +7 -0
  94. package/dist/src/orchestrator/turn-watchdog.js +38 -9
  95. package/dist/src/pil/agent-operating-contract.d.ts +1 -1
  96. package/dist/src/pil/agent-operating-contract.js +6 -4
  97. package/dist/src/pil/discovery.d.ts +1 -1
  98. package/dist/src/pil/discovery.js +2 -2
  99. package/dist/src/pil/layer1_5-complexity-size.d.ts +7 -0
  100. package/dist/src/pil/layer1_5-complexity-size.js +31 -5
  101. package/dist/src/pil/llm-classify.d.ts +78 -3
  102. package/dist/src/pil/llm-classify.js +351 -111
  103. package/dist/src/pil/native-capabilities-workbook.d.ts +1 -1
  104. package/dist/src/pil/native-capabilities-workbook.js +7 -0
  105. package/dist/src/pil/pipeline.js +2 -0
  106. package/dist/src/pil/repo-grounding-probe.d.ts +15 -0
  107. package/dist/src/pil/repo-grounding-probe.js +136 -0
  108. package/dist/src/pil/repo-structure-hints.d.ts +7 -0
  109. package/dist/src/pil/repo-structure-hints.js +45 -0
  110. package/dist/src/product-loop/artifact-io.js +4 -0
  111. package/dist/src/product-loop/criteria-seed.d.ts +51 -0
  112. package/dist/src/product-loop/criteria-seed.js +200 -0
  113. package/dist/src/product-loop/discovery-interview.d.ts +9 -0
  114. package/dist/src/product-loop/discovery-interview.js +37 -18
  115. package/dist/src/product-loop/discovery-recommender.js +2 -1
  116. package/dist/src/product-loop/discovery-schema.js +14 -1
  117. package/dist/src/product-loop/discovery-triage.d.ts +23 -0
  118. package/dist/src/product-loop/discovery-triage.js +109 -0
  119. package/dist/src/product-loop/gather.js +150 -2
  120. package/dist/src/product-loop/index.js +7 -0
  121. package/dist/src/product-loop/loop-driver.js +21 -8
  122. package/dist/src/product-loop/phase-plan.d.ts +16 -0
  123. package/dist/src/product-loop/phase-plan.js +42 -4
  124. package/dist/src/product-loop/phase-rituals.d.ts +3 -0
  125. package/dist/src/product-loop/phase-rituals.js +8 -3
  126. package/dist/src/product-loop/phase-runner.js +30 -11
  127. package/dist/src/product-loop/plan-adherence-review.d.ts +26 -0
  128. package/dist/src/product-loop/plan-adherence-review.js +144 -0
  129. package/dist/src/product-loop/sprint-runner.d.ts +62 -0
  130. package/dist/src/product-loop/sprint-runner.js +309 -8
  131. package/dist/src/product-loop/types.d.ts +25 -0
  132. package/dist/src/providers/anthropic.d.ts +9 -8
  133. package/dist/src/providers/anthropic.js +13 -47
  134. package/dist/src/providers/auth/grok-oauth.d.ts +1 -0
  135. package/dist/src/providers/auth/grok-oauth.js +30 -5
  136. package/dist/src/providers/auth/openai-oauth.d.ts +1 -0
  137. package/dist/src/providers/auth/openai-oauth.js +14 -0
  138. package/dist/src/providers/auth/token-store.d.ts +9 -9
  139. package/dist/src/providers/auth/token-store.js +8 -70
  140. package/dist/src/providers/auth/types.d.ts +8 -0
  141. package/dist/src/providers/env-store.d.ts +17 -0
  142. package/dist/src/providers/env-store.js +228 -0
  143. package/dist/src/providers/keychain.d.ts +21 -17
  144. package/dist/src/providers/keychain.js +124 -135
  145. package/dist/src/providers/runtime.d.ts +24 -9
  146. package/dist/src/providers/runtime.js +48 -37
  147. package/dist/src/providers/strategies/thinking-mode.js +9 -1
  148. package/dist/src/providers/strategies/xai.strategy.js +27 -0
  149. package/dist/src/providers/warm.d.ts +65 -0
  150. package/dist/src/providers/warm.js +145 -0
  151. package/dist/src/self-qa/agentic-loop.js +3 -2
  152. package/dist/src/storage/transcript.js +56 -2
  153. package/dist/src/tools/git-safety.d.ts +19 -0
  154. package/dist/src/tools/git-safety.js +168 -0
  155. package/dist/src/tools/native-tools.d.ts +1 -1
  156. package/dist/src/tools/native-tools.js +76 -1
  157. package/dist/src/tools/registry.d.ts +17 -0
  158. package/dist/src/tools/registry.js +116 -1
  159. package/dist/src/types/index.d.ts +29 -1
  160. package/dist/src/ui/app.js +67 -4
  161. package/dist/src/ui/components/agent-rail-activities.d.ts +26 -0
  162. package/dist/src/ui/components/agent-rail-activities.js +47 -0
  163. package/dist/src/ui/components/compact-progress-card.d.ts +24 -0
  164. package/dist/src/ui/components/compact-progress-card.js +42 -0
  165. package/dist/src/ui/components/council-phase-timeline.js +17 -2
  166. package/dist/src/ui/components/council-question-card.js +1 -0
  167. package/dist/src/ui/components/message-view.d.ts +15 -0
  168. package/dist/src/ui/components/message-view.js +50 -1
  169. package/dist/src/ui/components/tool-group.d.ts +15 -3
  170. package/dist/src/ui/components/tool-group.js +69 -11
  171. package/dist/src/ui/containers/modals-layer.d.ts +2 -1
  172. package/dist/src/ui/containers/modals-layer.js +2 -2
  173. package/dist/src/ui/council-harness-event.d.ts +57 -0
  174. package/dist/src/ui/council-harness-event.js +46 -0
  175. package/dist/src/ui/heartbeat-debug.d.ts +29 -0
  176. package/dist/src/ui/heartbeat-debug.js +45 -0
  177. package/dist/src/ui/modals/api-key-modal.js +1 -1
  178. package/dist/src/ui/modals/model-picker-modal.d.ts +8 -18
  179. package/dist/src/ui/modals/model-picker-modal.js +8 -10
  180. package/dist/src/ui/slash/ee.js +81 -0
  181. package/dist/src/ui/slash/menu-items.js +11 -2
  182. package/dist/src/ui/use-app-logic.js +354 -224
  183. package/dist/src/ui/utils/agent-activities.d.ts +39 -0
  184. package/dist/src/ui/utils/agent-activities.js +96 -0
  185. package/dist/src/ui/utils/group-tool-entries.d.ts +26 -0
  186. package/dist/src/ui/utils/group-tool-entries.js +111 -0
  187. package/dist/src/ui/utils/tool-summary.d.ts +21 -0
  188. package/dist/src/ui/utils/tool-summary.js +91 -0
  189. package/dist/src/utils/event-loop-monitor.d.ts +85 -0
  190. package/dist/src/utils/event-loop-monitor.js +107 -0
  191. package/dist/src/utils/llm-deadline.d.ts +14 -0
  192. package/dist/src/utils/llm-deadline.js +19 -0
  193. package/dist/src/utils/loop-profiler.d.ts +102 -0
  194. package/dist/src/utils/loop-profiler.js +202 -0
  195. package/dist/src/utils/settings.d.ts +27 -0
  196. package/dist/src/utils/settings.js +38 -2
  197. package/dist/src/utils/side-question.d.ts +1 -2
  198. package/dist/src/utils/side-question.js +2 -2
  199. package/dist/src/verify/entrypoint.js +51 -16
  200. package/dist/src/verify/orchestrator.d.ts +1 -1
  201. package/dist/src/verify/orchestrator.js +20 -3
  202. package/package.json +1 -2
  203. package/dist/src/cli/bw-vault.d.ts +0 -55
  204. package/dist/src/cli/bw-vault.js +0 -133
  205. package/dist/src/mcp/ee-tools.d.ts +0 -46
  206. package/dist/src/mcp/ee-tools.js +0 -194
@@ -28,9 +28,20 @@ export const LIFECYCLE_PRESET = new Set([
28
28
  "askcard-cancel",
29
29
  "sprint-stage",
30
30
  "sprint-halt",
31
+ // Emitted at product-loop/index.ts on plan commit; a wake-at-milestone monitor
32
+ // must see it. Was previously dropped by this preset despite being emitted.
33
+ "sprint-plan-committed",
31
34
  "route-decision",
32
35
  "steer-inject",
33
36
  "usage",
37
+ // Ephemeral kinds carry a visualText snapshot for wake-at-milestone monitors
38
+ // (event-tee.ts EPHEMERAL_KINDS). The default preset must not drop them, or a
39
+ // monitor on MUONROI_HARNESS_EVENT_LOG misses errors/timeouts/disconnects.
40
+ "ee-timeout",
41
+ "ee-error",
42
+ "grounding-flag",
43
+ "stream-retry",
44
+ "disconnect",
34
45
  ]);
35
46
  /**
36
47
  * Returns a predicate that returns true when an event kind is allowed to emit.
@@ -53,6 +53,13 @@ const ALLOWED_FIELDS = {
53
53
  status: "pass",
54
54
  round: "pass",
55
55
  correlationId: "pass",
56
+ // Liveness counters. Plain magnitudes (like elapsedMs / charCount above) —
57
+ // no prompt or response content — and without them a harness consumer
58
+ // cannot tell a slow reasoning call from a hung one, because `elapsedMs`
59
+ // alone freezes whenever the tick generator stops being pulled.
60
+ streamedChars: "pass",
61
+ lastDeltaAgeMs: "pass",
62
+ elapsedMs: "pass",
56
63
  },
57
64
  "council-turn-length": {
58
65
  role: "pass",
@@ -19,8 +19,11 @@
19
19
  * card) stay put, so the agent renders them fresh on demand; embedding a stale
20
20
  * snapshot for those would be worse, not better.
21
21
  *
22
- * Gated entirely behind MUONROI_HARNESS_EVENT_LOG unset means zero behavior
23
- * change (clean checkouts are byte-identical to before this file existed).
22
+ * ON BY DEFAULT (see {@link resolveEventLogPath}): an opt-in sink is one nobody
23
+ * remembers to opt into, and the cost of the log missing is an agent that
24
+ * cannot tell "waiting for a human" from "hung" — the exact confusion this file
25
+ * exists to prevent. Set MUONROI_HARNESS_EVENT_LOG=0 (or "off"/"false") to
26
+ * disable, or to a path to choose your own.
24
27
  */
25
28
  import type { LiveEvent } from "./protocol.js";
26
29
  /**
@@ -37,8 +40,21 @@ export interface TeedEventLine {
37
40
  visualText?: string;
38
41
  }
39
42
  /**
40
- * Build an event-tee sink. Returns null when MUONROI_HARNESS_EVENT_LOG is unset
41
- * or blank (no-op path — caller skips it entirely).
43
+ * Resolve where events are teed, from the MUONROI_HARNESS_EVENT_LOG value.
44
+ *
45
+ * - unset/blank → a per-pid file in the OS temp dir (the default-on path)
46
+ * - "0" / "off" / "false" / "no" → null, sink disabled
47
+ * - anything else → that literal path
48
+ *
49
+ * Per-pid keeps concurrent harness servers from interleaving into one file, and
50
+ * puts the log where the OS already reclaims it — no rotation to maintain.
51
+ *
52
+ * @param pid - process id (injectable for tests).
53
+ */
54
+ export declare function resolveEventLogPath(envValue?: string, pid?: number): string | null;
55
+ /**
56
+ * Build an event-tee sink. Returns null only when the sink is explicitly
57
+ * disabled — see {@link resolveEventLogPath}.
42
58
  *
43
59
  * @param getVisualText - lazily renders the current visual frame to plain text
44
60
  * (driver.render_visual). Called ONLY for ephemeral kinds, so persistent
@@ -19,10 +19,15 @@
19
19
  * card) stay put, so the agent renders them fresh on demand; embedding a stale
20
20
  * snapshot for those would be worse, not better.
21
21
  *
22
- * Gated entirely behind MUONROI_HARNESS_EVENT_LOG unset means zero behavior
23
- * change (clean checkouts are byte-identical to before this file existed).
22
+ * ON BY DEFAULT (see {@link resolveEventLogPath}): an opt-in sink is one nobody
23
+ * remembers to opt into, and the cost of the log missing is an agent that
24
+ * cannot tell "waiting for a human" from "hung" — the exact confusion this file
25
+ * exists to prevent. Set MUONROI_HARNESS_EVENT_LOG=0 (or "off"/"false") to
26
+ * disable, or to a path to choose your own.
24
27
  */
25
28
  import { appendFileSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
26
31
  /**
27
32
  * Event kinds that flash and disappear before an agent can wake + render.
28
33
  * Only these carry an at-emit visual snapshot in the teed line.
@@ -35,9 +40,31 @@ export const EPHEMERAL_KINDS = new Set([
35
40
  "ee-error",
36
41
  "grounding-flag",
37
42
  ]);
43
+ /** Values that turn the sink off. Anything else is treated as a path. */
44
+ const DISABLE_VALUES = new Set(["0", "off", "false", "no"]);
38
45
  /**
39
- * Build an event-tee sink. Returns null when MUONROI_HARNESS_EVENT_LOG is unset
40
- * or blank (no-op path — caller skips it entirely).
46
+ * Resolve where events are teed, from the MUONROI_HARNESS_EVENT_LOG value.
47
+ *
48
+ * - unset/blank → a per-pid file in the OS temp dir (the default-on path)
49
+ * - "0" / "off" / "false" / "no" → null, sink disabled
50
+ * - anything else → that literal path
51
+ *
52
+ * Per-pid keeps concurrent harness servers from interleaving into one file, and
53
+ * puts the log where the OS already reclaims it — no rotation to maintain.
54
+ *
55
+ * @param pid - process id (injectable for tests).
56
+ */
57
+ export function resolveEventLogPath(envValue, pid = process.pid) {
58
+ const raw = (envValue ?? "").trim();
59
+ if (DISABLE_VALUES.has(raw.toLowerCase()))
60
+ return null;
61
+ if (raw)
62
+ return raw;
63
+ return join(tmpdir(), `muonroi-harness-events-${pid}.jsonl`);
64
+ }
65
+ /**
66
+ * Build an event-tee sink. Returns null only when the sink is explicitly
67
+ * disabled — see {@link resolveEventLogPath}.
41
68
  *
42
69
  * @param getVisualText - lazily renders the current visual frame to plain text
43
70
  * (driver.render_visual). Called ONLY for ephemeral kinds, so persistent
@@ -45,7 +72,7 @@ export const EPHEMERAL_KINDS = new Set([
45
72
  * @param envValue - value of MUONROI_HARNESS_EVENT_LOG (injectable for tests).
46
73
  */
47
74
  export function createEventTee(getVisualText, envValue) {
48
- const path = (envValue ?? "").trim();
75
+ const path = resolveEventLogPath(envValue);
49
76
  if (!path)
50
77
  return null;
51
78
  return (event) => {
@@ -47,6 +47,18 @@ export declare function validateStartArgs(args: string[]): {
47
47
  bad: string;
48
48
  };
49
49
  export declare function sanitizeEnv(env: Record<string, string>): Record<string, string>;
50
+ /**
51
+ * Build the child TUI's environment for tui.start.
52
+ *
53
+ * Starts from the driver's own env so PATH/HOME survive, overlays the
54
+ * caller-supplied keys, THEN strips the dangerous ones. The merge-before-strip
55
+ * order keeps the security posture (NODE_OPTIONS/LD_PRELOAD/… still removed)
56
+ * while guaranteeing PATH is present: bun drops the fd 3/4 stdio channels when a
57
+ * child is spawned with a partial env that lacks PATH, which yields a null
58
+ * inWrite and crashes the whole driver on spawn. `base` defaults to process.env
59
+ * and is injectable for tests.
60
+ */
61
+ export declare function buildChildEnv(callerEnv?: Record<string, string>, base?: Record<string, string | undefined>): Record<string, string>;
50
62
  /**
51
63
  * Opt-in extra cwd roots for tui.start, layered ON TOP of the default
52
64
  * {home, repo-root} allowlist. The posture stays deny-by-default: only roots an
@@ -73,6 +85,8 @@ export declare function validateMockLlmPath(value: string): boolean;
73
85
  export declare function buildCapabilitiesPayload(): {
74
86
  protocol: string;
75
87
  features: readonly string[];
88
+ /** Where LiveEvents are teed as JSONL, or null when the sink is disabled. */
89
+ eventLogPath: string | null;
76
90
  };
77
91
  export declare function registerReadTools(server: McpServer, getDriver: () => Driver | null): void;
78
92
  export declare function registerActionTools(server: McpServer, getDriver: () => Driver | null): void;
@@ -18,7 +18,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
19
19
  import { z } from "zod";
20
20
  import { createDriver } from "./driver.js";
21
- import { createEventTee } from "./event-tee.js";
21
+ import { createEventTee, resolveEventLogPath } from "./event-tee.js";
22
22
  import { PROTOCOL_VERSION } from "./protocol.js";
23
23
  const ARG_ALLOW = /^(--agent-[a-z-]+(=.*)?|--mock-llm(=.+)?|--profile=[a-zA-Z0-9_-]+)$/;
24
24
  const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,63}$/;
@@ -50,6 +50,25 @@ export function sanitizeEnv(env) {
50
50
  }
51
51
  return out;
52
52
  }
53
+ /**
54
+ * Build the child TUI's environment for tui.start.
55
+ *
56
+ * Starts from the driver's own env so PATH/HOME survive, overlays the
57
+ * caller-supplied keys, THEN strips the dangerous ones. The merge-before-strip
58
+ * order keeps the security posture (NODE_OPTIONS/LD_PRELOAD/… still removed)
59
+ * while guaranteeing PATH is present: bun drops the fd 3/4 stdio channels when a
60
+ * child is spawned with a partial env that lacks PATH, which yields a null
61
+ * inWrite and crashes the whole driver on spawn. `base` defaults to process.env
62
+ * and is injectable for tests.
63
+ */
64
+ export function buildChildEnv(callerEnv = {}, base = process.env) {
65
+ const merged = {};
66
+ for (const [k, v] of Object.entries(base)) {
67
+ if (v !== undefined)
68
+ merged[k] = v;
69
+ }
70
+ return sanitizeEnv({ ...merged, ...callerEnv });
71
+ }
53
72
  const REPO_ROOT = process.cwd();
54
73
  /**
55
74
  * Opt-in extra cwd roots for tui.start, layered ON TOP of the default
@@ -147,11 +166,17 @@ const FEATURES = [
147
166
  "snapshot_visual",
148
167
  "cell",
149
168
  "visual_quality",
169
+ "wait_for_event",
170
+ "event_log",
150
171
  ];
151
172
  export function buildCapabilitiesPayload() {
152
173
  return {
153
174
  protocol: PROTOCOL_VERSION,
154
175
  features: FEATURES,
176
+ // A default-on sink nobody can locate is still opt-in. Reporting the
177
+ // resolved path here is what makes it discoverable without the caller
178
+ // reproducing the env/tmpdir/pid rule.
179
+ eventLogPath: resolveEventLogPath(process.env["MUONROI_HARNESS_EVENT_LOG"]),
155
180
  };
156
181
  }
157
182
  export function registerReadTools(server, getDriver) {
@@ -211,7 +236,13 @@ export function registerReadTools(server, getDriver) {
211
236
  return noDriver();
212
237
  return { content: [{ type: "text", text: String(d.count(selector)) }] };
213
238
  });
214
- server.registerTool("tui.render_text", { description: "ASCII debug render of the current frame.", inputSchema: {} }, async () => {
239
+ server.registerTool("tui.render_text", {
240
+ // "debug render" undersold it: this is the primary way to see what is on
241
+ // screen right now — every node, modal and message in the semantic tree.
242
+ description: "See the current screen as a semantic tree (every node, modal, message, and focus state). " +
243
+ "Use tui.render_visual instead for the literal characters a human reads.",
244
+ inputSchema: {},
245
+ }, async () => {
215
246
  const d = getDriver();
216
247
  if (!d)
217
248
  return noDriver();
@@ -318,21 +349,53 @@ export function registerAsyncTools(server, getDriver, deps) {
318
349
  const waitConditionShape = {
319
350
  selector: z.string().max(500).optional(),
320
351
  idle: z.boolean().optional(),
352
+ event: z.string().max(64).optional(),
321
353
  };
354
+ /**
355
+ * Rebuild the driver's WaitArgs from validated MCP input.
356
+ *
357
+ * Built key-by-key rather than passed through, because the driver dispatches
358
+ * on `"selector" in args` BEFORE `"event" in args` — an explicitly-`undefined`
359
+ * `selector` key would still win that check and silently wait on nothing.
360
+ */
361
+ function toWaitArgs(c) {
362
+ if (typeof c.selector === "string")
363
+ return { selector: c.selector };
364
+ if (typeof c.event === "string")
365
+ return { event: c.event };
366
+ if (c.idle)
367
+ return { idle: true };
368
+ return {};
369
+ }
322
370
  server.registerTool("tui.wait_for", {
323
- description: "Wait until a selector matches or the TUI is idle (or both for all=).",
371
+ description: "Block until a selector matches, a LiveEvent of the given kind arrives, or the TUI is idle " +
372
+ "(all= requires every condition). Prefer this over polling: the wait resolves on the driver's " +
373
+ "own event stream, so a modal pause (event='askcard-open') or a sprint halt wakes it immediately.",
324
374
  inputSchema: {
325
375
  selector: z.string().max(500).optional(),
326
376
  idle: z.boolean().optional(),
377
+ // The driver has always supported an `event` condition (buildCheck in
378
+ // driver.ts); only this MCP schema withheld it, so external agents fell
379
+ // back to polling a DB that never records askcard-open at all.
380
+ event: z.string().max(64).optional(),
327
381
  all: z.array(z.object(waitConditionShape)).max(10).optional(),
328
- timeoutMs: z.number().int().min(0).max(60_000).optional(),
382
+ // 10 min, matching the longest legitimate single wait (a council debate
383
+ // phase measured 119.5s, and an unattended /ideal sprint runs longer).
384
+ // At 60s a caller had to re-issue the wait repeatedly — polling wearing
385
+ // a wait_for costume.
386
+ timeoutMs: z.number().int().min(0).max(600_000).optional(),
329
387
  },
330
388
  }, async (input) => {
331
389
  const d = getDriver();
332
390
  if (!d)
333
391
  return noDriver();
392
+ const args = input.all
393
+ ? { all: input.all.map(toWaitArgs) }
394
+ : toWaitArgs(input);
395
+ if (typeof input.timeoutMs === "number")
396
+ args.timeoutMs = input.timeoutMs;
334
397
  try {
335
- await d.wait_for(input);
398
+ await d.wait_for(args);
336
399
  return { content: [{ type: "text", text: "ok" }] };
337
400
  }
338
401
  catch (e) {
@@ -355,7 +418,17 @@ export function registerAsyncTools(server, getDriver, deps) {
355
418
  return { content: [{ type: "text", text: String(ok) }] };
356
419
  });
357
420
  server.registerTool("tui.last_event", {
358
- description: "Return the most recent event of the given kind (null if none).",
421
+ // Keyword-loaded on purpose: deferred-tool search matches this text, and
422
+ // the terse original ("Return the most recent event of the given kind")
423
+ // did not rank for "is the TUI waiting for input or hung" — the exact
424
+ // question it answers. An unfindable tool is an absent one.
425
+ description: "Check what a live run is doing: is it waiting on a human (kind='askcard-open' — the " +
426
+ "modal/prompt/approval question), what stage it reached (kind='sprint-stage'), did it " +
427
+ "fail or hang (kind='sprint-halt' / 'toast'), how the council progressed " +
428
+ "(kind='council-step'). Returns the most recent event of that kind with its FULL payload " +
429
+ "(null if none) — including the complete askcard question text, which tui.query truncates. " +
430
+ "Use this instead of polling a database or log file: modal pauses write no DB row, so a " +
431
+ "poller cannot tell 'waiting for a human' from 'hung'. Pair with tui.wait_for to block.",
359
432
  // Full protocol event set (minus the idle sentinel) so an external agent can
360
433
  // observe lifecycle events — council/sprint/route/askcard, not just toasts.
361
434
  // The Driver accepts any kind; this enum is the MCP-boundary validation.
@@ -473,7 +546,9 @@ export function createMcpHarnessServer({ spawn }) {
473
546
  isError: true,
474
547
  };
475
548
  }
476
- const sanitizedEnv = sanitizeEnv(input.env ?? {});
549
+ // See buildChildEnv: merge process.env (keeps PATH so bun allocates the
550
+ // fd 3/4 transport) with caller keys, then strip dangerous vars.
551
+ const sanitizedEnv = buildChildEnv(input.env ?? {});
477
552
  // Build final arg list.
478
553
  const finalArgs = [...input.args];
479
554
  if (input.mockLlmDir && !finalArgs.some((a) => a.startsWith("--mock-llm"))) {
@@ -509,9 +584,10 @@ export function createMcpHarnessServer({ spawn }) {
509
584
  sendKey: (k) => sendLine(JSON.stringify({ op: "press", key: k })),
510
585
  sendType: (t) => sendLine(JSON.stringify({ op: "type", text: t })),
511
586
  });
512
- // Optional JSONL event sink for external milestone watchers (null unless
513
- // MUONROI_HARNESS_EVENT_LOG is set). Ephemeral kinds carry an at-emit
514
- // visual snapshot so flash events aren't lost before an agent wakes.
587
+ // JSONL event sink for external milestone watchers — on by default, null
588
+ // only when MUONROI_HARNESS_EVENT_LOG explicitly disables it. Ephemeral
589
+ // kinds carry an at-emit visual snapshot so flash events aren't lost
590
+ // before an agent wakes. Path is reported by tui.capabilities.
515
591
  const eventTee = createEventTee(() => driver.render_visual(), process.env["MUONROI_HARNESS_EVENT_LOG"]);
516
592
  // onLine already delivers complete newline-stripped lines — no extra
517
593
  // splitting required.
@@ -2,8 +2,8 @@ import { z } from "zod";
2
2
  import type { UINode } from "./protocol.js";
3
3
  declare const FieldOp: z.ZodObject<{
4
4
  field: z.ZodEnum<{
5
- name: "name";
6
5
  value: "value";
6
+ name: "name";
7
7
  state: "state";
8
8
  }>;
9
9
  op: z.ZodEnum<{
@@ -131,12 +131,34 @@ export type LiveEvent = {
131
131
  kind: "council-speaker";
132
132
  /** The council role label (e.g. "architect", "security", "qa"). */
133
133
  role: string;
134
- /** "start" — speaker began their turn; "done" — speaker finished. */
135
- status: "start" | "done";
134
+ /**
135
+ * "start" speaker began; "tick" — 1s progress heartbeat (long phases
136
+ * like research emit these with an advancing `elapsedMs`); "done" —
137
+ * speaker finished. A harness poller distinguishes ALIVE (tick with
138
+ * advancing elapsedMs) from HUNG (elapsedMs frozen) via `tui_last_event`.
139
+ */
140
+ status: "start" | "tick" | "done";
136
141
  /** Round number if available from the status chunk. */
137
142
  round?: number;
138
143
  /** Correlation ID linking this speaker event to the enclosing council run. */
139
144
  correlationId: string;
145
+ /** Milliseconds elapsed in this speaker's turn/phase; advances on ticks. */
146
+ elapsedMs?: number;
147
+ /**
148
+ * Chars (text + reasoning deltas) streamed since this phase began.
149
+ * `elapsedMs` can freeze on a HEALTHY call — its tick generator only
150
+ * advances when the consumer pulls, and a round awaiting its pairs via
151
+ * Promise.all does not pull. This counter is pushed from the token stream
152
+ * itself, so growth here proves liveness regardless of pumping.
153
+ */
154
+ streamedChars?: number;
155
+ /**
156
+ * Age in ms of the most recent stream delta. Small + growing
157
+ * `streamedChars` = SLOW BUT ALIVE (e.g. a reasoning model emitting
158
+ * reasoning tokens for minutes before any text); growing age + static
159
+ * chars = genuinely STUCK.
160
+ */
161
+ lastDeltaAgeMs?: number;
140
162
  } | {
141
163
  t: "event";
142
164
  kind: "council-turn-length";
@@ -62,6 +62,16 @@ export interface MockModelFixture {
62
62
  * <taskType>,<style>,<intent>,<deliverable>,<depth>,<scope>,<lang>,<clarity>
63
63
  */
64
64
  classify?: string;
65
+ /**
66
+ * Milliseconds to wait before each streamed chunk. Default 0 (instant).
67
+ *
68
+ * A real provider takes seconds; the mock is instant, which makes anything
69
+ * that only exists WHILE a call is in flight — a progress card, a spinner —
70
+ * impossible to observe from a spec. Set this to hold the in-flight state open
71
+ * long enough to assert on it. Keep it small: it is wall-clock in every spec
72
+ * that sets it.
73
+ */
74
+ chunkDelayMs?: number;
65
75
  /**
66
76
  * Opt in to the PIL-classifier intercept described on `classify`. When true,
67
77
  * a `doStream` call carrying the classifier's system prompt is answered with
@@ -85,6 +85,7 @@ export function createMockModel(fx) {
85
85
  // delivered to the classifier — intercepting would override their reply with
86
86
  // DEFAULT_CLASSIFY_LINE and make every classification collapse to "generate".
87
87
  const interceptClassify = fx.autoClassify === true || fx.classify !== undefined;
88
+ const chunkDelayMs = Math.max(0, fx.chunkDelayMs ?? 0);
88
89
  const model = new MockLanguageModelV3({
89
90
  provider,
90
91
  modelId,
@@ -116,8 +117,11 @@ export function createMockModel(fx) {
116
117
  return {
117
118
  stream: simulateReadableStream({
118
119
  chunks,
119
- initialDelayInMs: null,
120
- chunkDelayInMs: null,
120
+ // Only the turn stream honours the delay — the classify intercept above
121
+ // fires on EVERY turn, so pacing it would tax specs that just want a
122
+ // slow answer.
123
+ initialDelayInMs: chunkDelayMs || null,
124
+ chunkDelayInMs: chunkDelayMs || null,
121
125
  }),
122
126
  };
123
127
  },
@@ -1,26 +1,21 @@
1
1
  /**
2
2
  * src/chat/chat-keychain.ts
3
3
  *
4
- * Chat-service keychain loader with env-var fallback.
5
- * Parallel module to src/mcp/mcp-keychain.ts but keyed by ChatSecretId
6
- * (chat services like Discord, Slack) instead of MCP servers.
7
- *
8
- * Priority: OS keychain (keytar) > environment variable > null.
9
- * On CLI startup, hydrateChatEnvFromKeychain() populates process.env from
10
- * keychain for any chat secret not already set in env.
4
+ * Chat-service secret store, keyed by ChatSecretId (Discord, Slack). Backed by
5
+ * the env-store (`.env` file + process.env + Windows registry mirror) — the OS
6
+ * keychain (keytar) has been removed. Reads come straight from process.env.
11
7
  */
12
8
  export type ChatSecretId = "discord-token" | "discord-guild-id" | "slack-token" | "slack-team-id";
13
9
  export declare function setChatSecret(id: ChatSecretId, value: string): Promise<boolean>;
14
10
  export declare function getChatSecret(id: ChatSecretId): Promise<string | null>;
15
11
  export declare function deleteChatSecret(id: ChatSecretId): Promise<boolean>;
16
12
  /**
17
- * List all stored chat secret IDs.
18
- * Note: This only returns secrets stored in OS keychain, not those in env.
13
+ * List all stored chat secret IDs (those present in the environment).
19
14
  */
20
15
  export declare function listChatSecrets(): Promise<ChatSecretId[]>;
21
16
  /**
22
- * On CLI startup, populate process.env from keychain for any chat secret
23
- * not already set in env. Allows downstream code (chat/factory.ts) to
24
- * remain env-based without code changes.
17
+ * Retained for boot compatibility (index.ts). Chat secrets now live in the
18
+ * environment (loaded from the env-store at startup), so there is nothing to
19
+ * hydrate this is a no-op.
25
20
  */
26
21
  export declare function hydrateChatEnvFromKeychain(): Promise<void>;
@@ -1,22 +1,12 @@
1
1
  /**
2
2
  * src/chat/chat-keychain.ts
3
3
  *
4
- * Chat-service keychain loader with env-var fallback.
5
- * Parallel module to src/mcp/mcp-keychain.ts but keyed by ChatSecretId
6
- * (chat services like Discord, Slack) instead of MCP servers.
7
- *
8
- * Priority: OS keychain (keytar) > environment variable > null.
9
- * On CLI startup, hydrateChatEnvFromKeychain() populates process.env from
10
- * keychain for any chat secret not already set in env.
4
+ * Chat-service secret store, keyed by ChatSecretId (Discord, Slack). Backed by
5
+ * the env-store (`.env` file + process.env + Windows registry mirror) — the OS
6
+ * keychain (keytar) has been removed. Reads come straight from process.env.
11
7
  */
8
+ import { clearEnvVar, persistEnvVar } from "../providers/env-store.js";
12
9
  import { redactor } from "../utils/redactor.js";
13
- const KEYCHAIN_SERVICE = "muonroi-cli";
14
- const ACCOUNT_BY_CHAT = {
15
- "discord-token": "chat-discord-token",
16
- "discord-guild-id": "chat-discord-guild-id",
17
- "slack-token": "chat-slack-token",
18
- "slack-team-id": "chat-slack-team-id",
19
- };
20
10
  const ENV_BY_CHAT = {
21
11
  "discord-token": "MUONROI_DISCORD_TOKEN",
22
12
  "discord-guild-id": "MUONROI_DISCORD_GUILD_ID",
@@ -24,107 +14,50 @@ const ENV_BY_CHAT = {
24
14
  "slack-team-id": "MUONROI_SLACK_TEAM_ID",
25
15
  };
26
16
  const MIN_LEN = 8; // guild IDs are short (18-19 digits); tokens ~70 chars. Use 8 as floor.
27
- async function loadKeytar() {
28
- try {
29
- return (await import("keytar"));
30
- }
31
- catch {
32
- return null;
33
- }
17
+ function isTokenSecret(id) {
18
+ return id === "discord-token" || id === "slack-token";
34
19
  }
35
20
  export async function setChatSecret(id, value) {
36
21
  if (!value || value.length < MIN_LEN) {
37
22
  throw new Error(`Value for chat secret '${id}' is too short (< ${MIN_LEN} chars).`);
38
23
  }
39
- const kt = await loadKeytar();
40
- if (!kt?.setPassword)
41
- return false;
42
- // Enroll token values in redactor, but not IDs (they are not secrets)
43
- if (id === "discord-token" || id === "slack-token") {
24
+ if (isTokenSecret(id))
44
25
  redactor.enrollSecret(value);
45
- }
46
- try {
47
- await kt.setPassword(KEYCHAIN_SERVICE, ACCOUNT_BY_CHAT[id], value);
48
- return true;
49
- }
50
- catch (err) {
51
- // Runtime backend failure (e.g. Linux without libsecret or no active secret service).
52
- if (process.env.DEBUG || process.env.MUONROI_DEBUG_KEYCHAIN) {
53
- console.error(`[chat-keychain] setPassword backend error for ${id}:`, err?.message || err);
54
- }
55
- return false;
56
- }
26
+ persistEnvVar(ENV_BY_CHAT[id], value);
27
+ return true;
57
28
  }
58
29
  export async function getChatSecret(id) {
59
- const kt = await loadKeytar();
60
- if (kt) {
61
- try {
62
- const v = await kt.getPassword(KEYCHAIN_SERVICE, ACCOUNT_BY_CHAT[id]);
63
- if (v && v.length >= MIN_LEN) {
64
- // Enroll in redactor only for token values
65
- if (id === "discord-token" || id === "slack-token") {
66
- redactor.enrollSecret(v);
67
- }
68
- return v;
69
- }
70
- }
71
- catch {
72
- /* keytar backend failure → fall through to env */
73
- }
74
- }
75
30
  const envKey = process.env[ENV_BY_CHAT[id]];
76
31
  if (envKey && envKey.length >= MIN_LEN) {
77
- // Enroll in redactor only for token values
78
- if (id === "discord-token" || id === "slack-token") {
32
+ if (isTokenSecret(id))
79
33
  redactor.enrollSecret(envKey);
80
- }
81
34
  return envKey;
82
35
  }
83
36
  return null;
84
37
  }
85
38
  export async function deleteChatSecret(id) {
86
- const kt = await loadKeytar();
87
- if (!kt?.deletePassword)
88
- return false;
89
- try {
90
- return await kt.deletePassword(KEYCHAIN_SERVICE, ACCOUNT_BY_CHAT[id]);
91
- }
92
- catch (err) {
93
- if (process.env.DEBUG || process.env.MUONROI_DEBUG_KEYCHAIN) {
94
- console.error(`[chat-keychain] deletePassword backend error for ${id}:`, err?.message || err);
95
- }
96
- return false;
97
- }
39
+ const had = !!process.env[ENV_BY_CHAT[id]];
40
+ clearEnvVar(ENV_BY_CHAT[id]);
41
+ return had;
98
42
  }
99
43
  /**
100
- * List all stored chat secret IDs.
101
- * Note: This only returns secrets stored in OS keychain, not those in env.
44
+ * List all stored chat secret IDs (those present in the environment).
102
45
  */
103
46
  export async function listChatSecrets() {
104
47
  const ids = ["discord-token", "discord-guild-id", "slack-token", "slack-team-id"];
105
48
  const stored = [];
106
49
  for (const id of ids) {
107
- const v = await getChatSecret(id);
108
- if (v) {
50
+ if (await getChatSecret(id))
109
51
  stored.push(id);
110
- }
111
52
  }
112
53
  return stored;
113
54
  }
114
55
  /**
115
- * On CLI startup, populate process.env from keychain for any chat secret
116
- * not already set in env. Allows downstream code (chat/factory.ts) to
117
- * remain env-based without code changes.
56
+ * Retained for boot compatibility (index.ts). Chat secrets now live in the
57
+ * environment (loaded from the env-store at startup), so there is nothing to
58
+ * hydrate this is a no-op.
118
59
  */
119
60
  export async function hydrateChatEnvFromKeychain() {
120
- const ids = ["discord-token", "discord-guild-id", "slack-token", "slack-team-id"];
121
- for (const id of ids) {
122
- const envName = ENV_BY_CHAT[id];
123
- if (process.env[envName])
124
- continue; // env wins
125
- const v = await getChatSecret(id);
126
- if (v)
127
- process.env[envName] = v;
128
- }
61
+ // Intentional no-op: env-store already populated process.env at startup.
129
62
  }
130
63
  //# sourceMappingURL=chat-keychain.js.map