@gajae-code/coding-agent 0.12.10 → 0.12.12

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 (120) hide show
  1. package/CHANGELOG.md +47 -2
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +13 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +25 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/internal/managed-session-storage.d.ts +12 -2
  42. package/dist/types/session/session-manager.d.ts +40 -5
  43. package/dist/types/session/session-storage.d.ts +1 -0
  44. package/dist/types/tools/computer.d.ts +35 -2
  45. package/dist/types/tools/index.d.ts +8 -0
  46. package/dist/types/tools/todo-write.d.ts +2 -0
  47. package/dist/types/utils/npm-registry.d.ts +71 -0
  48. package/examples/extensions/README.md +38 -4
  49. package/examples/extensions/ooo-bridge.ts +15 -0
  50. package/package.json +8 -8
  51. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  52. package/src/async/job-manager.ts +9 -2
  53. package/src/cli/notify-cli.ts +226 -2
  54. package/src/cli/update-cli.ts +90 -23
  55. package/src/commands/notify.ts +27 -3
  56. package/src/commands/sdk.ts +1 -0
  57. package/src/config/file-lock-gc.ts +18 -0
  58. package/src/config/file-lock.ts +61 -16
  59. package/src/config/model-profiles.ts +31 -15
  60. package/src/coordinator/contract.ts +1 -0
  61. package/src/coordinator-mcp/question-state.ts +2 -0
  62. package/src/coordinator-mcp/server.ts +358 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  64. package/src/extensibility/extensions/index.ts +1 -0
  65. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  66. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  67. package/src/extensibility/extensions/runner.ts +15 -2
  68. package/src/extensibility/extensions/types.ts +2 -0
  69. package/src/gjc-runtime/launch-tmux.ts +53 -1
  70. package/src/internal-urls/docs-index.generated.ts +13 -12
  71. package/src/main.ts +203 -67
  72. package/src/modes/DESIGN.md +8 -5
  73. package/src/modes/components/footer.ts +10 -18
  74. package/src/modes/components/session-selector.ts +26 -3
  75. package/src/modes/components/status-line/gh.ts +23 -2
  76. package/src/modes/controllers/event-controller.ts +3 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  78. package/src/modes/controllers/input-controller.ts +11 -0
  79. package/src/modes/controllers/selector-controller.ts +8 -2
  80. package/src/modes/interactive-mode.ts +7 -1
  81. package/src/modes/utils/host-status.ts +38 -0
  82. package/src/prompts/tools/computer.md +6 -1
  83. package/src/runtime-mcp/types.ts +2 -0
  84. package/src/sdk/broker/lifecycle.ts +60 -3
  85. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  86. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  87. package/src/sdk/bus/chat-daemon-control.ts +18 -3
  88. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  89. package/src/sdk/bus/control-plane-frames.ts +51 -0
  90. package/src/sdk/bus/conversation-store.ts +29 -1
  91. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  92. package/src/sdk/bus/index.ts +70 -0
  93. package/src/sdk/bus/notification-service.ts +28 -1
  94. package/src/sdk/bus/rich-render.ts +3 -2
  95. package/src/sdk/bus/slack-daemon.ts +99 -4
  96. package/src/sdk/bus/slack-live-provider.ts +32 -2
  97. package/src/sdk/bus/slack-provider.ts +11 -0
  98. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  99. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  100. package/src/sdk/bus/telegram-daemon-contract.ts +25 -7
  101. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  102. package/src/sdk/bus/telegram-reference.ts +42 -6
  103. package/src/sdk/bus/topic-registry.ts +674 -181
  104. package/src/sdk/host/host.ts +180 -1
  105. package/src/sdk/lifecycle-session.ts +8 -2
  106. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  107. package/src/sdk/session-activation.ts +127 -0
  108. package/src/sdk/session.ts +139 -28
  109. package/src/sdk/startup-capability.ts +10 -1
  110. package/src/session/agent-session.ts +331 -30
  111. package/src/session/artifacts.ts +56 -13
  112. package/src/session/internal/managed-session-storage.ts +440 -243
  113. package/src/session/session-manager.ts +288 -44
  114. package/src/session/session-storage.ts +3 -0
  115. package/src/setup/provider-presets.json +1 -1
  116. package/src/task/index.ts +92 -44
  117. package/src/tools/computer.ts +322 -45
  118. package/src/tools/index.ts +8 -0
  119. package/src/tools/todo-write.ts +29 -11
  120. package/src/utils/npm-registry.ts +660 -0
package/src/main.ts CHANGED
@@ -76,20 +76,13 @@ import { persistTaskTokenLog, resolveTaskTokenLogDir, taskTokenLogFromUsage } fr
76
76
  import type { LspStartupServerInfo } from "./tools";
77
77
  import { getDisplayChangelogEntries, getInstalledVersionChangelogEntry, getNewEntries } from "./utils/changelog";
78
78
  import type { EventBus } from "./utils/event-bus";
79
+ import { fetchLatestPackageVersion } from "./utils/npm-registry";
79
80
 
80
81
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
81
82
  try {
82
- const response = await fetch("https://registry.npmjs.org/@gajae-code/coding-agent/latest");
83
- if (!response.ok) return undefined;
84
-
85
- const data = (await response.json()) as { version?: string };
86
- const latestVersion = data.version;
87
-
88
- if (latestVersion && Bun.semver.order(latestVersion, currentVersion) > 0) {
89
- return latestVersion;
90
- }
91
-
92
- return undefined;
83
+ // Resolved from npm config so mirrored/firewalled networks are checked too.
84
+ const { version } = await fetchLatestPackageVersion("@gajae-code/coding-agent");
85
+ return Bun.semver.order(version, currentVersion) > 0 ? version : undefined;
93
86
  } catch {
94
87
  return undefined;
95
88
  }
@@ -406,6 +399,7 @@ type StartupModelProfileArgs = {
406
399
  parsedArgs: Pick<Args, "default" | "model" | "mpreset" | "thinking">;
407
400
  startupModel?: CreateAgentSessionOptions["model"];
408
401
  startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
402
+ preferCachedModels?: boolean;
409
403
  };
410
404
 
411
405
  async function applyStartupModelProfilesWithPolicy(
@@ -416,16 +410,17 @@ async function applyStartupModelProfilesWithPolicy(
416
410
  profileName: string,
417
411
  persistDefault: boolean,
418
412
  options: { thinkingLevelOverride?: CreateAgentSessionOptions["thinkingLevel"] } = {},
419
- ): Promise<void> => {
413
+ ): Promise<boolean> => {
420
414
  try {
421
415
  await activateModelProfile(
422
416
  { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
423
417
  { persistDefault, thinkingLevelOverride: options.thinkingLevelOverride },
424
418
  );
419
+ return true;
425
420
  } catch (error) {
426
421
  if (onCredentialError && error instanceof ModelProfileCredentialError) {
427
422
  onCredentialError(error);
428
- return;
423
+ return false;
429
424
  }
430
425
  throw error;
431
426
  }
@@ -436,19 +431,40 @@ async function applyStartupModelProfilesWithPolicy(
436
431
  // deferred `--model <pattern>` path resolved inside createAgentSession.
437
432
  const explicitModel = args.parsedArgs.model ? (args.startupModel ?? args.session.model) : undefined;
438
433
  const defaultProfile = args.settings.get("modelProfile.default");
439
- if (defaultProfile || args.parsedArgs.mpreset) {
440
- await args.modelRegistry.refresh("online-if-uncached");
441
- }
434
+ const preferCachedProfiles = args.preferCachedModels === true && args.parsedArgs.mpreset !== undefined;
435
+ const applyConfiguredProfiles = async (): Promise<boolean> => {
436
+ let applied = true;
437
+ if (defaultProfile) {
438
+ applied =
439
+ (await applyProfile(defaultProfile, false, {
440
+ thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
441
+ ? args.settings.get("defaultThinkingLevel")
442
+ : undefined,
443
+ })) && applied;
444
+ }
445
+ if (args.parsedArgs.mpreset) {
446
+ applied = (await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true)) && applied;
447
+ }
448
+ return applied;
449
+ };
442
450
 
443
- if (defaultProfile) {
444
- await applyProfile(defaultProfile, false, {
445
- thinkingLevelOverride: args.settings.has("defaultThinkingLevel")
446
- ? args.settings.get("defaultThinkingLevel")
447
- : undefined,
448
- });
449
- }
450
- if (args.parsedArgs.mpreset) {
451
- await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true);
451
+ if (preferCachedProfiles) {
452
+ let applied: boolean;
453
+ let refreshedOnline = false;
454
+ try {
455
+ applied = await applyConfiguredProfiles();
456
+ } catch (error) {
457
+ if (error instanceof ModelProfileCredentialError) throw error;
458
+ await args.modelRegistry.refresh("online-if-uncached");
459
+ refreshedOnline = true;
460
+ applied = await applyConfiguredProfiles();
461
+ }
462
+ if (applied && !refreshedOnline) args.modelRegistry.refreshInBackground();
463
+ } else {
464
+ if (defaultProfile || args.parsedArgs.mpreset) {
465
+ await args.modelRegistry.refresh("online-if-uncached");
466
+ }
467
+ await applyConfiguredProfiles();
452
468
  }
453
469
 
454
470
  // Explicit CLI --model/--thinking must win over any activated or skipped profile.
@@ -509,20 +525,52 @@ export async function applyStartupModelProfilesForRoot(
509
525
  resumeAction: "continue-tail" | "open-idle" | undefined;
510
526
  },
511
527
  ): Promise<{ recoverableErrors: string[] }> {
528
+ const policyArgs = { ...args, preferCachedModels: args.isInteractive };
512
529
  if (!isStartupModelProfileCredentialRecoveryEligible(args)) {
513
- await applyStartupModelProfilesOrExit(args);
530
+ try {
531
+ await applyStartupModelProfilesWithPolicy(policyArgs);
532
+ } catch (error) {
533
+ await exitForStartupModelProfileError(args, error);
534
+ }
514
535
  return { recoverableErrors: [] };
515
536
  }
516
537
 
517
538
  const recoverableErrors: string[] = [];
518
539
  try {
519
- await applyStartupModelProfilesWithPolicy(args, error => recoverableErrors.push(error.message));
540
+ await applyStartupModelProfilesWithPolicy(policyArgs, error => recoverableErrors.push(error.message));
520
541
  } catch (error) {
521
542
  await exitForStartupModelProfileError(args, error);
522
543
  }
523
544
  return { recoverableErrors };
524
545
  }
525
546
 
547
+ type DeferredModelProfileStartup = () => Promise<{ recoverableErrors: string[] }>;
548
+ class DeferredModelProfileStartupError extends Error {
549
+ readonly profileError: unknown;
550
+
551
+ constructor(profileError: unknown) {
552
+ super("Deferred model profile startup failed");
553
+ this.profileError = profileError;
554
+ }
555
+ }
556
+
557
+ async function applyDeferredStartupModelProfilesForRoot(
558
+ args: StartupModelProfileArgs & {
559
+ isInteractive: boolean;
560
+ hasInteractiveTerminal: boolean;
561
+ initialMessage: string | undefined;
562
+ initialMessages: readonly string[];
563
+ resumeAction: "continue-tail" | "open-idle" | undefined;
564
+ },
565
+ ): Promise<{ recoverableErrors: string[] }> {
566
+ const recoverableErrors: string[] = [];
567
+ const onCredentialError = isStartupModelProfileCredentialRecoveryEligible(args)
568
+ ? (error: ModelProfileCredentialError) => recoverableErrors.push(error.message)
569
+ : undefined;
570
+ await applyStartupModelProfilesWithPolicy({ ...args, preferCachedModels: true }, onCredentialError);
571
+ return { recoverableErrors };
572
+ }
573
+
526
574
  interface InteractiveModeFactoryOptions {
527
575
  session: AgentSession;
528
576
  version: string;
@@ -616,6 +664,8 @@ export async function runInteractiveMode(
616
664
  initialImages?: ImageContent[],
617
665
  createInteractiveMode?: CreateInteractiveMode,
618
666
  resumeAction?: "continue-tail" | "open-idle",
667
+ startDeferredMcpConfig?: CreateAgentSessionResult["startDeferredMcpConfig"],
668
+ startDeferredModelProfiles?: DeferredModelProfileStartup,
619
669
  ): Promise<void> {
620
670
  const mode = createInteractiveMode
621
671
  ? createInteractiveMode({
@@ -659,45 +709,98 @@ export async function runInteractiveMode(
659
709
  mode.showStatus(notify.message);
660
710
  }
661
711
  }
712
+ if (startDeferredMcpConfig) {
713
+ mode.showStatus("Connecting MCP tools…");
714
+ void startDeferredMcpConfig()
715
+ .then(result => {
716
+ if (result.hasErrors) {
717
+ mode.showWarning(
718
+ result.loadedToolCount > 0 ? "Some MCP tools could not be loaded." : "MCP tools could not be loaded.",
719
+ );
720
+ return;
721
+ }
722
+ mode.showStatus(`${result.loadedToolCount} MCP tool${result.loadedToolCount === 1 ? "" : "s"} connected`);
723
+ })
724
+ .catch(() => {
725
+ logger.warn("Deferred MCP startup failed.");
726
+ mode.showError("MCP tools could not be loaded.");
727
+ });
728
+ }
729
+ let deferredModelProfileFailure: Promise<never> | undefined;
730
+ if (startDeferredModelProfiles) {
731
+ mode.showStatus("Loading model profile…");
732
+ deferredModelProfileFailure = startDeferredModelProfiles().then(
733
+ result => {
734
+ for (const error of result.recoverableErrors) mode.showError(error);
735
+ mode.showStatus("Model profile ready");
736
+ return Promise.withResolvers<never>().promise;
737
+ },
738
+ error => {
739
+ const message = error instanceof Error ? error.message : "Model profile could not be loaded.";
740
+ mode.showError(message);
741
+ throw new DeferredModelProfileStartupError(error);
742
+ },
743
+ );
744
+ }
662
745
 
663
- const hasStartupInput = initialMessage !== undefined || initialMessages.length > 0;
664
- if (!hasStartupInput && resumeAction === "continue-tail") {
665
- try {
666
- await session.continuePersistedHistory();
667
- } catch (error: unknown) {
668
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
669
- mode.showError(errorMessage);
746
+ const runStartupInputAndPromptLoop = async (): Promise<never> => {
747
+ const hasStartupInput = initialMessage !== undefined || initialMessages.length > 0;
748
+ if (!hasStartupInput && resumeAction === "continue-tail") {
749
+ try {
750
+ await session.continuePersistedHistory();
751
+ } catch (error: unknown) {
752
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
753
+ mode.showError(errorMessage);
754
+ }
670
755
  }
671
- }
672
756
 
673
- if (initialMessage !== undefined) {
674
- try {
675
- await session.prompt(initialMessage, { images: initialImages });
676
- } catch (error: unknown) {
677
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
678
- mode.showError(errorMessage);
757
+ if (initialMessage !== undefined) {
758
+ try {
759
+ await session.prompt(initialMessage, { images: initialImages });
760
+ } catch (error: unknown) {
761
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
762
+ mode.showError(errorMessage);
763
+ }
679
764
  }
680
- }
681
765
 
682
- for (const message of initialMessages) {
683
- try {
684
- let text = message;
685
- const slashResult = await executeBuiltinSlashCommand(text, {
686
- ctx: mode,
687
- handleBackgroundCommand: () => mode.handleBackgroundCommand(),
688
- });
689
- if (slashResult === true) continue;
690
- if (typeof slashResult === "string") text = slashResult;
691
- await session.prompt(text);
692
- } catch (error: unknown) {
693
- const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
694
- mode.showError(errorMessage);
766
+ for (const message of initialMessages) {
767
+ try {
768
+ let text = message;
769
+ const slashResult = await executeBuiltinSlashCommand(text, {
770
+ ctx: mode,
771
+ handleBackgroundCommand: () => mode.handleBackgroundCommand(),
772
+ });
773
+ if (slashResult === true) continue;
774
+ if (typeof slashResult === "string") text = slashResult;
775
+ await session.prompt(text);
776
+ } catch (error: unknown) {
777
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
778
+ mode.showError(errorMessage);
779
+ }
695
780
  }
696
- }
697
781
 
698
- while (true) {
699
- const input = await mode.getUserInput();
700
- await submitInteractiveInput(mode, session, input);
782
+ while (true) {
783
+ const input = await mode.getUserInput();
784
+ await submitInteractiveInput(mode, session, input);
785
+ }
786
+ };
787
+
788
+ const inputLoop = runStartupInputAndPromptLoop();
789
+ if (!deferredModelProfileFailure) {
790
+ await inputLoop;
791
+ return;
792
+ }
793
+ try {
794
+ await Promise.race([inputLoop, deferredModelProfileFailure]);
795
+ } catch (error) {
796
+ if (!(error instanceof DeferredModelProfileStartupError)) throw error;
797
+ try {
798
+ await mode.shutdown();
799
+ } finally {
800
+ mode.stop();
801
+ }
802
+ await inputLoop.catch(() => {});
803
+ throw error.profileError;
701
804
  }
702
805
  }
703
806
 
@@ -1494,7 +1597,13 @@ export async function runRootCommand(
1494
1597
  sessionOptions.notificationHostModeSupported = isInteractive;
1495
1598
  sessionOptions.sdkHostModeSupported = isInteractive;
1496
1599
  sessionOptions.settings = settingsInstance;
1600
+ if (isInteractive && sessionOptions.mcpConfigPath) {
1601
+ sessionOptions.deferMcpConfigStartup = true;
1602
+ }
1497
1603
  const hasRootStartupProfile = Boolean(settingsInstance.get("modelProfile.default") || parsedArgs.mpreset);
1604
+ const deferMemoryBackendStartup =
1605
+ hasRootStartupProfile && !(parsedArgs.authBootstrap === true && isInteractive) && mode !== "acp";
1606
+ sessionOptions.deferMemoryBackendStartup = deferMemoryBackendStartup;
1498
1607
 
1499
1608
  // Research-mode (RLM) preset: augment session options before session creation.
1500
1609
  deps.rlmPreset?.applyOptions(sessionOptions, settingsInstance);
@@ -1574,10 +1683,16 @@ export async function runRootCommand(
1574
1683
  if (!deps.suppressProcessExit) await postmortem.cleanup();
1575
1684
  }
1576
1685
  } else {
1577
- const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager, eventBus } = await createSession(
1578
- sessionOptions,
1579
- { skipPostCreateModelRefresh: hasRootStartupProfile },
1580
- );
1686
+ const {
1687
+ session,
1688
+ setToolUIContext,
1689
+ modelFallbackMessage,
1690
+ lspServers,
1691
+ mcpManager,
1692
+ startDeferredMcpConfig,
1693
+ startDeferredMemoryBackend,
1694
+ eventBus,
1695
+ } = await createSession(sessionOptions, { skipPostCreateModelRefresh: hasRootStartupProfile });
1581
1696
  applyCliRuntimeApiKeyOverride(authStorage, parsedArgs.apiKey, session.model);
1582
1697
 
1583
1698
  // Research-mode (RLM) preset: hard tool-boundary assertion after the registry is assembled.
@@ -1594,8 +1709,9 @@ export async function runRootCommand(
1594
1709
  }
1595
1710
  }
1596
1711
 
1712
+ let startDeferredModelProfiles: DeferredModelProfileStartup | undefined;
1597
1713
  if (!(parsedArgs.authBootstrap === true && isInteractive)) {
1598
- const { recoverableErrors } = await applyStartupModelProfilesForRoot({
1714
+ const profileArgs = {
1599
1715
  session,
1600
1716
  settings: settingsInstance,
1601
1717
  modelRegistry,
@@ -1607,9 +1723,27 @@ export async function runRootCommand(
1607
1723
  initialMessage,
1608
1724
  initialMessages: parsedArgs.messages,
1609
1725
  resumeAction: bareResumeAction,
1610
- });
1611
- for (const recoverableError of recoverableErrors) {
1612
- notifs.push({ kind: "error", message: recoverableError });
1726
+ };
1727
+ if (isInteractive && parsedArgs.mpreset) {
1728
+ const ready = Promise.withResolvers<void>();
1729
+ session.extendStartupTurnBarrier(ready.promise);
1730
+ startDeferredModelProfiles = async () => {
1731
+ try {
1732
+ const result = await applyDeferredStartupModelProfilesForRoot(profileArgs);
1733
+ startDeferredMemoryBackend?.();
1734
+ ready.resolve();
1735
+ return result;
1736
+ } catch (error) {
1737
+ ready.reject(error);
1738
+ throw error;
1739
+ }
1740
+ };
1741
+ } else {
1742
+ const { recoverableErrors } = await applyStartupModelProfilesForRoot(profileArgs);
1743
+ startDeferredMemoryBackend?.();
1744
+ for (const recoverableError of recoverableErrors) {
1745
+ notifs.push({ kind: "error", message: recoverableError });
1746
+ }
1613
1747
  }
1614
1748
  }
1615
1749
 
@@ -1690,6 +1824,8 @@ export async function runRootCommand(
1690
1824
  initialImages,
1691
1825
  deps.createInteractiveMode,
1692
1826
  bareResumeAction,
1827
+ startDeferredMcpConfig,
1828
+ startDeferredModelProfiles,
1693
1829
  );
1694
1830
  }
1695
1831
  } catch (error) {
@@ -63,9 +63,11 @@ visible-width-aligned column capped at 30 cells, two spaces, then a truncated
63
63
  value. The selected row uses the themed cursor; unselected rows reserve two
64
64
  spaces. It centers the selected item inside its `maxVisible` window, reports
65
65
  scroll position as `(current/total)`, places a blank row before the selected
66
- item description, and ends with the dim
67
- `Enter/Space to change · Esc to cancel` hint. Hosts that need stable height
68
- reserve fixed description rows; the status-line custom editor reserves two.
66
+ item description, and ends with a dim keyboard hint. Printable text starts a
67
+ case-insensitive label search shown above the list; Backspace removes one
68
+ grapheme, and Escape clears a non-empty search before a later Escape cancels
69
+ the list. Hosts that need stable height reserve fixed description rows; the
70
+ status-line custom editor reserves two.
69
71
 
70
72
  Submenus are a content replacement, not a modal overlay: a bold accent title,
71
73
  optional muted description, optional preview, a spacer, a select/list control,
@@ -82,8 +84,9 @@ empty space and list density for an operational setup flow.
82
84
  ### Focus, cursor, keyboard, and input behavior
83
85
 
84
86
  - Up/Down wrap within selector lists. Enter and Space activate the current
85
- action. Escape follows the current component's cancel path before the parent
86
- is allowed to close.
87
+ action. Printable text filters settings by label. Escape first clears an
88
+ active filter, then follows the current component's cancel path before the
89
+ parent is allowed to close.
87
90
  - The parent routes Tab/Left/Right to the tab bar except while a text input is
88
91
  active. Text entry owns arrow keys and Tab in that state.
89
92
  - `Input` has a visible `> ` prompt, a zero-width hardware cursor marker only
@@ -116,24 +116,16 @@ export class FooterComponent implements Component {
116
116
  render(width: number): string[] {
117
117
  const state = this.session.state;
118
118
 
119
- // Calculate cumulative usage from ALL session entries (not just post-compaction messages)
120
- let totalInput = 0;
121
- let totalOutput = 0;
122
- let totalCacheRead = 0;
123
- let totalCacheWrite = 0;
124
- let totalCost = 0;
125
- let totalPremiumRequests = 0;
126
-
127
- for (const entry of this.session.sessionManager.getEntries()) {
128
- if (entry.type === "message" && entry.message.role === "assistant") {
129
- totalInput += entry.message.usage.input;
130
- totalOutput += entry.message.usage.output;
131
- totalCacheRead += entry.message.usage.cacheRead;
132
- totalCacheWrite += entry.message.usage.cacheWrite;
133
- totalCost += entry.message.usage.cost.total;
134
- totalPremiumRequests += entry.message.usage.premiumRequests ?? 0;
135
- }
136
- }
119
+ // Use the session manager's cumulative index so parent and task usage are
120
+ // accounted for consistently without rescanning persisted entries here.
121
+ const {
122
+ input: totalInput,
123
+ output: totalOutput,
124
+ cacheRead: totalCacheRead,
125
+ cacheWrite: totalCacheWrite,
126
+ cost: totalCost,
127
+ premiumRequests: totalPremiumRequests,
128
+ } = this.session.sessionManager.getUsageStatistics();
137
129
 
138
130
  // Calculate context usage from session (handles compaction correctly).
139
131
  // After compaction, tokens are unknown until the next LLM response.
@@ -197,7 +197,7 @@ export class SessionSelectorComponent extends Container {
197
197
 
198
198
  constructor(
199
199
  sessions: SessionInfo[],
200
- private readonly onSelect: (sessionPath: string) => void,
200
+ private readonly onSelect: (sessionPath: string) => void | Promise<void>,
201
201
  private readonly onCancel: () => void,
202
202
  private readonly onExit: () => void,
203
203
  private readonly onDelete?: (session: SessionInfo) => Promise<boolean>,
@@ -214,7 +214,7 @@ export class SessionSelectorComponent extends Container {
214
214
  this.#sessionList = new SessionList(sessions);
215
215
  this.#sessionList.onSelect = session => {
216
216
  if (this.inspector) void this.#inspect(session);
217
- else this.onSelect(session.path);
217
+ else this.#dispatchSelect(session.path);
218
218
  };
219
219
  this.#sessionList.onCancel = () => this.#cancel();
220
220
  this.#sessionList.onExit = () => this.#exit();
@@ -241,6 +241,29 @@ export class SessionSelectorComponent extends Container {
241
241
  this.#onRequestRender?.();
242
242
  }
243
243
 
244
+ /**
245
+ * Observe async `onSelect` handlers. Callers (e.g. resume) may return a
246
+ * Promise; leaving it unobserved turns preparation/switch failures into
247
+ * process-killing unhandled rejections. Inline recovery keeps the picker
248
+ * usable when the host has not already closed it.
249
+ */
250
+ #dispatchSelect(sessionPath: string): void {
251
+ void Promise.resolve(this.onSelect(sessionPath)).catch((error: unknown) => {
252
+ if (this.#state.kind === "settled") {
253
+ // Host already closed the picker (interactive resume path). Surface the
254
+ // message only if we still have a render surface; controller catch is
255
+ // the primary recovery path after `done()`.
256
+ this.#showError(error instanceof Error ? error.message : String(error));
257
+ this.#requestRender();
258
+ return;
259
+ }
260
+ this.#state = { kind: "browsing" };
261
+ this.#sessionList.setInputFrozen(false);
262
+ this.#showError(error instanceof Error ? error.message : String(error));
263
+ this.#requestRender();
264
+ });
265
+ }
266
+
244
267
  #settle(selection: SessionSelectionResult): void {
245
268
  if (this.#state.kind === "settled") return;
246
269
  this.#state = { kind: "settled" };
@@ -249,7 +272,7 @@ export class SessionSelectorComponent extends Container {
249
272
  this.#confirmationDialog = null;
250
273
  if (dialog) this.removeChild(dialog);
251
274
  if (this.onSelection) this.onSelection(selection);
252
- else if (selection.kind === "selected") this.onSelect(selection.path);
275
+ else if (selection.kind === "selected") this.#dispatchSelect(selection.path);
253
276
  else this.onCancel();
254
277
  }
255
278
  #cancel(): void {
@@ -1,6 +1,26 @@
1
1
  import { type RunGh, runGhDefault } from "../../../utils/gh";
2
2
 
3
3
  const STATUS_LINE_GH_TIMEOUT_MS = 5_000;
4
+ const C0_C1_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
5
+
6
+ function canonicalPrUrl(value: unknown, number: number): string | null {
7
+ if (typeof value !== "string" || C0_C1_CONTROL_CHARACTERS.test(value)) return null;
8
+
9
+ try {
10
+ const url = new URL(value);
11
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
12
+ if (!url.hostname || url.username || url.password || url.search || url.hash) return null;
13
+
14
+ const suffix = `/pull/${number}`;
15
+ if (!url.pathname.endsWith(suffix)) return null;
16
+ const repositoryPath = url.pathname.slice(1, -suffix.length).split("/");
17
+ if (repositoryPath.length !== 2 || repositoryPath.some(component => component === "")) return null;
18
+
19
+ return url.href;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
4
24
 
5
25
  export async function lookupCurrentPr(runGh: RunGh = runGhDefault): Promise<{ number: number; url: string } | null> {
6
26
  try {
@@ -8,8 +28,9 @@ export async function lookupCurrentPr(runGh: RunGh = runGhDefault): Promise<{ nu
8
28
  if (result.exitCode !== 0 || result.timedOut) return null;
9
29
 
10
30
  const pr = JSON.parse(result.stdout) as { number?: unknown; url?: unknown };
11
- if (typeof pr.number !== "number" || typeof pr.url !== "string") return null;
12
- return { number: pr.number, url: pr.url };
31
+ if (typeof pr.number !== "number" || !Number.isSafeInteger(pr.number) || pr.number <= 0) return null;
32
+ const url = canonicalPrUrl(pr.url, pr.number);
33
+ return url ? { number: pr.number, url } : null;
13
34
  } catch {
14
35
  return null;
15
36
  }
@@ -32,6 +32,7 @@ import { computeIrcSplitWidths, getIrcSidebarSemanticToken } from "../components
32
32
  import type { IrcObservationRecord } from "../irc-observation-ledger";
33
33
  import { interruptHint } from "../shared";
34
34
  import { buildAbortDisplayMessage } from "../utils/abort-message";
35
+ import { emitHostStatus } from "../utils/host-status";
35
36
  import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
36
37
  import { parseIrcMessage } from "../utils/irc-message";
37
38
  import { ringTerminalBell } from "../utils/terminal-bell";
@@ -298,6 +299,7 @@ export class EventController {
298
299
  }
299
300
 
300
301
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
302
+ emitHostStatus("working");
301
303
  this.ctx.promptSuggestion?.onAgentStart();
302
304
  this.#lastIntent = undefined;
303
305
  this.#readToolCallArgs.clear();
@@ -924,6 +926,7 @@ export class EventController {
924
926
  this.ctx.updateEditorBorderColor();
925
927
  this.ctx.ui.requestRender();
926
928
  this.#scheduleIdleCompaction();
929
+ emitHostStatus("finished");
927
930
  this.sendCompletionNotification();
928
931
  this.ctx.promptSuggestion?.onAgentEnd();
929
932
  }
@@ -40,6 +40,7 @@ import { createReadonlySessionManager } from "../../session/session-manager";
40
40
  import { parseThinkingLevel } from "../../thinking";
41
41
  import type { TodoPhase } from "../../tools/todo-write";
42
42
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
43
+ import { emitHostStatus } from "../utils/host-status";
43
44
  import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
44
45
  import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
45
46
  import { prepareTranscriptRebuild } from "../utils/ui-helpers";
@@ -1258,6 +1259,7 @@ export class ExtensionUiController {
1258
1259
  computeBudget();
1259
1260
 
1260
1261
  ringTerminalBell(classifyHookSelectorBellEvent(title));
1262
+ emitHostStatus("attention");
1261
1263
 
1262
1264
  this.ctx.hookSelector = new HookSelectorComponent(
1263
1265
  title,
@@ -822,6 +822,17 @@ export class InputController {
822
822
  if (runner?.hasHandlers("input")) {
823
823
  const result = await runner.emitInput(text, inputImages, "interactive");
824
824
  if (result?.handled) {
825
+ const handledText = result.text === undefined ? "" : sanitizeText(result.text).trim();
826
+ if (handledText) {
827
+ this.ctx.addMessageToChat({
828
+ role: "custom",
829
+ customType: "extension-input-result",
830
+ content: handledText,
831
+ display: true,
832
+ timestamp: Date.now(),
833
+ });
834
+ this.ctx.ui.requestRender();
835
+ }
825
836
  if (this.#canModifyComposer(composer)) {
826
837
  this.ctx.editor.setText("");
827
838
  }
@@ -2638,9 +2638,15 @@ export class SelectorController {
2638
2638
  this.showSelector(done => {
2639
2639
  const selector = new SessionSelectorComponent(
2640
2640
  sessions,
2641
- async sessionPath => {
2641
+ sessionPath => {
2642
+ // `onSelect` is a void dispatch boundary: close the picker first, then
2643
+ // observe resume failures so a managed-candidate race (or any other
2644
+ // preparation/switch rejection) surfaces as UI error instead of an
2645
+ // unhandled rejection that can kill the process. Do not auto-retry.
2642
2646
  done();
2643
- await this.handleResumeSession(sessionPath);
2647
+ void this.handleResumeSession(sessionPath).catch(error => {
2648
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
2649
+ });
2644
2650
  },
2645
2651
  () => {
2646
2652
  done();
@@ -1036,9 +1036,15 @@ export class InteractiveMode implements InteractiveModeContext {
1036
1036
  if (this.session.getGoalModeState()?.mode === "exiting") {
1037
1037
  await this.#goalModeController.beforeGetUserInput();
1038
1038
  }
1039
- const { promise, resolve } = Promise.withResolvers<SubmittedUserInput>();
1039
+ const { promise, resolve, reject } = Promise.withResolvers<SubmittedUserInput>();
1040
+ let unsubscribeStop = () => {};
1041
+ unsubscribeStop = this.onStop(() => {
1042
+ this.onInputCallback = undefined;
1043
+ reject(Object.assign(new Error("Interactive mode stopped"), { code: "cancelled" }));
1044
+ });
1040
1045
  this.onInputCallback = input => {
1041
1046
  this.onInputCallback = undefined;
1047
+ unsubscribeStop();
1042
1048
  resolve(input);
1043
1049
  };
1044
1050
  this.#goalModeController.scheduleContinuation();