@gajae-code/coding-agent 0.12.1 → 0.12.4

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 (66) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/config/settings-schema.d.ts +2 -2
  3. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  4. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -3
  5. package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +5 -1
  6. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -2
  7. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  8. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  9. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  10. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  11. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  12. package/dist/types/modes/interactive-mode.d.ts +9 -0
  13. package/dist/types/modes/theme/theme.d.ts +3 -1
  14. package/dist/types/modes/types.d.ts +32 -0
  15. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  16. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -2
  17. package/dist/types/session/agent-session.d.ts +2 -0
  18. package/dist/types/session/internal/managed-session-storage.d.ts +22 -3
  19. package/dist/types/task/index.d.ts +4 -0
  20. package/dist/types/task/output-manager.d.ts +1 -0
  21. package/dist/types/task/receipt.d.ts +2 -0
  22. package/dist/types/tools/index.d.ts +2 -0
  23. package/package.json +8 -8
  24. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  25. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  26. package/scripts/run-acp-conformance.ts +5 -0
  27. package/src/async/job-manager.ts +22 -20
  28. package/src/config/provider-ranking.ts +1 -0
  29. package/src/config/settings-schema.ts +2 -2
  30. package/src/debug/index.ts +42 -17
  31. package/src/defaults/gjc/skills/ralplan/SKILL.md +9 -7
  32. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -5
  33. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +2 -1
  34. package/src/gjc-runtime/launch-worktree.ts +103 -1
  35. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  36. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  37. package/src/gjc-runtime/ralplan-runtime.ts +80 -10
  38. package/src/gjc-runtime/ultragoal-evidence.ts +110 -49
  39. package/src/gjc-runtime/ultragoal-runtime.ts +467 -22
  40. package/src/gjc-runtime/workflow-manifest.generated.json +29 -0
  41. package/src/gjc-runtime/workflow-manifest.ts +17 -2
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/modes/DESIGN.md +53 -0
  44. package/src/modes/components/irc-sidebar.ts +30 -1
  45. package/src/modes/components/tool-status-header.ts +0 -1
  46. package/src/modes/controllers/command-controller.ts +38 -34
  47. package/src/modes/controllers/event-controller.ts +27 -16
  48. package/src/modes/controllers/extension-ui-controller.ts +186 -34
  49. package/src/modes/controllers/selector-controller.ts +115 -22
  50. package/src/modes/interactive-mode.ts +189 -62
  51. package/src/modes/jobs-observer.ts +1 -1
  52. package/src/modes/theme/theme.ts +11 -9
  53. package/src/modes/types.ts +88 -0
  54. package/src/sdk/broker/lifecycle.ts +18 -13
  55. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  56. package/src/sdk/bus/telegram-daemon-contract.ts +3 -2
  57. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  58. package/src/sdk/session.ts +1 -0
  59. package/src/session/agent-session.ts +22 -1
  60. package/src/session/internal/managed-session-scope.ts +158 -107
  61. package/src/session/internal/managed-session-storage.ts +445 -57
  62. package/src/session/session-storage.ts +29 -44
  63. package/src/task/index.ts +257 -31
  64. package/src/task/output-manager.ts +24 -14
  65. package/src/task/receipt.ts +11 -1
  66. package/src/tools/index.ts +2 -0
@@ -22,7 +22,12 @@ import { HookEditorComponent } from "../../modes/components/hook-editor";
22
22
  import { HookInputComponent } from "../../modes/components/hook-input";
23
23
  import { HookSelectorComponent } from "../../modes/components/hook-selector";
24
24
  import { getAvailableThemesWithPaths, getThemeByName, setTheme, type Theme, theme } from "../../modes/theme/theme";
25
- import type { InteractiveModeContext } from "../../modes/types";
25
+ import {
26
+ clearInteractiveActivityLoaders,
27
+ type InteractiveModeContext,
28
+ stopInteractiveActivityIndicator,
29
+ syncInteractiveActivityIndicator,
30
+ } from "../../modes/types";
26
31
  import { createReadonlySessionManager } from "../../session/session-manager";
27
32
  import { parseThinkingLevel } from "../../thinking";
28
33
  import type { TodoPhase } from "../../tools/todo-write";
@@ -36,12 +41,55 @@ const HOOK_SELECTOR_CHROME_ROWS = 7;
36
41
  const HOOK_SELECTOR_OUTLINE_ROWS = 2;
37
42
  const HOOK_SELECTOR_INLINE_INPUT_ROWS = 2;
38
43
 
44
+ const EXTENSION_ACTION_MUTATIONS: ReadonlySet<PropertyKey> = new Set([
45
+ "sendMessage",
46
+ "sendUserMessage",
47
+ "appendEntry",
48
+ "setLabel",
49
+ "setActiveTools",
50
+ "setModel",
51
+ "setThinkingLevel",
52
+ "setThinkingVisibility",
53
+ "cycleThinkingLevel",
54
+ "setThinkingLevelForControl",
55
+ "setThinkingVisibilityForControl",
56
+ "setModelTemporaryForControl",
57
+ "setSessionName",
58
+ ]);
59
+ const EXTENSION_CONTEXT_MUTATIONS: ReadonlySet<PropertyKey> = new Set([
60
+ "abort",
61
+ "abortPromptAndWait",
62
+ "shutdown",
63
+ "compact",
64
+ "clearContext",
65
+ "cycleModel",
66
+ "setModelProfile",
67
+ "cycleThinkingLevel",
68
+ "setQueueMode",
69
+ "invokeSkill",
70
+ "setPlanMode",
71
+ "operateGoal",
72
+ "setSdkPermissionProvider",
73
+ "setSdkClientBridge",
74
+ "sdkControl",
75
+ ]);
76
+ const EXTENSION_COMMAND_MUTATIONS: ReadonlySet<PropertyKey> = new Set([
77
+ "reload",
78
+ "newSession",
79
+ "branch",
80
+ "navigateTree",
81
+ "compact",
82
+ "switchSession",
83
+ ]);
84
+
39
85
  export class ExtensionUiController {
40
86
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
87
+ #extensionErrorUnsubscribe?: () => void;
41
88
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
42
89
  #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
43
90
  #activeHookCustomComponent?: Component & { dispose?(): void };
44
91
  #activeHookCustomOverlay?: OverlayHandle;
92
+ #activeHookCustomCancel?: () => void;
45
93
 
46
94
  constructor(private ctx: InteractiveModeContext) {}
47
95
 
@@ -317,6 +365,27 @@ export class ExtensionUiController {
317
365
  this.ctx.restoreComposer();
318
366
  }
319
367
 
368
+ #isStopped(): boolean {
369
+ return this.ctx.isStopped?.() === true;
370
+ }
371
+
372
+ #assertActive(): void {
373
+ if (this.#isStopped()) throw Object.assign(new Error("Interactive mode stopped"), { code: "cancelled" });
374
+ }
375
+
376
+ #guardMutations<T extends object>(target: T, mutationNames: ReadonlySet<PropertyKey>): T {
377
+ return new Proxy(target, {
378
+ get: (current, property, receiver) => {
379
+ const member = Reflect.get(current, property, receiver) as unknown;
380
+ if (!mutationNames.has(property) || typeof member !== "function") return member;
381
+ return (...args: unknown[]) => {
382
+ this.#assertActive();
383
+ return Reflect.apply(member, current, args);
384
+ };
385
+ },
386
+ });
387
+ }
388
+
320
389
  /**
321
390
  * Initialize the hook system with TUI-based UI context.
322
391
  */
@@ -329,13 +398,19 @@ export class ExtensionUiController {
329
398
  notify: (message, type) => this.showHookNotify(message, type),
330
399
  onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
331
400
  setStatus: (key, text) => this.setHookStatus(key, text),
332
- setWorkingMessage: message => this.ctx.setWorkingMessage(message),
401
+ setWorkingMessage: message => {
402
+ if (!this.#isStopped()) this.ctx.setWorkingMessage(message);
403
+ },
333
404
  setWidget: (key, content, options) => this.setHookWidget(key, content, options),
334
- setTitle: title => setTerminalTitle(title),
405
+ setTitle: title => {
406
+ if (!this.#isStopped()) setTerminalTitle(title);
407
+ },
335
408
  custom: (factory, options) => this.showHookCustom(factory, options),
336
- setEditorText: text => this.ctx.editor.setText(text),
409
+ setEditorText: text => {
410
+ if (!this.#isStopped()) this.ctx.editor.setText(text);
411
+ },
337
412
  pasteToEditor: text => {
338
- this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`);
413
+ if (!this.#isStopped()) this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`);
339
414
  },
340
415
  getEditorText: () => this.ctx.editor.getText(),
341
416
  editor: (title, prefill, dialogOptions, editorOptions) =>
@@ -346,17 +421,22 @@ export class ExtensionUiController {
346
421
  getAllThemes: async () => (await getAvailableThemesWithPaths()).map(t => ({ name: t.name, path: t.path })),
347
422
  getTheme: name => getThemeByName(name),
348
423
  setTheme: async themeArg => {
424
+ if (this.#isStopped()) return { success: false, error: "Interactive mode stopped" };
349
425
  if (typeof themeArg === "string") {
350
- return await setTheme(themeArg, true);
426
+ return await setTheme(themeArg, true, { shouldApply: () => !this.#isStopped() });
351
427
  }
352
428
  // Theme object passed directly - not supported in current implementation
353
429
  return Promise.resolve({ success: false, error: "Direct theme object not supported" });
354
430
  },
355
431
  setFooter: () => {},
356
432
  setHeader: () => {},
357
- setEditorComponent: factory => this.ctx.setEditorComponent(factory),
433
+ setEditorComponent: factory => {
434
+ if (!this.#isStopped()) this.ctx.setEditorComponent(factory);
435
+ },
358
436
  getToolsExpanded: () => this.ctx.toolOutputExpanded,
359
- setToolsExpanded: expanded => this.ctx.setToolsExpanded(expanded),
437
+ setToolsExpanded: expanded => {
438
+ if (!this.#isStopped()) this.ctx.setToolsExpanded(expanded);
439
+ },
360
440
  };
361
441
  this.ctx.setToolUIContext(uiContext, true);
362
442
 
@@ -370,8 +450,11 @@ export class ExtensionUiController {
370
450
  const wasStreaming = this.ctx.session.isStreaming;
371
451
  this.ctx.session
372
452
  .sendCustomMessage(message, options)
373
- .then(() => this.#applyCustomMessageDisplay(wasStreaming, message.display))
453
+ .then(() => {
454
+ if (!this.#isStopped()) this.#applyCustomMessageDisplay(wasStreaming, message.display);
455
+ })
374
456
  .catch((err: unknown) => {
457
+ if (this.#isStopped()) return;
375
458
  this.ctx.showError(
376
459
  `Extension sendMessage failed: ${err instanceof Error ? err.message : String(err)}`,
377
460
  );
@@ -479,10 +562,12 @@ export class ExtensionUiController {
479
562
  reload: async () => {
480
563
  const previousSessionId = this.ctx.sessionManager.getSessionId();
481
564
  await this.ctx.session.reload();
565
+ if (this.#isStopped()) return;
482
566
  const sessionIdentityChanged = previousSessionId !== this.ctx.sessionManager.getSessionId();
483
567
  if (sessionIdentityChanged) this.ctx.resetIrcSidebarSession();
484
568
  this.ctx.rebuildInitialMessages(sessionIdentityChanged ? "replace-identity" : "reconcile-same-transcript");
485
569
  await this.ctx.reloadTodos();
570
+ if (this.#isStopped()) return;
486
571
  this.ctx.showStatus("Reloaded session");
487
572
  },
488
573
  newSession: async options => {
@@ -491,18 +576,17 @@ export class ExtensionUiController {
491
576
  if (!success) {
492
577
  return { cancelled: true };
493
578
  }
579
+ if (this.#isStopped()) return { cancelled: true };
580
+ clearInteractiveActivityLoaders(this.ctx);
494
581
  cleanupPreviousSessionUi();
495
582
 
496
- if (this.ctx.loadingAnimation) {
497
- this.ctx.loadingAnimation.stop();
498
- this.ctx.loadingAnimation = undefined;
499
- }
500
- this.ctx.statusContainer.clear();
583
+ stopInteractiveActivityIndicator(this.ctx);
501
584
  this.ctx.resetIrcSidebarSession();
502
585
  setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
503
586
 
504
587
  if (options?.setup) {
505
588
  await options.setup(this.ctx.sessionManager);
589
+ if (this.#isStopped()) return { cancelled: true };
506
590
  }
507
591
 
508
592
  this.ctx.statusLine.invalidate();
@@ -522,12 +606,14 @@ export class ExtensionUiController {
522
606
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 0),
523
607
  );
524
608
  await this.ctx.reloadTodos();
609
+ if (this.#isStopped()) return { cancelled: true };
525
610
  this.ctx.ui.requestRender();
526
611
 
527
612
  return { cancelled: false };
528
613
  },
529
614
  branch: async entryId => {
530
615
  const result = await this.ctx.session.branch(entryId);
616
+ if (this.#isStopped()) return { cancelled: true };
531
617
  if (result.cancelled) {
532
618
  return { cancelled: true };
533
619
  }
@@ -536,6 +622,7 @@ export class ExtensionUiController {
536
622
  // Update UI
537
623
  this.ctx.rebuildInitialMessages("replace-identity");
538
624
  await this.ctx.reloadTodos();
625
+ if (this.#isStopped()) return { cancelled: true };
539
626
  this.ctx.editor.setText(result.selectedText);
540
627
  this.ctx.showStatus("Branched to new session");
541
628
 
@@ -543,6 +630,7 @@ export class ExtensionUiController {
543
630
  },
544
631
  navigateTree: async (targetId, options) => {
545
632
  const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize });
633
+ if (this.#isStopped()) return { cancelled: true };
546
634
  if (result.cancelled) {
547
635
  return { cancelled: true };
548
636
  }
@@ -550,6 +638,7 @@ export class ExtensionUiController {
550
638
  // Update UI
551
639
  this.ctx.rebuildInitialMessages("reconcile-same-transcript");
552
640
  await this.ctx.reloadTodos();
641
+ if (this.#isStopped()) return { cancelled: true };
553
642
  if (result.editorText && !this.ctx.editor.getText().trim()) {
554
643
  this.ctx.editor.setText(result.editorText);
555
644
  }
@@ -563,9 +652,11 @@ export class ExtensionUiController {
563
652
 
564
653
  this.clearHookWidgets();
565
654
  const result = await this.ctx.session.switchSession(sessionPath);
655
+ if (this.#isStopped()) return { cancelled: true };
566
656
  if (!result) {
567
657
  return { cancelled: true };
568
658
  }
659
+ clearInteractiveActivityLoaders(this.ctx);
569
660
  const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
570
661
  if (switchingToDifferentSession) this.ctx.resetIrcSidebarSession();
571
662
 
@@ -574,15 +665,23 @@ export class ExtensionUiController {
574
665
  switchingToDifferentSession ? "replace-identity" : "reconcile-same-transcript",
575
666
  );
576
667
  await this.ctx.reloadTodos();
668
+ if (this.#isStopped()) return { cancelled: true };
669
+ syncInteractiveActivityIndicator(this.ctx);
577
670
  return { cancelled: false };
578
671
  },
579
672
  };
580
673
 
581
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
674
+ extensionRunner.initialize(
675
+ this.#guardMutations(actions, EXTENSION_ACTION_MUTATIONS),
676
+ this.#guardMutations(contextActions, EXTENSION_CONTEXT_MUTATIONS),
677
+ this.#guardMutations(commandActions, EXTENSION_COMMAND_MUTATIONS),
678
+ uiContext,
679
+ );
582
680
 
583
681
  // Subscribe to extension errors
584
- extensionRunner.onError((error: ExtensionError) => {
585
- this.showExtensionError(error.extensionPath, error.error);
682
+ this.#extensionErrorUnsubscribe?.();
683
+ this.#extensionErrorUnsubscribe = extensionRunner.onError((error: ExtensionError) => {
684
+ if (!this.#isStopped()) this.showExtensionError(error.extensionPath, error.error);
586
685
  });
587
686
 
588
687
  // Emit session_start event
@@ -592,6 +691,7 @@ export class ExtensionUiController {
592
691
  }
593
692
 
594
693
  setHookWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void {
694
+ if (this.#isStopped()) return;
595
695
  const placement = options?.placement ?? "aboveEditor";
596
696
  this.#removeHookWidget(this.#hookWidgetsAbove, key);
597
697
  this.#removeHookWidget(this.#hookWidgetsBelow, key);
@@ -781,10 +881,12 @@ export class ExtensionUiController {
781
881
  }
782
882
  const previousSessionId = this.ctx.sessionManager.getSessionId();
783
883
  await this.ctx.session.reload();
884
+ if (this.#isStopped()) return;
784
885
  const sessionIdentityChanged = previousSessionId !== this.ctx.sessionManager.getSessionId();
785
886
  if (sessionIdentityChanged) this.ctx.resetIrcSidebarSession();
786
887
  this.ctx.rebuildInitialMessages(sessionIdentityChanged ? "replace-identity" : "reconcile-same-transcript");
787
888
  await this.ctx.reloadTodos();
889
+ if (this.#isStopped()) return;
788
890
  this.ctx.showStatus("Reloaded session");
789
891
  },
790
892
  newSession: async options => {
@@ -796,17 +898,16 @@ export class ExtensionUiController {
796
898
  if (!success) {
797
899
  return { cancelled: true };
798
900
  }
901
+ if (this.#isStopped()) return { cancelled: true };
902
+ clearInteractiveActivityLoaders(this.ctx);
799
903
  cleanupPreviousSessionUi();
800
904
 
801
- if (this.ctx.loadingAnimation) {
802
- this.ctx.loadingAnimation.stop();
803
- this.ctx.loadingAnimation = undefined;
804
- }
805
- this.ctx.statusContainer.clear();
905
+ stopInteractiveActivityIndicator(this.ctx);
806
906
  this.ctx.resetIrcSidebarSession();
807
907
 
808
908
  if (options?.setup) {
809
909
  await options.setup(this.ctx.sessionManager);
910
+ if (this.#isStopped()) return { cancelled: true };
810
911
  }
811
912
 
812
913
  prepareTranscriptRebuild(this.ctx.ui, "replace-identity");
@@ -821,6 +922,7 @@ export class ExtensionUiController {
821
922
  new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 0),
822
923
  );
823
924
  await this.ctx.reloadTodos();
925
+ if (this.#isStopped()) return { cancelled: true };
824
926
  this.ctx.ui.requestRender();
825
927
 
826
928
  return { cancelled: false };
@@ -830,6 +932,7 @@ export class ExtensionUiController {
830
932
  return { cancelled: true };
831
933
  }
832
934
  const result = await this.ctx.session.branch(entryId);
935
+ if (this.#isStopped()) return { cancelled: true };
833
936
  if (result.cancelled) {
834
937
  return { cancelled: true };
835
938
  }
@@ -838,6 +941,7 @@ export class ExtensionUiController {
838
941
  // Update UI
839
942
  this.ctx.rebuildInitialMessages("replace-identity");
840
943
  await this.ctx.reloadTodos();
944
+ if (this.#isStopped()) return { cancelled: true };
841
945
  this.ctx.editor.setText(result.selectedText);
842
946
  this.ctx.showStatus("Branched to new session");
843
947
 
@@ -848,6 +952,7 @@ export class ExtensionUiController {
848
952
  return { cancelled: true };
849
953
  }
850
954
  const result = await this.ctx.session.navigateTree(targetId, { summarize: options?.summarize });
955
+ if (this.#isStopped()) return { cancelled: true };
851
956
  if (result.cancelled) {
852
957
  return { cancelled: true };
853
958
  }
@@ -855,6 +960,7 @@ export class ExtensionUiController {
855
960
  // Update UI
856
961
  this.ctx.rebuildInitialMessages("reconcile-same-transcript");
857
962
  await this.ctx.reloadTodos();
963
+ if (this.#isStopped()) return { cancelled: true };
858
964
  if (result.editorText && !this.ctx.editor.getText().trim()) {
859
965
  this.ctx.editor.setText(result.editorText);
860
966
  }
@@ -871,20 +977,29 @@ export class ExtensionUiController {
871
977
 
872
978
  this.clearHookWidgets();
873
979
  const result = await this.ctx.session.switchSession(sessionPath);
980
+ if (this.#isStopped()) return { cancelled: true };
874
981
  if (!result) {
875
982
  return { cancelled: true };
876
983
  }
984
+ clearInteractiveActivityLoaders(this.ctx);
877
985
  const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
878
986
  if (switchingToDifferentSession) this.ctx.resetIrcSidebarSession();
879
987
  this.ctx.rebuildInitialMessages(
880
988
  switchingToDifferentSession ? "replace-identity" : "reconcile-same-transcript",
881
989
  );
882
990
  await this.ctx.reloadTodos();
991
+ if (this.#isStopped()) return { cancelled: true };
992
+ syncInteractiveActivityIndicator(this.ctx);
883
993
  return { cancelled: false };
884
994
  },
885
995
  };
886
996
 
887
- extensionRunner.initialize(actions, contextActions, commandActions, uiContext);
997
+ extensionRunner.initialize(
998
+ this.#guardMutations(actions, EXTENSION_ACTION_MUTATIONS),
999
+ this.#guardMutations(contextActions, EXTENSION_CONTEXT_MUTATIONS),
1000
+ this.#guardMutations(commandActions, EXTENSION_COMMAND_MUTATIONS),
1001
+ uiContext,
1002
+ );
888
1003
  }
889
1004
 
890
1005
  createBackgroundUiContext(): ExtensionUIContext {
@@ -1029,7 +1144,7 @@ export class ExtensionUiController {
1029
1144
  * Set hook status text in the footer.
1030
1145
  */
1031
1146
  setHookStatus(key: string, text: string | undefined): void {
1032
- if (this.ctx.isBackgrounded) {
1147
+ if (this.#isStopped() || this.ctx.isBackgrounded) {
1033
1148
  return;
1034
1149
  }
1035
1150
  this.ctx.statusLine.setHookStatus(key, text);
@@ -1044,6 +1159,7 @@ export class ExtensionUiController {
1044
1159
  options: string[],
1045
1160
  dialogOptions?: ExtensionUIDialogOptions,
1046
1161
  ): Promise<string | undefined> {
1162
+ if (this.#isStopped()) return Promise.resolve(undefined);
1047
1163
  const { promise, finish, attachAbort } = this.#createHookDialogState(
1048
1164
  () => this.hideHookSelector(),
1049
1165
  dialogOptions?.signal,
@@ -1148,8 +1264,9 @@ export class ExtensionUiController {
1148
1264
  */
1149
1265
  hideHookSelector(): void {
1150
1266
  this.ctx.hookSelector?.dispose();
1151
- this.#restoreComposerEditor();
1152
1267
  this.ctx.hookSelector = undefined;
1268
+ if (this.#isStopped()) return;
1269
+ this.#restoreComposerEditor();
1153
1270
  this.ctx.ui.setFocus(this.ctx.editor);
1154
1271
  this.ctx.ui.requestRender();
1155
1272
  }
@@ -1171,6 +1288,7 @@ export class ExtensionUiController {
1171
1288
  dialogOptions?: ExtensionUIDialogOptions,
1172
1289
  inputOptions?: { readonly initialValue?: string },
1173
1290
  ): Promise<string | undefined> {
1291
+ if (this.#isStopped()) return Promise.resolve(undefined);
1174
1292
  const { promise, finish, attachAbort } = this.#createHookDialogState(
1175
1293
  () => this.hideHookInput(),
1176
1294
  dialogOptions?.signal,
@@ -1209,8 +1327,9 @@ export class ExtensionUiController {
1209
1327
  */
1210
1328
  hideHookInput(): void {
1211
1329
  this.ctx.hookInput?.dispose();
1212
- this.#restoreComposerEditor();
1213
1330
  this.ctx.hookInput = undefined;
1331
+ if (this.#isStopped()) return;
1332
+ this.#restoreComposerEditor();
1214
1333
  this.ctx.ui.setFocus(this.ctx.editor);
1215
1334
  this.ctx.ui.requestRender();
1216
1335
  }
@@ -1224,6 +1343,7 @@ export class ExtensionUiController {
1224
1343
  dialogOptions?: ExtensionUIDialogOptions,
1225
1344
  editorOptions?: { promptStyle?: boolean },
1226
1345
  ): Promise<string | undefined> {
1346
+ if (this.#isStopped()) return Promise.resolve(undefined);
1227
1347
  const { promise, finish, attachAbort } = this.#createHookDialogState(
1228
1348
  () => this.hideHookEditor(),
1229
1349
  dialogOptions?.signal,
@@ -1258,6 +1378,11 @@ export class ExtensionUiController {
1258
1378
  * Hide the hook editor.
1259
1379
  */
1260
1380
  hideHookEditor(): void {
1381
+ if (this.#isStopped()) {
1382
+ this.ctx.hookEditor?.dispose();
1383
+ this.ctx.hookEditor = undefined;
1384
+ return;
1385
+ }
1261
1386
  this.#restoreComposerEditor();
1262
1387
  this.ctx.hookEditor = undefined;
1263
1388
  this.ctx.ui.setFocus(this.ctx.editor);
@@ -1268,6 +1393,7 @@ export class ExtensionUiController {
1268
1393
  * Show a notification for hooks.
1269
1394
  */
1270
1395
  showHookNotify(message: string, type?: "info" | "warning" | "error"): void {
1396
+ if (this.#isStopped()) return;
1271
1397
  if (type === "error") {
1272
1398
  this.ctx.showError(message);
1273
1399
  } else if (type === "warning") {
@@ -1289,6 +1415,7 @@ export class ExtensionUiController {
1289
1415
  ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>,
1290
1416
  options?: { overlay?: boolean },
1291
1417
  ): Promise<T> {
1418
+ if (this.#isStopped()) return undefined as T;
1292
1419
  const savedText = this.ctx.editor.getText();
1293
1420
  const keybindings = KeybindingsManager.inMemory();
1294
1421
 
@@ -1299,7 +1426,12 @@ export class ExtensionUiController {
1299
1426
  const close = (result: T) => {
1300
1427
  if (closed) return;
1301
1428
  closed = true;
1429
+ this.#activeHookCustomCancel = undefined;
1302
1430
  this.#clearActiveHookCustom();
1431
+ if (this.#isStopped()) {
1432
+ resolve(result);
1433
+ return;
1434
+ }
1303
1435
  if (!options?.overlay) {
1304
1436
  this.#restoreComposerEditor();
1305
1437
  this.ctx.editor.setText(savedText);
@@ -1309,10 +1441,16 @@ export class ExtensionUiController {
1309
1441
  resolve(result);
1310
1442
  };
1311
1443
 
1444
+ this.#activeHookCustomCancel?.();
1312
1445
  this.#clearActiveHookCustom();
1446
+ this.#activeHookCustomCancel = () => close(undefined as T);
1313
1447
  Promise.try(() => factory(this.ctx.ui, theme, keybindings, close)).then(c => {
1314
- if (closed) {
1448
+ if (closed || this.#isStopped()) {
1315
1449
  c.dispose?.();
1450
+ if (!closed) {
1451
+ closed = true;
1452
+ resolve(undefined as T);
1453
+ }
1316
1454
  return;
1317
1455
  }
1318
1456
  component = c;
@@ -1341,6 +1479,7 @@ export class ExtensionUiController {
1341
1479
  * Show an extension error in the UI.
1342
1480
  */
1343
1481
  addExtensionTerminalInputListener(handler: TerminalInputHandler): () => void {
1482
+ if (this.#isStopped()) return () => {};
1344
1483
  const unsubscribe = this.ctx.ui.addInputListener(handler);
1345
1484
  this.#extensionTerminalInputUnsubscribers.add(unsubscribe);
1346
1485
  return () => {
@@ -1369,7 +1508,17 @@ export class ExtensionUiController {
1369
1508
  this.#extensionTerminalInputUnsubscribers.clear();
1370
1509
  }
1371
1510
 
1511
+ dispose(): void {
1512
+ this.#extensionErrorUnsubscribe?.();
1513
+ this.#extensionErrorUnsubscribe = undefined;
1514
+ this.#activeHookCustomCancel?.();
1515
+ this.#activeHookCustomCancel = undefined;
1516
+ this.clearExtensionTerminalInputListeners();
1517
+ this.clearHookWidgets();
1518
+ }
1519
+
1372
1520
  showExtensionError(extensionPath: string, error: string): void {
1521
+ if (this.#isStopped()) return;
1373
1522
  const errorText = new Text(theme.fg("error", `Extension "${extensionPath}" error: ${error}`), 1, 0);
1374
1523
  this.ctx.chatContainer.addChild(errorText);
1375
1524
  this.ctx.ui.requestRender();
@@ -1395,6 +1544,7 @@ export class ExtensionUiController {
1395
1544
  }
1396
1545
 
1397
1546
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
1547
+ if (this.#isStopped()) return Promise.resolve();
1398
1548
  // Compute queued BEFORE send: prompt() may flip session.isStreaming synchronously.
1399
1549
  const queued = Boolean(options?.deliverAs) || this.ctx.session.isStreaming;
1400
1550
  // Call send first so the busy/queued path finds the session queue populated
@@ -1402,6 +1552,7 @@ export class ExtensionUiController {
1402
1552
  const send = this.ctx.session.sendUserMessage(content, options);
1403
1553
  applyInjectedUserSubmission(this.ctx, { content, queued });
1404
1554
  void send.catch((err: unknown) => {
1555
+ if (this.#isStopped()) return;
1405
1556
  this.ctx.showError(`Extension sendUserMessage failed: ${err instanceof Error ? err.message : String(err)}`);
1406
1557
  });
1407
1558
  return send;
@@ -1425,20 +1576,21 @@ export class ExtensionUiController {
1425
1576
  } {
1426
1577
  const { promise, resolve } = Promise.withResolvers<string | undefined>();
1427
1578
  let settled = false;
1428
- const onAbort = () => {
1429
- hide();
1430
- if (!settled) {
1431
- settled = true;
1432
- resolve(undefined);
1433
- }
1434
- };
1579
+ let unregisterStop: (() => void) | undefined;
1435
1580
  const finish = (value: string | undefined) => {
1436
1581
  if (settled) return;
1437
1582
  settled = true;
1438
1583
  signal?.removeEventListener("abort", onAbort);
1584
+ unregisterStop?.();
1439
1585
  resolve(value);
1440
1586
  };
1587
+ const onAbort = () => {
1588
+ hide();
1589
+ finish(undefined);
1590
+ };
1441
1591
  const attachAbort = () => {
1592
+ unregisterStop = this.ctx.onStop?.(onAbort);
1593
+ if (settled) unregisterStop?.();
1442
1594
  if (!signal) return;
1443
1595
  if (signal.aborted) {
1444
1596
  onAbort();