@gajae-code/coding-agent 0.8.1 → 0.8.2

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 (112) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/completion-cli.d.ts +41 -0
  3. package/dist/types/cli/config-cli.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +7 -0
  5. package/dist/types/cli.d.ts +124 -1
  6. package/dist/types/commands/completion.d.ts +29 -0
  7. package/dist/types/commands/config.d.ts +3 -0
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +7 -6
  10. package/dist/types/config/keybindings.d.ts +2 -2
  11. package/dist/types/config/model-profile-activation.d.ts +6 -0
  12. package/dist/types/config/settings.d.ts +8 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  14. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  17. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  19. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  21. package/dist/types/main.d.ts +4 -1
  22. package/dist/types/modes/components/model-selector.d.ts +4 -0
  23. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  26. package/dist/types/modes/print-mode.d.ts +13 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  28. package/dist/types/notifications/config.d.ts +8 -1
  29. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  30. package/dist/types/notifications/index.d.ts +6 -3
  31. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  32. package/dist/types/notifications/telegram-daemon.d.ts +15 -3
  33. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  34. package/dist/types/runtime-api-key.d.ts +7 -0
  35. package/dist/types/session/agent-session.d.ts +10 -0
  36. package/dist/types/session/agent-storage.d.ts +1 -0
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/tools/computer.d.ts +2 -2
  39. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  40. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  41. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  42. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  43. package/package.json +7 -7
  44. package/src/cli/completion-cli.ts +234 -0
  45. package/src/cli/config-cli.ts +37 -9
  46. package/src/cli/fast-help.ts +1 -0
  47. package/src/cli/notify-cli.ts +91 -7
  48. package/src/cli.ts +3 -1
  49. package/src/commands/completion.ts +84 -0
  50. package/src/commands/config.ts +2 -0
  51. package/src/commands/deep-interview.ts +2 -1
  52. package/src/config/file-lock.ts +73 -34
  53. package/src/config/keybindings.ts +2 -2
  54. package/src/config/model-profile-activation.ts +65 -0
  55. package/src/config/model-registry.ts +14 -2
  56. package/src/config/settings.ts +22 -0
  57. package/src/coordinator-mcp/server.ts +34 -8
  58. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  59. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  60. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  61. package/src/extensibility/extensions/runner.ts +2 -0
  62. package/src/extensibility/extensions/types.ts +9 -0
  63. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  64. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  65. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  66. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  67. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  68. package/src/gjc-runtime/state-runtime.ts +12 -2
  69. package/src/gjc-runtime/team-runtime.ts +23 -0
  70. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  71. package/src/hooks/native-skill-hook.ts +29 -0
  72. package/src/internal-urls/docs-index.generated.ts +2 -2
  73. package/src/main.ts +52 -41
  74. package/src/modes/components/model-selector.ts +54 -11
  75. package/src/modes/components/queued-message-selector.ts +92 -0
  76. package/src/modes/components/settings-selector.ts +4 -0
  77. package/src/modes/components/welcome.ts +160 -44
  78. package/src/modes/controllers/command-controller.ts +1 -2
  79. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  80. package/src/modes/controllers/input-controller.ts +179 -17
  81. package/src/modes/controllers/selector-controller.ts +122 -27
  82. package/src/modes/interactive-mode.ts +43 -16
  83. package/src/modes/print-mode.ts +43 -4
  84. package/src/modes/prompt-action-autocomplete.ts +21 -20
  85. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  86. package/src/modes/utils/ui-helpers.ts +2 -2
  87. package/src/notifications/config.ts +9 -1
  88. package/src/notifications/daemon-paths.ts +2 -0
  89. package/src/notifications/index.ts +39 -9
  90. package/src/notifications/lifecycle-commands.ts +37 -7
  91. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  92. package/src/notifications/telegram-daemon-control.ts +3 -1
  93. package/src/notifications/telegram-daemon.ts +217 -43
  94. package/src/notifications/telegram-reference.ts +0 -16
  95. package/src/prompts/system/system-prompt.md +3 -0
  96. package/src/prompts/tools/computer.md +2 -2
  97. package/src/runtime-api-key.ts +16 -0
  98. package/src/sdk.ts +20 -6
  99. package/src/session/agent-session.ts +225 -31
  100. package/src/session/agent-storage.ts +4 -0
  101. package/src/session/session-manager.ts +44 -2
  102. package/src/slash-commands/builtin-registry.ts +153 -71
  103. package/src/task/executor.ts +7 -4
  104. package/src/task/index.ts +9 -3
  105. package/src/task/output-manager.ts +16 -6
  106. package/src/task/receipt.ts +26 -7
  107. package/src/tools/computer.ts +246 -43
  108. package/src/tools/telegram-send.ts +6 -0
  109. package/src/workflow/workflow-intent-diff.ts +29 -6
  110. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  111. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  112. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -8,11 +8,12 @@ import {
8
8
  activateModelProfile,
9
9
  type MaterializeModelProfileForDeletionResult,
10
10
  materializeActiveModelProfileAssignment,
11
+ materializeActiveModelProfileAssignments,
11
12
  materializeModelProfileForDeletion,
12
13
  restoreMaterializedModelProfileForDeletion,
13
14
  } from "../../config/model-profile-activation";
14
15
  import { formatModelProfileDisplayLabel, recommendModelProfileForProvider } from "../../config/model-profiles";
15
- import { GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
16
+ import { GJC_MODEL_ASSIGNMENT_TARGETS, type GjcModelAssignmentTargetId } from "../../config/model-registry";
16
17
  import { formatModelSelectorValue } from "../../config/model-resolver";
17
18
  import type { ModelProfileConfig } from "../../config/models-config-schema";
18
19
  import { type Settings, settings } from "../../config/settings";
@@ -438,6 +439,7 @@ export class SelectorController {
438
439
  availableThinkingLevels: [...this.ctx.session.getAvailableThinkingLevels()],
439
440
  thinkingLevel: this.ctx.session.thinkingLevel,
440
441
  availableThemes,
442
+ availableModelProfiles: [...this.ctx.session.modelRegistry.getModelProfiles().keys()],
441
443
  cwd: getProjectDir(),
442
444
  },
443
445
  {
@@ -634,6 +636,18 @@ export class SelectorController {
634
636
  this.ctx.updateEditorBorderColor();
635
637
  break;
636
638
 
639
+ case "modelProfile.default": {
640
+ // Applying the default profile live mirrors the /model preset flow so the
641
+ // running session switches immediately, not only on next startup.
642
+ const profileName = typeof value === "string" ? value : "";
643
+ if (!profileName) break;
644
+ this.#applyModelProfile(profileName, true)
645
+ .then(() => this.ctx.ui.requestRender())
646
+ .catch(error => {
647
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
648
+ });
649
+ break;
650
+ }
637
651
  case "clearOnShrink":
638
652
  this.ctx.ui.setClearOnShrink(value as boolean);
639
653
  break;
@@ -784,6 +798,29 @@ export class SelectorController {
784
798
  }
785
799
  }
786
800
 
801
+ /**
802
+ * Activate a model profile through the shared /model + /settings path: swap the
803
+ * live session model (and, when persistDefault, persist it as the startup
804
+ * default) then refresh the status surfaces. Rethrows so callers surface errors.
805
+ */
806
+ async #applyModelProfile(profileName: string, persistDefault: boolean): Promise<void> {
807
+ const profileLabel = formatModelProfileDisplayLabel(
808
+ this.ctx.session.modelRegistry.getModelProfile(profileName) ?? { name: profileName },
809
+ );
810
+ await activateModelProfile(
811
+ {
812
+ session: this.ctx.session,
813
+ modelRegistry: this.ctx.session.modelRegistry,
814
+ settings: this.ctx.settings,
815
+ profileName,
816
+ },
817
+ { persistDefault },
818
+ );
819
+ this.ctx.statusLine.invalidate();
820
+ this.ctx.updateEditorBorderColor();
821
+ this.ctx.showStatus(persistDefault ? `Default model profile: ${profileLabel}` : `Model profile: ${profileLabel}`);
822
+ }
823
+
787
824
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
788
825
  this.showSelector(done => {
789
826
  let modelSelector: ModelSelectorComponent;
@@ -809,27 +846,7 @@ export class SelectorController {
809
846
  return;
810
847
  }
811
848
  if (selection.kind === "profile") {
812
- const profileLabel = formatModelProfileDisplayLabel(
813
- this.ctx.session.modelRegistry.getModelProfile(selection.profileName) ?? {
814
- name: selection.profileName,
815
- },
816
- );
817
- await activateModelProfile(
818
- {
819
- session: this.ctx.session,
820
- modelRegistry: this.ctx.session.modelRegistry,
821
- settings: this.ctx.settings,
822
- profileName: selection.profileName,
823
- },
824
- { persistDefault: selection.setDefault },
825
- );
826
- this.ctx.statusLine.invalidate();
827
- this.ctx.updateEditorBorderColor();
828
- this.ctx.showStatus(
829
- selection.setDefault
830
- ? `Default model profile: ${profileLabel}`
831
- : `Model profile: ${profileLabel}`,
832
- );
849
+ await this.#applyModelProfile(selection.profileName, selection.setDefault);
833
850
  done();
834
851
  this.ctx.ui.requestRender();
835
852
  return;
@@ -843,6 +860,79 @@ export class SelectorController {
843
860
  this.ctx.showStatus(`Temporary model: ${selectedSelector ?? model.id}`);
844
861
  done();
845
862
  this.ctx.ui.requestRender();
863
+ } else if (selection.roles) {
864
+ const targetRoles: readonly GjcModelAssignmentTargetId[] = selection.roles;
865
+ const includesDefault = targetRoles.includes("default");
866
+ const includesRoleAgent = targetRoles.some(targetRole => targetRole !== "default");
867
+ if (includesRoleAgent) {
868
+ const apiKey = await this.ctx.session.modelRegistry.getApiKey(
869
+ model,
870
+ this.ctx.session.sessionId,
871
+ );
872
+ if (!apiKey) {
873
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
874
+ }
875
+ }
876
+ const value =
877
+ selectedSelector ?? formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel);
878
+ const assignments = new Map<GjcModelAssignmentTargetId, string>();
879
+ for (const targetRole of targetRoles) assignments.set(targetRole, value);
880
+ const defaultSelector =
881
+ selectedSelector && thinkingLevel && selectedSelector.endsWith(`:${thinkingLevel}`)
882
+ ? selectedSelector.slice(0, -thinkingLevel.length - 1)
883
+ : selectedSelector;
884
+
885
+ if (includesDefault) {
886
+ await this.ctx.session.setModel(model, "default", {
887
+ selector: defaultSelector,
888
+ thinkingLevel,
889
+ });
890
+ if (thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit) {
891
+ this.ctx.session.setThinkingLevel(thinkingLevel);
892
+ }
893
+ }
894
+ const materializedProfile = materializeActiveModelProfileAssignments({
895
+ session: this.ctx.session,
896
+ settings: this.ctx.settings,
897
+ assignments,
898
+ });
899
+ if (!materializedProfile) {
900
+ const overrides = this.ctx.settings.get("task.agentModelOverrides");
901
+ const nextOverrides = { ...overrides };
902
+ let writesOverrides = false;
903
+ for (const targetRole of targetRoles) {
904
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[targetRole];
905
+ if (target.settingsPath === "modelRoles") {
906
+ this.ctx.settings.setModelRole(targetRole, value);
907
+ } else {
908
+ nextOverrides[targetRole] = value;
909
+ writesOverrides = true;
910
+ }
911
+ }
912
+ if (writesOverrides) {
913
+ this.ctx.settings.set("task.agentModelOverrides", nextOverrides);
914
+ }
915
+ }
916
+ modelSelector.refreshRoleAssignments({
917
+ currentModel: this.ctx.session.model,
918
+ currentThinkingLevel: this.ctx.session.thinkingLevel,
919
+ activeModelProfile:
920
+ this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
921
+ });
922
+ this.ctx.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
923
+ this.ctx.statusLine.invalidate();
924
+ this.ctx.updateEditorBorderColor();
925
+ await this.ctx.notifyConfigChanged?.();
926
+ const labels = targetRoles.map(
927
+ targetRole => GJC_MODEL_ASSIGNMENT_TARGETS[targetRole].tag ?? targetRole.toUpperCase(),
928
+ );
929
+ this.ctx.showStatus(
930
+ includesDefault
931
+ ? `All model targets set to ${value} for ${labels.join(", ")}.`
932
+ : `Role-agent models set to ${value} for ${labels.join(", ")}.`,
933
+ );
934
+ done();
935
+ this.ctx.ui.requestRender();
846
936
  } else if (role === "default") {
847
937
  // Default: update agent state and persist as the active default model.
848
938
  await this.ctx.session.setModel(model, role, {
@@ -880,19 +970,21 @@ export class SelectorController {
880
970
  }
881
971
  const value =
882
972
  selectedSelector ?? formatModelSelectorValue(`${model.provider}/${model.id}`, thinkingLevel);
883
- const materializedProfile = materializeActiveModelProfileAssignment({
973
+ const assignments = new Map<GjcModelAssignmentTargetId, string>([[role, value]]);
974
+ const materializedProfile = materializeActiveModelProfileAssignments({
884
975
  session: this.ctx.session,
885
976
  settings: this.ctx.settings,
886
- role,
887
- selector: value,
977
+ assignments,
888
978
  });
889
979
  if (!materializedProfile) {
890
980
  const target = GJC_MODEL_ASSIGNMENT_TARGETS[role];
891
981
  if (target.settingsPath === "modelRoles") {
892
982
  this.ctx.settings.setModelRole(role, value);
893
983
  } else {
894
- const overrides = this.ctx.settings.get("task.agentModelOverrides");
895
- this.ctx.settings.set("task.agentModelOverrides", { ...overrides, [role]: value });
984
+ this.ctx.settings.set("task.agentModelOverrides", {
985
+ ...this.ctx.settings.get("task.agentModelOverrides"),
986
+ [role]: value,
987
+ });
896
988
  }
897
989
  }
898
990
  modelSelector.refreshRoleAssignments({
@@ -902,6 +994,9 @@ export class SelectorController {
902
994
  this.ctx.session.getActiveModelProfile?.() ?? this.ctx.settings.get("modelProfile.default"),
903
995
  });
904
996
  this.ctx.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
997
+ this.ctx.statusLine.invalidate();
998
+ this.ctx.updateEditorBorderColor();
999
+ await this.ctx.notifyConfigChanged?.();
905
1000
  this.ctx.showStatus(`${role} agent model: ${value}`);
906
1001
  done();
907
1002
  this.ctx.ui.requestRender();
@@ -117,12 +117,14 @@ import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInpu
117
117
  import { UiHelpers } from "./utils/ui-helpers";
118
118
 
119
119
  const INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS = 5_000;
120
- const DEFAULT_COMPOSER_PLACEHOLDER = "Type your message...";
120
+ const COMPOSER_NEWLINE_HINT = process.platform === "win32" ? "Alt+Enter/Ctrl+J" : "Shift+Enter/Ctrl+J";
121
+ const DEFAULT_COMPOSER_PLACEHOLDER = `Type your message... ${COMPOSER_NEWLINE_HINT}: New line · Ctrl+C: Clear · Shift+Tab: Reasoning`;
122
+ const WELCOME_RESERVED_CONTAINER_CHILD_LIMIT = 8;
121
123
  const FRIENDLY_KEY_PARTS: Record<string, string> = {
122
124
  alt: "Alt",
123
- cmd: "Command",
124
- command: "Command",
125
- ctrl: "Control",
125
+ cmd: "Cmd",
126
+ command: "Cmd",
127
+ ctrl: "Ctrl",
126
128
  enter: "Enter",
127
129
  meta: process.platform === "darwin" ? "Command" : "Meta",
128
130
  option: "Option",
@@ -132,7 +134,7 @@ const FRIENDLY_KEY_PARTS: Record<string, string> = {
132
134
  function formatShortcutForPlaceholder(key: string): string {
133
135
  return key
134
136
  .split("+")
135
- .map(part => FRIENDLY_KEY_PARTS[part.toLowerCase()] ?? part)
137
+ .map(part => FRIENDLY_KEY_PARTS[part.toLowerCase()] ?? (part.length === 1 ? part.toUpperCase() : part))
136
138
  .join("+");
137
139
  }
138
140
 
@@ -443,7 +445,6 @@ export class InteractiveMode implements InteractiveModeContext {
443
445
  logger.warn("History storage unavailable", { error: String(error) });
444
446
  }
445
447
  this.hookWidgetContainerAbove = new Container();
446
- this.hookWidgetContainerAbove.addChild(new Spacer(1));
447
448
  this.hookWidgetContainerBelow = new Container();
448
449
  this.editorContainer = new Container();
449
450
  this.editorContainer.addChild(this.editor);
@@ -531,13 +532,7 @@ export class InteractiveMode implements InteractiveModeContext {
531
532
  }
532
533
 
533
534
  if (!startupQuiet) {
534
- const getWelcomeReservedBottomRows = (width: number): number =>
535
- [
536
- this.statusLine,
537
- this.hookWidgetContainerAbove,
538
- this.editorContainer,
539
- this.hookWidgetContainerBelow,
540
- ].reduce((rows, component) => rows + component.render(width).length, 0);
535
+ const getWelcomeReservedBottomRows = (width: number): number => this.#getWelcomeReservedRows(width);
541
536
 
542
537
  // Add welcome header
543
538
  this.#welcomeComponent = new WelcomeComponent(
@@ -940,11 +935,37 @@ export class InteractiveMode implements InteractiveModeContext {
940
935
 
941
936
  #getComposerPlaceholder(): string {
942
937
  if (!this.#isPromptDeliveryBusy()) return DEFAULT_COMPOSER_PLACEHOLDER;
943
- const enterAction = this.settings.get("busyPromptMode") === "steer" ? "Steering" : "Message Queueing";
938
+ const enterAction = this.settings.get("busyPromptMode") === "steer" ? "Steer" : "Queue";
944
939
  const parts = [`Enter: ${enterAction}`];
945
940
  const queueKey = this.#getMessageQueueShortcut();
946
- if (queueKey) parts.push(`${formatShortcutForPlaceholder(queueKey)}: Message Queueing`);
947
- return `${DEFAULT_COMPOSER_PLACEHOLDER} ${parts.join(" · ")}`;
941
+ if (queueKey) parts.push(`${formatShortcutForPlaceholder(queueKey)}: Queue`);
942
+ return `${DEFAULT_COMPOSER_PLACEHOLDER} · ${parts.join(" · ")}`;
943
+ }
944
+
945
+ #getWelcomeReservedRows(width: number): number {
946
+ const transientRows = [
947
+ this.chatContainer,
948
+ this.pendingMessagesContainer,
949
+ this.statusContainer,
950
+ this.todoContainer,
951
+ this.btwContainer,
952
+ ].reduce((rows, container) => rows + this.#renderShortContainerRowsForWelcomeReservation(width, container), 0);
953
+
954
+ const pinnedRows = [
955
+ this.statusLine,
956
+ this.hookWidgetContainerAbove,
957
+ this.editorContainer,
958
+ this.hookWidgetContainerBelow,
959
+ ].reduce((rows, component) => rows + component.render(width).length, 0);
960
+
961
+ return transientRows + pinnedRows;
962
+ }
963
+
964
+ #renderShortContainerRowsForWelcomeReservation(width: number, container: Container): number {
965
+ if (container.children.length === 0 || container.children.length > WELCOME_RESERVED_CONTAINER_CHILD_LIMIT) {
966
+ return 0;
967
+ }
968
+ return container.render(width).length;
948
969
  }
949
970
 
950
971
  updateEditorChrome(): void {
@@ -1351,6 +1372,12 @@ export class InteractiveMode implements InteractiveModeContext {
1351
1372
  return;
1352
1373
  }
1353
1374
 
1375
+ // Leaving plan mode invalidates the plan-mode steering and standing
1376
+ // resolve handler that the in-flight turn may still be acting on. Abort
1377
+ // before clearing that state so the model cannot continue from stale
1378
+ // plan-mode context and retry `resolve` against a retired handler.
1379
+ await this.session.abort({ timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS });
1380
+
1354
1381
  const previousTools = this.#planModePreviousTools;
1355
1382
  if (previousTools && previousTools.length > 0) {
1356
1383
  await this.session.setActiveToolsByName(previousTools);
@@ -5,7 +5,7 @@
5
5
  * - `gjc -p "prompt"` - text output
6
6
  * - `gjc --mode json "prompt"` - JSON event stream
7
7
  */
8
- import type { AssistantMessage, ImageContent } from "@gajae-code/ai";
8
+ import { type AssistantMessage, type ImageContent, isContextOverflow } from "@gajae-code/ai";
9
9
  import { logger, sanitizeText } from "@gajae-code/utils";
10
10
  import type { AgentSession } from "../session/agent-session";
11
11
  import { isSilentAbort } from "../session/messages";
@@ -31,6 +31,33 @@ export interface PrintModeOptions {
31
31
  suppressProcessExit?: boolean;
32
32
  }
33
33
 
34
+ /**
35
+ * Exit code used when a non-interactive **text-mode** run (`gjc -p`) terminates
36
+ * because the model context window is exhausted and automatic compaction could
37
+ * not bring the request under the limit. Distinct from the generic failure code
38
+ * (1) so text-mode callers can detect context exhaustion specifically instead of
39
+ * parsing the raw provider error string.
40
+ *
41
+ * Scope: text-mode final-response path only. JSON mode (`--mode json`) streams
42
+ * events from the subscription and does not run this terminal-error branch, so
43
+ * it is intentionally NOT covered by this exit code.
44
+ */
45
+ export const CONTEXT_OVERFLOW_EXIT_CODE = 2;
46
+
47
+ /**
48
+ * Build an actionable stderr diagnostic for a terminal context-overflow error in
49
+ * text mode. The raw provider message is preserved (appended) for debugging, but
50
+ * the leading guidance explains what happened and what the operator can do —
51
+ * tailored to whether auto-compaction was even enabled.
52
+ */
53
+ function formatContextOverflowError(message: AssistantMessage, autoCompactionEnabled: boolean): string {
54
+ const providerDetail = message.errorMessage ? ` (provider error: ${sanitizeText(message.errorMessage)})` : "";
55
+ const guidance = autoCompactionEnabled
56
+ ? "Context window exhausted: automatic compaction ran but could not reduce the request below the model's context limit. Reduce the input size (smaller file reads / tool output), raise the compaction threshold, or switch to a larger-context model."
57
+ : "Context window exhausted and automatic compaction is disabled. Enable it (compaction.enabled=true with a non-off compaction.strategy) so GJC can compact and continue, reduce the input size, or switch to a larger-context model.";
58
+ return `${guidance}${providerDetail}`;
59
+ }
60
+
34
61
  /**
35
62
  * Run in print (single-shot) mode.
36
63
  * Sends prompts to the agent and outputs the result.
@@ -88,15 +115,27 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
88
115
  (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") &&
89
116
  !isSilentAbort(assistantMsg.errorMessage)
90
117
  ) {
91
- const errorLine = sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
118
+ // Context-overflow is an expected, recoverable-in-principle condition — not
119
+ // an opaque crash. Auto-compaction has already run inside session.prompt();
120
+ // if we still land here the request could not be made to fit. In this
121
+ // text-mode final-response path, surface an actionable diagnostic and a
122
+ // distinct exit code so text-mode (`gjc -p`) callers can detect context
123
+ // exhaustion instead of parsing the raw provider error string. (JSON mode
124
+ // does not reach this branch and is intentionally out of scope.)
125
+ const isOverflow =
126
+ assistantMsg.stopReason === "error" && isContextOverflow(assistantMsg, session.model?.contextWindow);
127
+ const errorLine = isOverflow
128
+ ? formatContextOverflowError(assistantMsg, session.autoCompactionEnabled)
129
+ : sanitizeText(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);
130
+ const exitCode = isOverflow ? CONTEXT_OVERFLOW_EXIT_CODE : 1;
92
131
  const flushed = process.stderr.write(`${errorLine}\n`);
93
132
  // When the caller owns finalization (RLM autonomous), return instead of
94
133
  // exiting so its cleanup runs; the caller surfaces a non-zero exit itself.
95
134
  if (!options.suppressProcessExit) {
96
135
  if (flushed) {
97
- process.exit(1);
136
+ process.exit(exitCode);
98
137
  } else {
99
- process.stderr.once("drain", () => process.exit(1));
138
+ process.stderr.once("drain", () => process.exit(exitCode));
100
139
  }
101
140
  }
102
141
  }
@@ -1,5 +1,12 @@
1
- import type { AutocompleteItem, AutocompleteProvider, SlashCommand } from "@gajae-code/tui";
2
- import { CombinedAutocompleteProvider, getKeybindings, getSlashCommandMatchRank } from "@gajae-code/tui";
1
+ import {
2
+ type AutocompleteItem,
3
+ type AutocompleteProvider,
4
+ CombinedAutocompleteProvider,
5
+ extractSlashCommandTokenPrefix,
6
+ getKeybindings,
7
+ getSlashCommandMatchRank,
8
+ type SlashCommand,
9
+ } from "@gajae-code/tui";
3
10
  import type { KeybindingsManager } from "../config/keybindings";
4
11
  import { formatKeyHints } from "../config/keybindings";
5
12
  import { isSettingsInitialized, settings } from "../config/settings";
@@ -147,6 +154,10 @@ function sortSlashCommandSuggestions(
147
154
  return { ...suggestions, items };
148
155
  }
149
156
 
157
+ function isRootPathSuggestionResult(suggestions: { items: AutocompleteItem[]; prefix: string } | null): boolean {
158
+ return suggestions?.prefix.startsWith("/") === true && suggestions.items.some(item => item.value.startsWith("/"));
159
+ }
160
+
150
161
  function withoutSkillCommandSuggestions(
151
162
  suggestions: { items: AutocompleteItem[]; prefix: string } | null,
152
163
  ): { items: AutocompleteItem[]; prefix: string } | null {
@@ -168,18 +179,9 @@ function getPromptActionPrefix(textBeforeCursor: string): string | null {
168
179
  }
169
180
 
170
181
  function getSlashTokenPrefix(textBeforeCursor: string): string | null {
171
- const slashIndex = textBeforeCursor.lastIndexOf("/");
172
- if (slashIndex === -1) return null;
173
- const beforeSlash = textBeforeCursor.slice(0, slashIndex);
174
- if (beforeSlash && !/\s$/.test(beforeSlash)) return null;
175
- const token = textBeforeCursor.slice(slashIndex);
176
- if (/[\s]/.test(token)) return null;
177
- return token;
182
+ return extractSlashCommandTokenPrefix(textBeforeCursor);
178
183
  }
179
184
 
180
- function isLeadingSlashToken(textBeforeCursor: string, slashPrefix: string): boolean {
181
- return textBeforeCursor.trimStart() === slashPrefix;
182
- }
183
185
  export class PromptActionAutocompleteProvider implements AutocompleteProvider {
184
186
  #baseProvider: CombinedAutocompleteProvider;
185
187
  #actions: PromptActionDefinition[];
@@ -226,14 +228,13 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
226
228
 
227
229
  const slashPrefix = getSlashTokenPrefix(textBeforeCursor);
228
230
  if (slashPrefix) {
229
- const isLeading = isLeadingSlashToken(textBeforeCursor, slashPrefix);
230
- const baseSuggestions = isLeading
231
- ? withoutSkillCommandSuggestions(await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol))
232
- : null;
233
- const skillCommandSuggestions =
234
- isLeading || slashPrefix.startsWith("/skill")
235
- ? this.#getSkillCommandSuggestions(textBeforeCursor, { includeEmpty: isLeading })
236
- : null;
231
+ const baseSuggestions = withoutSkillCommandSuggestions(
232
+ await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol),
233
+ );
234
+ if (isRootPathSuggestionResult(baseSuggestions)) return baseSuggestions;
235
+ const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor, {
236
+ includeEmpty: false,
237
+ });
237
238
  return sortSlashCommandSuggestions(
238
239
  mergeAutocompleteSuggestions(baseSuggestions, skillCommandSuggestions),
239
240
  this.#commands,
@@ -23,6 +23,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
23
23
  "|-----|--------|",
24
24
  "| `Enter` | Send / queue while busy |",
25
25
  `| \`${appKey(bindings, "app.message.queue")}\` | Queue message for next turn |`,
26
+ `| \`${appKey(bindings, "app.message.dequeue")}\` | Select queued message to edit |`,
26
27
  "| `Shift+Enter` / `Ctrl+J` | New line |",
27
28
  "| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
28
29
  "| `Ctrl+U` | Delete to start of line |",
@@ -599,8 +599,8 @@ export class UiHelpers {
599
599
  const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
600
600
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
601
601
  }
602
- const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
603
- const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
602
+ const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up/Alt+Down";
603
+ const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to select/edit/reorder`);
604
604
  this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
605
605
  }
606
606
  }
@@ -52,7 +52,14 @@ export function isGloballyConfigured(cfg: NotificationConfig): boolean {
52
52
  export function shouldRegisterNotificationsExtension(input: {
53
53
  env: NodeJS.ProcessEnv;
54
54
  cfg?: NotificationConfig;
55
+ /** Task recursion depth; helper/subagent sessions must not spawn remote surfaces. */
56
+ taskDepth?: number;
57
+ /** Parent subagent id/prefix; present for helper/subagent sessions even when depth is omitted. */
58
+ parentTaskPrefix?: string;
59
+ /** Role-agent type/name; present for task sessions even if depth metadata is lost. */
60
+ currentAgentType?: string;
55
61
  }): boolean {
62
+ if ((input.taskDepth ?? 0) > 0 || input.parentTaskPrefix || input.currentAgentType) return false;
56
63
  if (input.env.GJC_NOTIFICATIONS === "0") return false;
57
64
  if (input.env.GJC_NOTIFICATIONS === "1" || input.env.GJC_NOTIFICATIONS_TOKEN) return true;
58
65
  return input.cfg ? isGloballyConfigured(input.cfg) : false;
@@ -112,7 +119,8 @@ export interface RedactableAction {
112
119
  * When redact is false, return the action unchanged.
113
120
  *
114
121
  * Redaction still applies to streamed content frames (turn_stream, context_update,
115
- * image_attachment) which are suppressed at their emit sites, not here.
122
+ * image_attachment) which are suppressed at their emit sites, not here. Explicit
123
+ * `telegram_send` file attachments are rejected before the file is read or forwarded.
116
124
  */
117
125
  export function buildRedactedAction(
118
126
  action: RedactableAction,
@@ -7,6 +7,7 @@ export interface DaemonPaths {
7
7
  roots: string;
8
8
  steal: string;
9
9
  aliases: string;
10
+ seenUpdates: string;
10
11
  }
11
12
 
12
13
  export function daemonPaths(agentDir: string): DaemonPaths {
@@ -18,5 +19,6 @@ export function daemonPaths(agentDir: string): DaemonPaths {
18
19
  roots: path.join(dir, "telegram-daemon.roots.json"),
19
20
  steal: path.join(dir, "telegram-daemon.steal"),
20
21
  aliases: path.join(dir, "telegram-callback-aliases.json"),
22
+ seenUpdates: path.join(dir, "telegram-seen-updates.json"),
21
23
  };
22
24
  }
@@ -28,7 +28,7 @@ import type { ImageContent, TextContent } from "@gajae-code/ai";
28
28
  import { NotificationServer } from "@gajae-code/natives";
29
29
  import { logger, postmortem } from "@gajae-code/utils";
30
30
  import { Settings } from "../config/settings";
31
- import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
31
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "../extensibility/extensions";
32
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
33
33
  import { registerTelegramFileSink } from "./attachment-registry";
34
34
  import {
@@ -100,7 +100,7 @@ export interface SessionCloseFrame {
100
100
  chatId: string;
101
101
  token: string;
102
102
  target: SessionCloseTarget;
103
- /** Hard-kill even if a live pane is attached (GJC-managed only). */
103
+ /** Required force-only close flag; false/omitted is rejected by daemon policy. */
104
104
  force?: boolean;
105
105
  }
106
106
 
@@ -336,6 +336,8 @@ interface SessionRuntime {
336
336
  disposeAnswerSource: () => void;
337
337
  /** Deregisters this session's Telegram file sink. */
338
338
  disposeFileSink: () => void;
339
+ /** Deregisters this session's unattended workflow-gate listener. */
340
+ disposeGateListener: () => void;
339
341
  redact: boolean;
340
342
  verbosity: "lean" | "verbose";
341
343
  sessionTag: string;
@@ -358,6 +360,8 @@ interface ResolvedSettings {
358
360
  settingsAvailable: boolean;
359
361
  }
360
362
 
363
+ const TELEGRAM_FILE_REDACTION_ERROR = "Telegram file attachments are disabled while notifications redaction is on.";
364
+
361
365
  const defaultConfig: NotificationConfig = {
362
366
  enabled: false,
363
367
  botToken: undefined,
@@ -379,7 +383,9 @@ export function notificationsEnabled(): boolean {
379
383
  return process.env.GJC_NOTIFICATIONS === "1" || Boolean(process.env.GJC_NOTIFICATIONS_TOKEN);
380
384
  }
381
385
 
382
- function resolveSettings(): ResolvedSettings {
386
+ function resolveSettings(settingsOverride?: Settings): ResolvedSettings {
387
+ if (settingsOverride)
388
+ return { settings: settingsOverride, cfg: getNotificationConfig(settingsOverride), settingsAvailable: true };
383
389
  try {
384
390
  const settings = Settings.instance;
385
391
  return { settings, cfg: getNotificationConfig(settings), settingsAvailable: true };
@@ -491,7 +497,7 @@ function sessionIdFromFile(file: string | undefined): string | undefined {
491
497
  return underscore >= 0 ? base.slice(underscore + 1) : undefined;
492
498
  }
493
499
 
494
- export const createNotificationsExtension: ExtensionFactory = api => {
500
+ export function createNotificationsExtension(api: ExtensionAPI, options: { settings?: Settings } = {}): void {
495
501
  const runtimes = new Map<string, SessionRuntime>();
496
502
  const disabledSessions = new Set<string>();
497
503
  const sessionId = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId();
@@ -503,9 +509,16 @@ export const createNotificationsExtension: ExtensionFactory = api => {
503
509
  runtimes.delete(id);
504
510
  try {
505
511
  rt.cancelPostmortemCleanup();
512
+ } catch {}
513
+ try {
506
514
  rt.disposeAnswerSource();
515
+ } catch {}
516
+ try {
507
517
  rt.disposeFileSink();
508
518
  } catch {}
519
+ try {
520
+ rt.disposeGateListener();
521
+ } catch {}
509
522
  // Resolve any still-pending interactive asks so the ask tool is not left hanging.
510
523
  for (const pending of rt.pendingInteractive.values()) pending.resolve(undefined);
511
524
  rt.pendingInteractive.clear();
@@ -529,10 +542,14 @@ export const createNotificationsExtension: ExtensionFactory = api => {
529
542
  return isSessionNotificationsEnabled({ cfg, env: process.env, sessionDisabled: disabledSessions.has(id) });
530
543
  }
531
544
 
545
+ function isNotificationEligibleContext(ctx: ExtensionContext): boolean {
546
+ return ctx.sessionMetadata?.kind !== "sub";
547
+ }
548
+
532
549
  async function startSession(ctx: ExtensionContext): Promise<"started" | "already" | "disabled" | "failed"> {
533
550
  const id = sessionId(ctx);
534
- const { settings, cfg, settingsAvailable } = resolveSettings();
535
- if (!isEnabledForSession(id, cfg)) return "disabled";
551
+ const { settings, cfg, settingsAvailable } = resolveSettings(options.settings);
552
+ if (!isNotificationEligibleContext(ctx) || !isEnabledForSession(id, cfg)) return "disabled";
536
553
  if (runtimes.has(id)) return "already";
537
554
 
538
555
  const stateRoot = path.join(ctx.cwd, ".gjc", "state");
@@ -660,6 +677,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
660
677
  tag,
661
678
  );
662
679
  const disposeFileSink = registerTelegramFileSink(id, async file => {
680
+ if (runtime?.redact ?? redact) {
681
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
682
+ }
683
+
663
684
  try {
664
685
  const data = await fs.promises.readFile(file.path);
665
686
  server.pushFrame(
@@ -683,6 +704,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
683
704
  pendingInteractive,
684
705
  disposeAnswerSource,
685
706
  disposeFileSink,
707
+ disposeGateListener: () => {},
686
708
  cancelPostmortemCleanup: () => {},
687
709
  redact,
688
710
  verbosity,
@@ -726,7 +748,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
726
748
 
727
749
  // Unattended: a real ask emits a workflow gate; register it repliable by gate_id.
728
750
  if (unattended && gate?.onGateEmitted) {
729
- gate.onGateEmitted(g => {
751
+ runtime.disposeGateListener = gate.onGateEmitted(g => {
730
752
  const options = (g.options ?? []).map(o => String((o as { label?: unknown }).label ?? ""));
731
753
  gateOptions.set(g.gate_id, options);
732
754
  const promptCtx = g.context as { prompt?: unknown; title?: unknown } | undefined;
@@ -761,7 +783,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
761
783
  async handler(args: string, ctx: ExtensionCommandContext): Promise<void> {
762
784
  const id = sessionId(ctx);
763
785
  const command = args.trim().split(/\s+/, 1)[0]?.toLowerCase() || "status";
764
- const resolved = resolveSettings();
786
+ const resolved = resolveSettings(options.settings);
765
787
  const enabledWithoutLocalOff = isSessionNotificationsEnabled({
766
788
  cfg: resolved.cfg,
767
789
  env: process.env,
@@ -781,6 +803,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
781
803
  }
782
804
 
783
805
  if (command === "on") {
806
+ if (!isNotificationEligibleContext(ctx)) {
807
+ ctx.ui.notify("Notifications are disabled for subagent sessions.", "warning");
808
+ return;
809
+ }
784
810
  if (process.env.GJC_NOTIFICATIONS === "0") {
785
811
  ctx.ui.notify(
786
812
  "Notifications remain disabled: GJC_NOTIFICATIONS=0 is an authoritative opt-out.",
@@ -878,6 +904,10 @@ export const createNotificationsExtension: ExtensionFactory = api => {
878
904
  rt.sessionTag,
879
905
  );
880
906
  rt.disposeFileSink = registerTelegramFileSink(newId, async file => {
907
+ if (rt.redact) {
908
+ return { ok: false, error: TELEGRAM_FILE_REDACTION_ERROR };
909
+ }
910
+
881
911
  try {
882
912
  const data = await fs.promises.readFile(file.path);
883
913
  rt.server.pushFrame(
@@ -1099,4 +1129,4 @@ export const createNotificationsExtension: ExtensionFactory = api => {
1099
1129
  api.on("session_shutdown", async (_event, ctx) => {
1100
1130
  await stopSession(sessionId(ctx));
1101
1131
  });
1102
- };
1132
+ }