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
@@ -6,11 +6,13 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { clearLastSurfacedMatches, getDefaultEEClient, getLastSurfacedMatches } from "../ee/intercept.js";
8
8
  import { deliberateCompact } from "../flow/compaction/index.js";
9
+ import { stageProgress } from "../flow/compaction/progress.js";
9
10
  import { writeScaffoldCheckpoint } from "../flow/scaffold-checkpoint.js";
10
11
  import { appendCrashLog, setActiveEeYield } from "../index.js";
11
12
  import { POPULAR_MCP_CATALOG } from "../mcp/catalog.js";
12
13
  import { parseEnvLines, parseHeaderLines } from "../mcp/parse-headers.js";
13
14
  import { toMcpServerId, validateMcpServerConfig } from "../mcp/validate.js";
15
+ import { ASK_USER_DISMISSED, buildAskUserQuestion } from "../orchestrator/ask-user.js";
14
16
  import { createCompactionSummaryMessage } from "../orchestrator/compaction.js";
15
17
  import { Agent } from "../orchestrator/orchestrator.js";
16
18
  import { buildCompactResumeMessage, detectProactiveCompactRequest, } from "../orchestrator/proactive-compact-detector.js";
@@ -39,9 +41,12 @@ import { initialCardState, reduceCardKey, } from "./components/council-question-
39
41
  import { cycleRoundSelection } from "./components/council-rail-rounds.js";
40
42
  import { reapStatuses, upsertStatus } from "./components/council-status-list.js";
41
43
  import { BB_TEMPLATE_OPTIONS, FE_STACK_OPTIONS, initialInitNewFormState, } from "./components/init-new-form-card.js";
44
+ import { findLastCollapsibleIndex } from "./components/message-view.js";
45
+ import { groupToolEntries } from "./utils/group-tool-entries.js";
42
46
  import { initialPointToExistingFormState, } from "./components/point-to-existing-form-card.js";
43
47
  import { useRolePalette } from "./components/role-palette.js";
44
48
  import { usePairQuoteBuffer } from "./components/use-pair-quote-buffer.js";
49
+ import { mapCouncilStatusToSpeakerEvent } from "./council-harness-event.js";
45
50
  import { useAgentEditor } from "./hooks/use-agent-editor.js";
46
51
  import { useMcpEditor } from "./hooks/use-mcp-editor.js";
47
52
  import { useModelPicker } from "./hooks/use-model-picker.js";
@@ -599,7 +604,11 @@ export function useAppLogic(props) {
599
604
  setConfiguredProviders(resolvePickerProviders(SPLASH_PROVIDERS, configured, (p) => getModelsForProvider(p).length > 0));
600
605
  setProvidersWithKey(new Set(configured));
601
606
  }
602
- catch {
607
+ catch (err) {
608
+ // This resets every chip to "no key — press K". Silently, that reads as
609
+ // "the key I just set was not saved" when the credentials are in fact on
610
+ // disk and only the lookup failed — so say so instead of swallowing it.
611
+ console.error(`[providers] credential lookup failed; chips will show no key: ${err instanceof Error ? err.message : String(err)}`);
603
612
  setConfiguredProviders([...SPLASH_PROVIDERS]);
604
613
  setProvidersWithKey(new Set());
605
614
  }
@@ -629,9 +638,19 @@ export function useAppLogic(props) {
629
638
  try {
630
639
  const ok = await setKeyForProvider(apiKeyPrompt.provider, key);
631
640
  if (!ok) {
632
- setApiKeyPrompt({ ...apiKeyPrompt, error: "Keychain unavailable set env var instead" });
641
+ setApiKeyPrompt({ ...apiKeyPrompt, error: "Could not store key. Try `export <PROVIDER>_API_KEY=…`." });
633
642
  return;
634
643
  }
644
+ // Same reason as the OAuth path: boot skips a provider with no
645
+ // credentials, so without this the key is stored but the provider stays
646
+ // unusable until the next start.
647
+ try {
648
+ const { rewarmProviderFactory } = await import("../providers/warm.js");
649
+ await rewarmProviderFactory(apiKeyPrompt.provider);
650
+ }
651
+ catch (err) {
652
+ console.error(`[providers] ${apiKeyPrompt.provider} key stored but its factory could not be rebuilt; a restart may be needed: ${err instanceof Error ? err.message : String(err)}`);
653
+ }
635
654
  await refreshProvidersWithKey();
636
655
  setApiKeyPrompt(null);
637
656
  }
@@ -639,85 +658,92 @@ export function useAppLogic(props) {
639
658
  setApiKeyPrompt({ ...apiKeyPrompt, error: e.message });
640
659
  }
641
660
  }, [apiKeyPrompt, refreshProvidersWithKey]);
642
- const [bwSync, setBwSync] = useState(null);
643
- const submitBwPassword = useCallback(async () => {
644
- if (!bwSync || bwSync.phase !== "password")
645
- return;
646
- const password = bwSync.value;
647
- if (password.length < 4) {
648
- setBwSync({ ...bwSync, error: "Master password too short" });
649
- return;
650
- }
651
- setBwSync({ ...bwSync, loading: true, error: null });
661
+ // ── OAuth subscription login (browser-based) for openai / xai ───────────
662
+ // One auth mode per provider: a successful OAuth login clears any stored API
663
+ // key for that provider (exclusivity). Runs the registry login() flow, which
664
+ // opens the browser and resolves when the loopback callback completes.
665
+ const [oauthProviders, setOAuthProviders] = useState(() => new Set());
666
+ const [oauthLogin, setOAuthLogin] = useState(null);
667
+ const oauthCancelRef = useRef(false);
668
+ // Aborts the in-flight sign-in itself, not just the UI's interest in it —
669
+ // see cancelProviderOAuth.
670
+ const oauthAbortRef = useRef(null);
671
+ // Populate the OAuth-capable provider set once, from the OAuth registry.
672
+ useEffect(() => {
673
+ let alive = true;
674
+ void (async () => {
675
+ try {
676
+ const { listOAuthProviderIds } = await import("../providers/auth/registry.js");
677
+ const ids = await listOAuthProviderIds();
678
+ if (alive)
679
+ setOAuthProviders(new Set(ids));
680
+ }
681
+ catch {
682
+ /* registry unavailable — no OAuth affordance */
683
+ }
684
+ })();
685
+ return () => {
686
+ alive = false;
687
+ };
688
+ }, []);
689
+ const startProviderOAuth = useCallback(async (provider) => {
690
+ oauthCancelRef.current = false;
691
+ // Abort the PREVIOUS attempt's server before starting another: the
692
+ // browser flow binds a loopback callback on a two-port set, so a
693
+ // still-running one would make this attempt fail to bind.
694
+ oauthAbortRef.current?.abort();
695
+ oauthAbortRef.current = new AbortController();
696
+ setOAuthLogin({ provider, error: null });
652
697
  try {
653
- const { unlockWithPassword, listSecureNotesByPrefix } = await import("../cli/bw-vault.js");
654
- const unlock = await unlockWithPassword(password);
655
- if (!unlock.ok || !unlock.session) {
656
- setBwSync({ ...bwSync, loading: false, error: unlock.error ?? "bw unlock failed" });
698
+ const { getOAuthProviderConfig } = await import("../providers/auth/registry.js");
699
+ const cfg = await getOAuthProviderConfig(provider);
700
+ if (!cfg) {
701
+ setOAuthLogin({ provider, error: "OAuth is not available for this provider." });
657
702
  return;
658
703
  }
659
- const prefix = "muonroi-cli/";
660
- const list = await listSecureNotesByPrefix(unlock.session, prefix);
661
- if (!list.ok) {
662
- setBwSync({ ...bwSync, loading: false, error: list.error });
704
+ const tokens = await cfg.provider.login({ signal: oauthAbortRef.current?.signal });
705
+ if (oauthCancelRef.current)
663
706
  return;
707
+ const { saveTokens } = await import("../providers/auth/token-store.js");
708
+ await saveTokens(provider, tokens);
709
+ // Exclusivity: OAuth login clears any stored API key for this provider.
710
+ try {
711
+ const { clearEnvVar } = await import("../providers/env-store.js");
712
+ const { ENV_BY_PROVIDER } = await import("../providers/keychain.js");
713
+ clearEnvVar(ENV_BY_PROVIDER[provider]);
664
714
  }
665
- const matched = [];
666
- for (const it of list.items) {
667
- const providerName = it.name.slice(prefix.length);
668
- if (SPLASH_PROVIDERS.includes(providerName) && it.notes.length >= 20) {
669
- matched.push({ provider: providerName, key: it.notes });
670
- }
671
- }
672
- if (matched.length === 0) {
673
- setBwSync({
674
- ...bwSync,
675
- loading: false,
676
- error: `No items found in vault with prefix '${prefix}<provider>'`,
677
- });
678
- return;
715
+ catch (err) {
716
+ console.error(`[providers] could not clear the stored ${provider} key after OAuth login: ${err instanceof Error ? err.message : String(err)}`);
679
717
  }
680
- setBwSync({
681
- phase: "picker",
682
- session: unlock.session,
683
- items: matched,
684
- selected: new Set(matched.map((m) => m.provider)),
685
- focusIndex: 0,
686
- loading: false,
687
- error: null,
688
- });
689
- }
690
- catch (e) {
691
- setBwSync({ ...bwSync, loading: false, error: e.message });
692
- }
693
- }, [bwSync]);
694
- const commitBwImport = useCallback(async () => {
695
- if (!bwSync || bwSync.phase !== "picker")
696
- return;
697
- setBwSync({ ...bwSync, loading: true, error: null });
698
- let imported = 0;
699
- let failed = 0;
700
- for (const item of bwSync.items) {
701
- if (!bwSync.selected.has(item.provider))
702
- continue;
718
+ // The factory bakes in the auth it saw when it was built, so the tokens
719
+ // we just saved reach nothing until it is rebuilt — that is why signing
720
+ // in only took effect after restarting the session.
703
721
  try {
704
- const ok = await setKeyForProvider(item.provider, item.key);
705
- if (ok)
706
- imported++;
707
- else
708
- failed++;
722
+ const { rewarmProviderFactory } = await import("../providers/warm.js");
723
+ await rewarmProviderFactory(provider);
709
724
  }
710
- catch {
711
- failed++;
725
+ catch (err) {
726
+ console.error(`[providers] ${provider} signed in but its factory could not be rebuilt; a restart may be needed: ${err instanceof Error ? err.message : String(err)}`);
712
727
  }
728
+ await refreshProvidersWithKey();
729
+ setOAuthLogin(null);
713
730
  }
714
- await refreshProvidersWithKey();
715
- if (failed > 0 && imported === 0) {
716
- setBwSync({ ...bwSync, loading: false, error: `Imported 0; ${failed} failed (keychain unavailable?)` });
717
- return;
718
- }
719
- setBwSync(null);
720
- }, [bwSync, refreshProvidersWithKey]);
731
+ catch (e) {
732
+ if (oauthCancelRef.current)
733
+ return;
734
+ setOAuthLogin({ provider, error: e.message });
735
+ }
736
+ }, [refreshProvidersWithKey]);
737
+ const cancelProviderOAuth = useCallback(() => {
738
+ oauthCancelRef.current = true;
739
+ // Esc used to close the card and leave the sign-in running: its loopback
740
+ // server kept port 1455/1457 for the full 5-minute callback timeout, so the
741
+ // next attempt could not bind and only restarting the CLI recovered it.
742
+ // Abort ends the flow, which closes the server and frees the port now.
743
+ oauthAbortRef.current?.abort();
744
+ oauthAbortRef.current = null;
745
+ setOAuthLogin(null);
746
+ }, []);
721
747
  // Sync React model state with status bar store so chat input reflects
722
748
  // per-turn router upgrades (brain EE upgrade, warm/cold routing, etc.)
723
749
  useEffect(() => {
@@ -728,7 +754,7 @@ export function useAppLogic(props) {
728
754
  }, [setModel]);
729
755
  const initialHasApiKey = agent.hasApiKey();
730
756
  const [hasApiKey, setHasApiKey] = useState(initialHasApiKey);
731
- const [messages, setMessages] = useState(() => agent.getChatEntries());
757
+ const [messages, setMessages] = useState(() => groupToolEntries(agent.getChatEntries()));
732
758
  const [streamContent, setStreamContent] = useState("");
733
759
  // Reasoning state: track activity + last-elapsed for a "💭 Thought for Ns"
734
760
  // pill instead of dumping CoT into the chat (saves 80–120 setState/sec on
@@ -808,6 +834,8 @@ export function useAppLogic(props) {
808
834
  // Peek toggle (ctrl+e) for the todo panel while it auto-collapses during a
809
835
  // council debate. Default collapsed; expands back to the full panel on demand.
810
836
  const [councilTodoExpanded, setCouncilTodoExpanded] = useState(false);
837
+ // Non-null only while a /compact is in flight; drives CompactProgressCard.
838
+ const [compactRun, setCompactRun] = useState(null);
811
839
  const [councilInfoCards, setCouncilInfoCards] = useState([]);
812
840
  // P3 — council metadata for the context rail (leader/panel/budget/research/
813
841
  // cost), upsert-merged from incremental council_meta patches.
@@ -824,6 +852,14 @@ export function useAppLogic(props) {
824
852
  // global (all/live rounds). Mirrored into a ref so the global key handler can
825
853
  // read the current round list without re-subscribing on every round update.
826
854
  const [selectedRound, setSelectedRound] = useState(null);
855
+ // Mirrors councilStatuses for the global key handler: ctrl+e only belongs to
856
+ // the council todo panel while that panel is actually auto-collapsed, which
857
+ // app.tsx gates on `councilStatuses.length > 0`. Reading it from a ref keeps
858
+ // handleKey off the councilStatuses dep list.
859
+ const councilStatusesRef = useRef([]);
860
+ useEffect(() => {
861
+ councilStatusesRef.current = councilStatuses;
862
+ }, [councilStatuses]);
827
863
  const councilRoundsRef = useRef([]);
828
864
  useEffect(() => {
829
865
  councilRoundsRef.current = councilRounds;
@@ -1018,6 +1054,10 @@ export function useAppLogic(props) {
1018
1054
  // Pending resolvers for safety-override askcards. Keyed by questionId; populated
1019
1055
  // by setSafetyOverrideHandler, drained by the askcard answer/cancel branches.
1020
1056
  const safetyOverrideResolversRef = useRef(new Map());
1057
+ // Pending resolvers for ask_user askcards (the model-callable ask_user tool).
1058
+ // Keyed by questionId; populated by setAskUserHandler, drained by the askcard
1059
+ // answer/cancel branches. Resolve value = the human's answer string.
1060
+ const askUserResolversRef = useRef(new Map());
1021
1061
  // Current todo snapshot (Claude-style sticky checklist). Updated by
1022
1062
  // `task_list_update` chunks emitted after every `todo_write` tool call.
1023
1063
  // Auto-hides ~2s after 100% completion so the panel doesn't linger.
@@ -1032,7 +1072,9 @@ export function useAppLogic(props) {
1032
1072
  // throws (internal try/catch → []).
1033
1073
  // biome-ignore lint/correctness/useExhaustiveDependencies: sessionId + messages.length are intentional recompute signals (getSessionTree reads the current session internally)
1034
1074
  const sessionTree = useMemo(() => agent.getSessionTree(), [agent, sessionId, messages.length]);
1035
- const [showApiKeyModal, setShowApiKeyModal] = useState(() => !initialHasApiKey);
1075
+ // Boot straight to chat: no forced API-key modal on start. Auth is on-demand
1076
+ // via the provider picker (opens on a no-auth send, or via /providers).
1077
+ const [showApiKeyModal, setShowApiKeyModal] = useState(false);
1036
1078
  const [apiKeyError, setApiKeyError] = useState(null);
1037
1079
  const [showSlashMenu, setShowSlashMenu] = useState(false);
1038
1080
  // Ref stays current synchronously so keyboard-burst handlers read the right value
@@ -1085,6 +1127,18 @@ export function useAppLogic(props) {
1085
1127
  const newSinceLockRef = useRef(0);
1086
1128
  const [newSinceLock, setNewSinceLock] = useState(0);
1087
1129
  const [scrollLockedAway, setScrollLockedAway] = useState(false);
1130
+ // Transcript length when the user last left the bottom. The pill counts new
1131
+ // *entries* against this watermark. It must NOT count scrollToBottom() calls:
1132
+ // those fire per 32ms stream flush and per tool event, so a single streaming
1133
+ // answer used to push the pill into the hundreds ("251 new below").
1134
+ const lockedAtCountRef = useRef(0);
1135
+ // Ref mirror of scrollLockedAway so the scroll helpers can read the current
1136
+ // lock state synchronously (React state lags a render behind).
1137
+ const lockedAwayRef = useRef(false);
1138
+ const messageCountRef = useRef(0);
1139
+ useEffect(() => {
1140
+ messageCountRef.current = messages.length;
1141
+ }, [messages.length]);
1088
1142
  // Context rail (MUONROI_CONTEXT_RAIL): user can hide/show the right metadata
1089
1143
  // panel with Ctrl+B. Defaults visible; the rail also auto-hides below 100 cols
1090
1144
  // (decided in app.tsx where terminal width is known).
@@ -1103,29 +1157,49 @@ export function useAppLogic(props) {
1103
1157
  const vpH = sb.viewport.height ?? 0;
1104
1158
  return sb.scrollTop + vpH >= sb.scrollHeight - 2;
1105
1159
  }, []);
1160
+ // Retire the jump-to-latest pill. Single writer for the three lock fields so
1161
+ // the ref mirror can never drift from the rendered state.
1162
+ const clearScrollLock = useCallback(() => {
1163
+ lockedAwayRef.current = false;
1164
+ lockedAtCountRef.current = messageCountRef.current;
1165
+ if (newSinceLockRef.current !== 0) {
1166
+ newSinceLockRef.current = 0;
1167
+ setNewSinceLock(0);
1168
+ }
1169
+ setScrollLockedAway(false);
1170
+ }, []);
1171
+ // Engage the lock. Single writer, mirroring clearScrollLock. The first call
1172
+ // after leaving the bottom sets the watermark; later calls are no-ops, so
1173
+ // repeated scroll attempts for the SAME content (32ms stream flushes, tool
1174
+ // events) can never inflate the pill.
1175
+ const engageScrollLock = useCallback(() => {
1176
+ if (!isScrollLockEnabled() || lockedAwayRef.current)
1177
+ return;
1178
+ lockedAwayRef.current = true;
1179
+ lockedAtCountRef.current = messageCountRef.current;
1180
+ setScrollLockedAway(true);
1181
+ }, []);
1106
1182
  // Soft scroll: respects scroll-lock. New content that arrives while the user
1107
1183
  // reads history does NOT move the viewport; it just increments the pill count.
1108
1184
  const scrollToBottom = useCallback(() => {
1109
1185
  if (isScrollLockEnabled() && !isPinnedToBottom()) {
1110
- newSinceLockRef.current += 1;
1111
- setNewSinceLock(newSinceLockRef.current);
1112
- setScrollLockedAway(true);
1186
+ engageScrollLock();
1187
+ const fresh = Math.max(0, messageCountRef.current - lockedAtCountRef.current);
1188
+ newSinceLockRef.current = fresh;
1189
+ setNewSinceLock(fresh);
1113
1190
  return;
1114
1191
  }
1115
1192
  // Pinned (or lock off): scroll, and if the user had manually scrolled back
1116
1193
  // to the bottom on their own, retire the jump-to-latest pill.
1117
- if (newSinceLockRef.current !== 0) {
1118
- newSinceLockRef.current = 0;
1119
- setNewSinceLock(0);
1120
- setScrollLockedAway(false);
1121
- }
1194
+ if (lockedAwayRef.current || newSinceLockRef.current !== 0)
1195
+ clearScrollLock();
1122
1196
  try {
1123
1197
  scrollRef.current?.scrollTo(scrollRef.current?.scrollHeight ?? 99999);
1124
1198
  }
1125
1199
  catch {
1126
1200
  /* */
1127
1201
  }
1128
- }, [isPinnedToBottom]);
1202
+ }, [isPinnedToBottom, clearScrollLock, engageScrollLock]);
1129
1203
  // Hard scroll: re-arms native sticky and jumps to the latest line regardless
1130
1204
  // of manual-scroll state. Used by explicit user actions (new prompt submit,
1131
1205
  // jump-to-latest pill) that intend to return to the live tail.
@@ -1141,10 +1215,8 @@ export function useAppLogic(props) {
1141
1215
  /* */
1142
1216
  }
1143
1217
  }
1144
- newSinceLockRef.current = 0;
1145
- setNewSinceLock(0);
1146
- setScrollLockedAway(false);
1147
- }, []);
1218
+ clearScrollLock();
1219
+ }, [clearScrollLock]);
1148
1220
  const { width, height } = useTerminalDimensions();
1149
1221
  const processedInitial = useRef(false);
1150
1222
  const contentAccRef = useRef("");
@@ -1156,7 +1228,7 @@ export function useAppLogic(props) {
1156
1228
  const originalIdealPromptRef = useRef(null);
1157
1229
  const isProcessingRef = useRef(false);
1158
1230
  const hasApiKeyRef = useRef(initialHasApiKey);
1159
- const showApiKeyModalRef = useRef(!initialHasApiKey);
1231
+ const showApiKeyModalRef = useRef(false);
1160
1232
  const queuedMessagesRef = useRef([]);
1161
1233
  const processMessageRef = useRef(() => { });
1162
1234
  const [queuedMessages, setQueuedMessages] = useState([]);
@@ -1789,6 +1861,16 @@ export function useAppLogic(props) {
1789
1861
  setCouncilRounds([]);
1790
1862
  setSelectedRound(null);
1791
1863
  setCouncilPlaceholders(new Map());
1864
+ // The auto-council path drives councilPhases through the MAIN stream loop,
1865
+ // which — unlike the nested /council and /ideal loops — never reset it on
1866
+ // turn end. On a hung/watchdog-aborted council turn the running phase never
1867
+ // emits its `state:"done"` event, so <CouncilPhaseTimeline> stays frozen at
1868
+ // "Council working… elapsed Ns" forever and the session LOOKS hung even after
1869
+ // the turn actually finalized (live: reasoning-model hang c1d461439618).
1870
+ // Clearing here (called at both beginLiveTurn and finalizeActiveTurn) tears
1871
+ // the timeline down on every turn boundary. The completed phases were already
1872
+ // rendered live; the persisted [Council Decision] message is the durable record.
1873
+ setCouncilPhases([]);
1792
1874
  councilTopicRef.current = undefined;
1793
1875
  }, []);
1794
1876
  const finishTurnProcessing = useCallback(() => {
@@ -2102,6 +2184,39 @@ export function useAppLogic(props) {
2102
2184
  safetyOverrideResolversRef.current.clear();
2103
2185
  };
2104
2186
  }, [agent, setPendingCouncilQuestionSync, setCouncilCardStateSync]);
2187
+ // ask_user handler — surfaces an AGENT-authored card (question + options come
2188
+ // ONLY from the model's tool input; the CLI synthesises nothing) and blocks
2189
+ // until the human answers or dismisses. The resolved string becomes the tool
2190
+ // result the agent reads to decide its next step.
2191
+ useEffect(() => {
2192
+ agent.setAskUserHandler(async (info) => {
2193
+ return new Promise((resolve) => {
2194
+ const qid = `ask-user-${Date.now()}`;
2195
+ askUserResolversRef.current.set(qid, resolve);
2196
+ const question = buildAskUserQuestion(info, qid);
2197
+ setPendingCouncilQuestionSync(question);
2198
+ setCouncilCardStateSync(initialCardState(question));
2199
+ try {
2200
+ agentRuntime?.emitEvent({
2201
+ t: "event",
2202
+ kind: "askcard-open",
2203
+ questionId: qid,
2204
+ question: question.question,
2205
+ phase: "ask-user",
2206
+ optionCount: question.options?.length ?? 0,
2207
+ defaultIndex: question.defaultIndex ?? 0,
2208
+ });
2209
+ }
2210
+ catch {
2211
+ /* best-effort */
2212
+ }
2213
+ });
2214
+ });
2215
+ return () => {
2216
+ agent.setAskUserHandler(null);
2217
+ askUserResolversRef.current.clear();
2218
+ };
2219
+ }, [agent, setPendingCouncilQuestionSync, setCouncilCardStateSync]);
2105
2220
  const finalizeActiveTurn = useCallback(({ wasInterrupted = false, hadError = false } = {}) => {
2106
2221
  const activeTurn = activeTurnRef.current;
2107
2222
  if (!activeTurn) {
@@ -2134,7 +2249,11 @@ export function useAppLogic(props) {
2134
2249
  prevUserIdx++;
2135
2250
  }
2136
2251
  }
2137
- return fresh;
2252
+ // The persisted transcript has one tool_result per call and no
2253
+ // tool_group, so this resync used to wipe the group the live turn
2254
+ // built and leave a flat "→ <tool>" line per call on screen — the
2255
+ // state the user actually reads after the answer lands. Re-fold them.
2256
+ return groupToolEntries(fresh);
2138
2257
  });
2139
2258
  setSessionTitle(activeTurn.agent.getSessionTitle());
2140
2259
  setSessionId(activeTurn.agent.getSessionId());
@@ -2691,6 +2810,22 @@ export function useAppLogic(props) {
2691
2810
  showApiKeyModal,
2692
2811
  showSlashMenu,
2693
2812
  ]);
2813
+ // Retire the pill when the user scrolls back to the bottom BY HAND (mouse
2814
+ // wheel / trackpad). Previously only End (scrollToBottomForced) or new content
2815
+ // arriving while pinned could clear it, so wheeling down to the newest line
2816
+ // left a stale "N new below" sitting there — the reported bug.
2817
+ // ScrollBoxRenderable exposes no scroll event (checked @opentui/core 0.1.107:
2818
+ // scrollTo/scrollBy/scrollTop, no emitter), so a poll is the only hook. It
2819
+ // runs ONLY while the pill is up, and stops the moment it clears.
2820
+ useEffect(() => {
2821
+ if (!scrollLockedAway)
2822
+ return;
2823
+ const id = setInterval(() => {
2824
+ if (isPinnedToBottom())
2825
+ clearScrollLock();
2826
+ }, 200);
2827
+ return () => clearInterval(id);
2828
+ }, [scrollLockedAway, isPinnedToBottom, clearScrollLock]);
2694
2829
  useEffect(() => {
2695
2830
  const onRawInput = (sequence) => {
2696
2831
  const parsed = parseKeypress(sequence, { useKittyKeyboard: renderer.useKittyKeyboard });
@@ -2718,7 +2853,7 @@ export function useAppLogic(props) {
2718
2853
  }, [interruptActiveRun, renderer]);
2719
2854
  const resetToNewSession = useCallback(() => {
2720
2855
  const snapshot = agent.startNewSession();
2721
- setMessages(snapshot?.entries ?? []);
2856
+ setMessages(groupToolEntries(snapshot?.entries ?? []));
2722
2857
  setExpandedMessages(new Set());
2723
2858
  activeTurnRef.current = null;
2724
2859
  clearLiveTurnUi();
@@ -2987,14 +3122,10 @@ export function useAppLogic(props) {
2987
3122
  }
2988
3123
  setCouncilStatuses((prev) => upsertStatus(prev, cs));
2989
3124
  // Task 2.2b — emit council-speaker harness event (agent-mode only).
3125
+ // Carries state:"tick" + elapsedMs so a harness poller can tell
3126
+ // a long-running research phase (alive) from a hung one.
2990
3127
  try {
2991
- agentRuntime?.emitEvent({
2992
- t: "event",
2993
- kind: "council-speaker",
2994
- role: cs.role ?? cs.label ?? "unknown",
2995
- status: cs.state === "start" ? "start" : "done",
2996
- correlationId: cs.statusId,
2997
- });
3128
+ agentRuntime?.emitEvent(mapCouncilStatusToSpeakerEvent(cs));
2998
3129
  }
2999
3130
  catch {
3000
3131
  /* best-effort */
@@ -3160,7 +3291,7 @@ export function useAppLogic(props) {
3160
3291
  appendCompaction(sessionId, nextSeq, cr.summary, cr.tokensBeforeCompress);
3161
3292
  }
3162
3293
  agent.setMessages([createCompactionSummaryMessage(cr.summary)]);
3163
- setMessages(agent.getChatEntries());
3294
+ setMessages(groupToolEntries(agent.getChatEntries()));
3164
3295
  setMessages((prev) => [
3165
3296
  ...prev,
3166
3297
  buildAssistantEntry(`⋯ Đã tự nén ngữ cảnh (${cr.tokensBeforeCompress} → ${cr.tokensAfterCompress} tokens, giữ ${cr.decisionsExtracted} quyết định; kết quả tool vẫn rehydrate được qua ee_query). Tiếp tục tác vụ...`),
@@ -3533,8 +3664,10 @@ export function useAppLogic(props) {
3533
3664
  const match = result.match(/Instructions:\s*(.+)/);
3534
3665
  const instructions = match ? match[1].trim() : "";
3535
3666
  const flowDir = path.join(agent.getCwd(), ".muonroi-flow");
3667
+ const startedAt = Date.now();
3668
+ setCompactRun({ progress: stageProgress("artifacts"), startedAt });
3536
3669
  try {
3537
- const cr = await deliberateCompact(flowDir, agent.getMessages(), "", 4096, agent.getProvider(), model, instructions);
3670
+ const cr = await deliberateCompact(flowDir, agent.getMessages(), "", 4096, model, instructions, (p) => setCompactRun((prev) => (prev ? { ...prev, progress: p } : prev)));
3538
3671
  const sessionId = agent.getSessionId();
3539
3672
  if (sessionId) {
3540
3673
  const nextSeq = getNextMessageSequence(sessionId);
@@ -3542,7 +3675,7 @@ export function useAppLogic(props) {
3542
3675
  }
3543
3676
  const summaryMsg = createCompactionSummaryMessage(cr.summary);
3544
3677
  agent.setMessages([summaryMsg]);
3545
- setMessages(agent.getChatEntries());
3678
+ setMessages(groupToolEntries(agent.getChatEntries()));
3546
3679
  setMessages((prev) => [
3547
3680
  ...prev,
3548
3681
  buildAssistantEntry(`Compaction: ${cr.decisionsExtracted} decisions extracted, ${cr.tokensBeforeCompress} → ${cr.tokensAfterCompress} tokens.\nSnapshot: ${cr.historyPath}`),
@@ -3551,6 +3684,9 @@ export function useAppLogic(props) {
3551
3684
  catch (e) {
3552
3685
  setMessages((prev) => [...prev, buildAssistantEntry(`Compaction failed: ${e}`)]);
3553
3686
  }
3687
+ finally {
3688
+ setCompactRun(null);
3689
+ }
3554
3690
  return;
3555
3691
  }
3556
3692
  if (result.startsWith("__EXPAND__") || result.startsWith("__EXPAND_JSON__")) {
@@ -3564,7 +3700,7 @@ export function useAppLogic(props) {
3564
3700
  revertLatestCompaction(sessionId);
3565
3701
  }
3566
3702
  agent.setMessages(restoredMessages);
3567
- setMessages(agent.getChatEntries());
3703
+ setMessages(groupToolEntries(agent.getChatEntries()));
3568
3704
  const text = lines.slice(2).join("\n");
3569
3705
  setMessages((prev) => [...prev, buildAssistantEntry(text)]);
3570
3706
  return;
@@ -3807,13 +3943,7 @@ export function useAppLogic(props) {
3807
3943
  setCouncilStatuses((prev) => upsertStatus(prev, cs));
3808
3944
  // Task 2.2b — emit council-speaker in branch 2 (agent-mode only).
3809
3945
  try {
3810
- agentRuntime?.emitEvent({
3811
- t: "event",
3812
- kind: "council-speaker",
3813
- role: cs.role ?? cs.label ?? "unknown",
3814
- status: cs.state === "start" ? "start" : "done",
3815
- correlationId: cs.statusId,
3816
- });
3946
+ agentRuntime?.emitEvent(mapCouncilStatusToSpeakerEvent(cs));
3817
3947
  }
3818
3948
  catch {
3819
3949
  /* best-effort */
@@ -3982,7 +4112,7 @@ export function useAppLogic(props) {
3982
4112
  isProcessingRef.current = true;
3983
4113
  setIsProcessing(true);
3984
4114
  try {
3985
- const gen = agent.runCouncilV2(topic);
4115
+ const gen = agent.runCouncilV2(topic, { convenePath: true });
3986
4116
  for await (const chunk of gen) {
3987
4117
  // Council emitted a chunk — clear the "Waiting for next phase"
3988
4118
  // inter-card heartbeat started after the last askcard answer.
@@ -4102,13 +4232,7 @@ export function useAppLogic(props) {
4102
4232
  setCouncilStatuses((prev) => upsertStatus(prev, cs));
4103
4233
  // Task 2.2b — emit council-speaker in branch 3 (agent-mode only).
4104
4234
  try {
4105
- agentRuntime?.emitEvent({
4106
- t: "event",
4107
- kind: "council-speaker",
4108
- role: cs.role ?? cs.label ?? "unknown",
4109
- status: cs.state === "start" ? "start" : "done",
4110
- correlationId: cs.statusId,
4111
- });
4235
+ agentRuntime?.emitEvent(mapCouncilStatusToSpeakerEvent(cs));
4112
4236
  }
4113
4237
  catch {
4114
4238
  /* best-effort */
@@ -4354,8 +4478,10 @@ export function useAppLogic(props) {
4354
4478
  const match = result.match(/Instructions:\s*(.+)/);
4355
4479
  const instructions = match ? match[1].trim() : "";
4356
4480
  const flowDir = path.join(agent.getCwd(), ".muonroi-flow");
4481
+ const startedAt = Date.now();
4482
+ setCompactRun({ progress: stageProgress("artifacts"), startedAt });
4357
4483
  try {
4358
- const cr = await deliberateCompact(flowDir, agent.getMessages(), "", 4096, agent.getProvider(), model, instructions);
4484
+ const cr = await deliberateCompact(flowDir, agent.getMessages(), "", 4096, model, instructions, (p) => setCompactRun((prev) => (prev ? { ...prev, progress: p } : prev)));
4359
4485
  const sessionId = agent.getSessionId();
4360
4486
  if (sessionId) {
4361
4487
  const nextSeq = getNextMessageSequence(sessionId);
@@ -4363,7 +4489,7 @@ export function useAppLogic(props) {
4363
4489
  }
4364
4490
  const summaryMsg = createCompactionSummaryMessage(cr.summary);
4365
4491
  agent.setMessages([summaryMsg]);
4366
- setMessages(agent.getChatEntries());
4492
+ setMessages(groupToolEntries(agent.getChatEntries()));
4367
4493
  setMessages((prev) => [
4368
4494
  ...prev,
4369
4495
  buildAssistantEntry(`Compaction: ${cr.decisionsExtracted} decisions extracted, ${cr.tokensBeforeCompress} → ${cr.tokensAfterCompress} tokens.\nSnapshot: ${cr.historyPath}`),
@@ -4372,6 +4498,9 @@ export function useAppLogic(props) {
4372
4498
  catch (e) {
4373
4499
  setMessages((prev) => [...prev, buildAssistantEntry(`Compaction failed: ${e}`)]);
4374
4500
  }
4501
+ finally {
4502
+ setCompactRun(null);
4503
+ }
4375
4504
  return;
4376
4505
  }
4377
4506
  if (result.startsWith("__EXPAND__") || result.startsWith("__EXPAND_JSON__")) {
@@ -4385,7 +4514,7 @@ export function useAppLogic(props) {
4385
4514
  revertLatestCompaction(sessionId);
4386
4515
  }
4387
4516
  agent.setMessages(restoredMessages);
4388
- setMessages(agent.getChatEntries());
4517
+ setMessages(groupToolEntries(agent.getChatEntries()));
4389
4518
  const text = lines.slice(2).join("\n");
4390
4519
  setMessages((prev) => [...prev, buildAssistantEntry(text)]);
4391
4520
  return;
@@ -4628,9 +4757,12 @@ export function useAppLogic(props) {
4628
4757
  }
4629
4758
  // Ctrl+E — peek the full todo panel while it is auto-collapsed during a
4630
4759
  // council debate (app.tsx collapses it to one line so the debate scrollbox
4631
- // keeps its height). Global + unconditional toggle; a no-op visually when
4632
- // no todo snapshot exists or no council is active.
4633
- if (key.name === "e" && key.ctrl && !key.meta) {
4760
+ // keeps its height). Scoped to an ACTIVE council: app.tsx only collapses
4761
+ // the panel when `councilStatuses.length > 0`, so outside a debate this
4762
+ // toggle is invisible and an unconditional `return` here swallowed the
4763
+ // key before the transcript expand handler below could ever see it,
4764
+ // making every "ctrl+e expand" affordance in the message log dead.
4765
+ if (key.name === "e" && key.ctrl && !key.meta && councilStatusesRef.current.length > 0) {
4634
4766
  setCouncilTodoExpanded((v) => !v);
4635
4767
  return;
4636
4768
  }
@@ -4678,11 +4810,16 @@ export function useAppLogic(props) {
4678
4810
  /* */
4679
4811
  }
4680
4812
  // Paging up leaves the tail; paging back to bottom clears the pill.
4681
- if (key.name === "pagedown" && isPinnedToBottom()) {
4682
- newSinceLockRef.current = 0;
4683
- setNewSinceLock(0);
4684
- setScrollLockedAway(false);
4685
- }
4813
+ // Engaging on pageup is what was missing: the lock was only ever set
4814
+ // from scrollToBottom(), i.e. when new content ARRIVED while away
4815
+ // from the tail. Paging up therefore surfaced the pill only if a
4816
+ // render happened to follow — measured as engaging on one run and
4817
+ // not the next. Setting it here makes it depend on the key, not on
4818
+ // whether the stream was still flushing.
4819
+ if (key.name === "pageup" && !isPinnedToBottom())
4820
+ engageScrollLock();
4821
+ if (key.name === "pagedown" && isPinnedToBottom())
4822
+ clearScrollLock();
4686
4823
  }
4687
4824
  return;
4688
4825
  }
@@ -5183,6 +5320,32 @@ export function useAppLogic(props) {
5183
5320
  });
5184
5321
  return;
5185
5322
  }
5323
+ // ask_user askcard: resolve the stored promise with the user's
5324
+ // answer string (option value or free text) — becomes the tool result.
5325
+ if (pendingQuestion.phase === "ask-user") {
5326
+ const resolver = askUserResolversRef.current.get(qid);
5327
+ askUserResolversRef.current.delete(qid);
5328
+ setPendingCouncilQuestionSync(null);
5329
+ setCouncilCardStateSync(null);
5330
+ resolver?.(ans.text);
5331
+ try {
5332
+ agentRuntime?.emitEvent({
5333
+ t: "event",
5334
+ kind: "askcard-answered",
5335
+ questionId: qid,
5336
+ answerKind: ans.kind ?? "choice",
5337
+ answerText: ans.text,
5338
+ });
5339
+ }
5340
+ catch {
5341
+ /* best-effort */
5342
+ }
5343
+ logUIInteraction(agent.getSessionId() ?? undefined, {
5344
+ subtype: "askcard_answered",
5345
+ data: { questionId: qid, answerKind: ans.kind ?? "choice", answerText: ans.text },
5346
+ });
5347
+ return;
5348
+ }
5186
5349
  // Resolve the label of the option that was selected. For "choice"
5187
5350
  // and "freetext" the option index lives on the card state; for
5188
5351
  // "chat" the user typed a free reply and there is no option to
@@ -5270,6 +5433,23 @@ export function useAppLogic(props) {
5270
5433
  }
5271
5434
  return;
5272
5435
  }
5436
+ // ask_user cancel (Esc) → resolve with the dismissed sentinel; the
5437
+ // agent reads it and decides its own follow-up (never CLI-forced).
5438
+ if (pendingQuestion.phase === "ask-user") {
5439
+ const resolver = askUserResolversRef.current.get(qid);
5440
+ askUserResolversRef.current.delete(qid);
5441
+ setPendingCouncilQuestionSync(null);
5442
+ setCouncilCardStateSync(null);
5443
+ clearInterCardHeartbeat();
5444
+ resolver?.(ASK_USER_DISMISSED);
5445
+ try {
5446
+ agentRuntime?.emitEvent({ t: "event", kind: "askcard-cancel", questionId: qid });
5447
+ }
5448
+ catch {
5449
+ /* best-effort */
5450
+ }
5451
+ return;
5452
+ }
5273
5453
  setPendingCouncilQuestionSync(null);
5274
5454
  setCouncilCardStateSync(null);
5275
5455
  clearInterCardHeartbeat();
@@ -5848,61 +6028,11 @@ export function useAppLogic(props) {
5848
6028
  return;
5849
6029
  }
5850
6030
  if (showModelPicker) {
5851
- // Sub-modal: BW sync (password + provider picker phases).
5852
- if (bwSync) {
6031
+ // Sub-modal: OAuth login in progress (browser-based). Only Esc (cancel)
6032
+ // is actionable — the flow completes via the browser/loopback callback.
6033
+ if (oauthLogin) {
5853
6034
  if (isEscapeKey(key)) {
5854
- setBwSync(null);
5855
- return;
5856
- }
5857
- if (bwSync.phase === "password") {
5858
- if (bwSync.loading)
5859
- return;
5860
- if (key.name === "return") {
5861
- void submitBwPassword();
5862
- return;
5863
- }
5864
- if (key.name === "backspace") {
5865
- // Functional updater — burst-safe (see API-key prompt above).
5866
- setBwSync((bw) => bw && bw.phase === "password" ? { ...bw, value: bw.value.slice(0, -1), error: null } : bw);
5867
- return;
5868
- }
5869
- if (key.sequence && !key.ctrl && !key.meta) {
5870
- // Keep spaces: a master password may legitimately contain them.
5871
- const cleaned = stripControlBytes(key.sequence);
5872
- if (cleaned.length === 0)
5873
- return;
5874
- setBwSync((bw) => bw && bw.phase === "password" ? { ...bw, value: bw.value + cleaned, error: null } : bw);
5875
- return;
5876
- }
5877
- return;
5878
- }
5879
- if (bwSync.phase === "picker") {
5880
- if (bwSync.loading)
5881
- return;
5882
- if (key.name === "up") {
5883
- setBwSync({ ...bwSync, focusIndex: Math.max(0, bwSync.focusIndex - 1) });
5884
- return;
5885
- }
5886
- if (key.name === "down") {
5887
- setBwSync({ ...bwSync, focusIndex: Math.min(bwSync.items.length - 1, bwSync.focusIndex + 1) });
5888
- return;
5889
- }
5890
- if (key.name === "space" || key.sequence === " ") {
5891
- const item = bwSync.items[bwSync.focusIndex];
5892
- if (item) {
5893
- const next = new Set(bwSync.selected);
5894
- if (next.has(item.provider))
5895
- next.delete(item.provider);
5896
- else
5897
- next.add(item.provider);
5898
- setBwSync({ ...bwSync, selected: next });
5899
- }
5900
- return;
5901
- }
5902
- if (key.name === "return") {
5903
- void commitBwImport();
5904
- return;
5905
- }
6035
+ cancelProviderOAuth();
5906
6036
  return;
5907
6037
  }
5908
6038
  return;
@@ -5963,8 +6093,10 @@ export function useAppLogic(props) {
5963
6093
  setApiKeyPrompt({ provider: p, value: "", error: null });
5964
6094
  return;
5965
6095
  }
5966
- if (key.name === "b") {
5967
- setBwSync({ phase: "password", value: "", error: null, loading: false });
6096
+ if (key.name === "o") {
6097
+ const p = configuredProviders[providerChipIndex];
6098
+ if (p && oauthProviders.has(p))
6099
+ void startProviderOAuth(p);
5968
6100
  return;
5969
6101
  }
5970
6102
  if (key.name === "space" || key.sequence === " ") {
@@ -5975,8 +6107,17 @@ export function useAppLogic(props) {
5975
6107
  }
5976
6108
  if (key.name === "d" || key.name === "return") {
5977
6109
  const p = configuredProviders[providerChipIndex];
5978
- if (p && providersWithKey.has(p))
6110
+ if (!p)
6111
+ return;
6112
+ // Enter means "use this provider". With credentials that means making
6113
+ // it the default; with none it means signing in, which is the only
6114
+ // useful thing left to do on the row (Enter was a dead key there).
6115
+ // /login is gone, so this picker is the single auth surface: K adds a
6116
+ // key, Enter signs in via OAuth where the provider supports it.
6117
+ if (providersWithKey.has(p))
5979
6118
  setAsDefaultProvider(p);
6119
+ else if (oauthProviders.has(p))
6120
+ void startProviderOAuth(p);
5980
6121
  return;
5981
6122
  }
5982
6123
  return;
@@ -6188,10 +6329,8 @@ export function useAppLogic(props) {
6188
6329
  key.stopPropagation();
6189
6330
  return;
6190
6331
  }
6191
- if (!hasApiKeyRef.current && shouldOpenApiKeyModalForKey(key)) {
6192
- openApiKeyModal();
6193
- return;
6194
- }
6332
+ // No forced modal on keystroke when unauthenticated — the user can type
6333
+ // freely; the provider picker opens on send instead (see handleSubmit).
6195
6334
  if (key.sequence === "/" && !isProcessing) {
6196
6335
  const text = inputRef.current?.plainText || "";
6197
6336
  if (!text.trim()) {
@@ -6208,20 +6347,18 @@ export function useAppLogic(props) {
6208
6347
  }
6209
6348
  }
6210
6349
  if (key.name === "e" && key.ctrl) {
6211
- let lastUserIdx = -1;
6212
- for (let i = messages.length - 1; i >= 0; i--) {
6213
- if (messages[i].type === "user") {
6214
- lastUserIdx = i;
6215
- break;
6216
- }
6217
- }
6218
- if (lastUserIdx >= 0) {
6350
+ // Toggle the NEWEST entry that actually renders a "ctrl+e" affordance —
6351
+ // long user text, collapsed model narration, chain-of-thought, or a done
6352
+ // tool group. Targeting only the last *user* message meant the key did
6353
+ // nothing for every other affordance that advertises it.
6354
+ const idx = findLastCollapsibleIndex(messages);
6355
+ if (idx >= 0) {
6219
6356
  setExpandedMessages((prev) => {
6220
6357
  const next = new Set(prev);
6221
- if (next.has(lastUserIdx))
6222
- next.delete(lastUserIdx);
6358
+ if (next.has(idx))
6359
+ next.delete(idx);
6223
6360
  else
6224
- next.add(lastUserIdx);
6361
+ next.add(idx);
6225
6362
  return next;
6226
6363
  });
6227
6364
  }
@@ -6418,10 +6555,11 @@ export function useAppLogic(props) {
6418
6555
  // these directly; omitting them froze the closure on a stale `null`, which
6419
6556
  // is why typing/pasting into the per-provider key prompt did nothing.
6420
6557
  apiKeyPrompt,
6421
- bwSync,
6558
+ oauthLogin,
6559
+ oauthProviders,
6422
6560
  submitProviderKey,
6423
- submitBwPassword,
6424
- commitBwImport,
6561
+ startProviderOAuth,
6562
+ cancelProviderOAuth,
6425
6563
  openApiKeyModal,
6426
6564
  openCatalogMcp,
6427
6565
  openMcpEditor,
@@ -6473,7 +6611,6 @@ export function useAppLogic(props) {
6473
6611
  toggleModelDisabled,
6474
6612
  pointToExistingForm,
6475
6613
  setApiKeyPrompt,
6476
- setBwSync,
6477
6614
  setCouncilCardStateSync,
6478
6615
  setPendingCouncilQuestionSync,
6479
6616
  setShowSlashMenuSync,
@@ -6491,6 +6628,7 @@ export function useAppLogic(props) {
6491
6628
  setSessionPickerIndex,
6492
6629
  scrollToBottomForced,
6493
6630
  isPinnedToBottom,
6631
+ clearScrollLock,
6494
6632
  ]);
6495
6633
  useKeyboard(handleKey);
6496
6634
  const handlePaste = useCallback((event) => {
@@ -6504,21 +6642,6 @@ export function useAppLogic(props) {
6504
6642
  setApiKeyPrompt((s) => (s ? { ...s, value: s.value + pasted, error: null } : s));
6505
6643
  return;
6506
6644
  }
6507
- if (bwSync && bwSync.phase === "password") {
6508
- event.preventDefault();
6509
- // Master passwords may contain spaces — keep them; only drop guards,
6510
- // control bytes and line breaks (which a trailing paste newline adds).
6511
- const pasted = stripControlBytes(decodePasteBytes(event.bytes)).replace(/[\r\n]+/g, "");
6512
- if (pasted) {
6513
- setBwSync((s) => (s && s.phase === "password" ? { ...s, value: s.value + pasted, error: null } : s));
6514
- }
6515
- return;
6516
- }
6517
- if (!hasApiKeyRef.current) {
6518
- event.preventDefault();
6519
- openApiKeyModal();
6520
- return;
6521
- }
6522
6645
  const text = decodePasteBytes(event.bytes);
6523
6646
  const trimmed = text.trim();
6524
6647
  const imageExts = /\.(png|jpe?g|gif|webp|svg|bmp|ico|tiff?)$/i;
@@ -6538,7 +6661,7 @@ export function useAppLogic(props) {
6538
6661
  const block = { id, content: text, lines: lineCount };
6539
6662
  replacePasteBlocks([...pasteBlocksRef.current, block]);
6540
6663
  inputRef.current?.insertText(getPasteBlockToken(block));
6541
- }, [apiKeyPrompt, bwSync, openApiKeyModal, replacePasteBlocks]);
6664
+ }, [apiKeyPrompt, replacePasteBlocks]);
6542
6665
  const handleSubmit = useCallback(() => {
6543
6666
  const raw = inputRef.current?.plainText || "";
6544
6667
  if (raw.trim()) {
@@ -6562,6 +6685,14 @@ export function useAppLogic(props) {
6562
6685
  }
6563
6686
  return;
6564
6687
  }
6688
+ // No provider configured yet: open the picker so the user can sign in
6689
+ // (OAuth) or add a key. Keep the composer text so the same message can be
6690
+ // resent as soon as a provider is connected.
6691
+ if (!hasApiKeyRef.current) {
6692
+ setShowModelPicker(true);
6693
+ setModelPickerIndex(0);
6694
+ return;
6695
+ }
6565
6696
  inputRef.current?.clear();
6566
6697
  let message = raw;
6567
6698
  const blocks = [...pasteBlocksRef.current];
@@ -6619,10 +6750,6 @@ export function useAppLogic(props) {
6619
6750
  }
6620
6751
  if (!message.trim())
6621
6752
  return;
6622
- if (!hasApiKeyRef.current) {
6623
- openApiKeyModal();
6624
- return;
6625
- }
6626
6753
  // Council question response — route answer back to council generator.
6627
6754
  // The card now owns keyboard input; this branch survives only for the
6628
6755
  // legacy code path where the user typed an answer in the main prompt
@@ -6662,7 +6789,6 @@ export function useAppLogic(props) {
6662
6789
  agent,
6663
6790
  clearLiveTurnUi,
6664
6791
  handleCommand,
6665
- openApiKeyModal,
6666
6792
  processMessage,
6667
6793
  replacePasteBlocks,
6668
6794
  scrollToBottom,
@@ -6672,6 +6798,8 @@ export function useAppLogic(props) {
6672
6798
  setShowSlashMenuSync,
6673
6799
  setPendingCouncilQuestionSync,
6674
6800
  setCouncilCardStateSync,
6801
+ setShowModelPicker,
6802
+ setModelPickerIndex,
6675
6803
  ]);
6676
6804
  // Switch to the "messages" branch (which renders log + halt-card + init-new-form +
6677
6805
  // point-to-existing-form + council-progress) whenever ANY of these overlays
@@ -6709,7 +6837,8 @@ export function useAppLogic(props) {
6709
6837
  apiKeyPrompt,
6710
6838
  blockPrompt,
6711
6839
  btwState,
6712
- bwSync,
6840
+ oauthLogin,
6841
+ oauthProviders,
6713
6842
  configuredProviders,
6714
6843
  connectModalIndex,
6715
6844
  contextStats,
@@ -6723,6 +6852,7 @@ export function useAppLogic(props) {
6723
6852
  councilMessages,
6724
6853
  councilTranscriptExpanded,
6725
6854
  councilTodoExpanded,
6855
+ compactRun,
6726
6856
  councilPhases,
6727
6857
  councilPlaceholders,
6728
6858
  councilProgress,