@gajae-code/coding-agent 0.7.11 → 0.8.1

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 (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +25 -2
  5. package/dist/types/discovery/cursor.d.ts +4 -1
  6. package/dist/types/discovery/index.d.ts +0 -1
  7. package/dist/types/discovery/windsurf.d.ts +3 -1
  8. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +5 -0
  9. package/dist/types/modes/components/custom-editor.d.ts +4 -0
  10. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  11. package/dist/types/modes/components/status-line.d.ts +8 -0
  12. package/dist/types/modes/components/two-column-body.d.ts +9 -0
  13. package/dist/types/modes/components/welcome.d.ts +9 -1
  14. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/notifications/telegram-daemon.d.ts +3 -0
  20. package/dist/types/runtime-mcp/config-writer.d.ts +9 -0
  21. package/dist/types/runtime-mcp/config.d.ts +2 -0
  22. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  24. package/dist/types/runtime-mcp/types.d.ts +6 -0
  25. package/dist/types/system-prompt.d.ts +7 -0
  26. package/dist/types/tools/skill.d.ts +1 -1
  27. package/dist/types/workflow/workflow-intent-diff.d.ts +19 -0
  28. package/package.json +10 -10
  29. package/src/capability/mcp.ts +2 -0
  30. package/src/config/mcp-schema.json +5 -1
  31. package/src/config/settings-schema.ts +18 -2
  32. package/src/coordinator-mcp/server.ts +39 -4
  33. package/src/discovery/builtin.ts +12 -0
  34. package/src/discovery/cursor.ts +5 -84
  35. package/src/discovery/gemini.ts +3 -89
  36. package/src/discovery/index.ts +4 -1
  37. package/src/discovery/mcp-json.ts +11 -0
  38. package/src/discovery/opencode.ts +5 -122
  39. package/src/discovery/windsurf.ts +4 -82
  40. package/src/edit/read-file.ts +9 -1
  41. package/src/extensibility/skills.ts +30 -2
  42. package/src/gjc-runtime/session-state-sidecar.ts +167 -24
  43. package/src/gjc-runtime/team-runtime.ts +56 -51
  44. package/src/internal-urls/docs-index.generated.ts +4 -4
  45. package/src/modes/acp/acp-agent.ts +7 -6
  46. package/src/modes/components/agent-dashboard.ts +1 -34
  47. package/src/modes/components/custom-editor.ts +18 -0
  48. package/src/modes/components/extensions/extension-dashboard.ts +2 -48
  49. package/src/modes/components/model-selector.ts +58 -2
  50. package/src/modes/components/session-selector.ts +19 -9
  51. package/src/modes/components/settings-selector.ts +13 -0
  52. package/src/modes/components/status-line.ts +215 -86
  53. package/src/modes/components/tree-selector.ts +28 -8
  54. package/src/modes/components/two-column-body.ts +36 -0
  55. package/src/modes/components/welcome.ts +171 -35
  56. package/src/modes/controllers/command-controller.ts +3 -2
  57. package/src/modes/controllers/event-controller.ts +4 -0
  58. package/src/modes/controllers/input-controller.ts +158 -38
  59. package/src/modes/controllers/selector-controller.ts +25 -28
  60. package/src/modes/interactive-mode.ts +20 -23
  61. package/src/modes/prompt-action-autocomplete.ts +17 -7
  62. package/src/modes/rpc/rpc-types.ts +1 -0
  63. package/src/modes/shared/agent-wire/deep-interview-gate.ts +9 -3
  64. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +20 -0
  65. package/src/modes/types.ts +1 -0
  66. package/src/notifications/index.ts +20 -1
  67. package/src/notifications/lifecycle-control-runtime.ts +12 -3
  68. package/src/notifications/telegram-daemon.ts +14 -0
  69. package/src/prompts/system/system-prompt.md +15 -1
  70. package/src/runtime-mcp/config-writer.ts +28 -0
  71. package/src/runtime-mcp/config.ts +7 -0
  72. package/src/runtime-mcp/loader.ts +3 -0
  73. package/src/runtime-mcp/manager.ts +3 -0
  74. package/src/runtime-mcp/types.ts +6 -0
  75. package/src/sdk.ts +4 -0
  76. package/src/session/agent-session.ts +21 -1
  77. package/src/session/session-manager.ts +58 -18
  78. package/src/system-prompt.ts +8 -0
  79. package/src/task/executor.ts +5 -0
  80. package/src/tools/ast-grep.ts +10 -7
  81. package/src/tools/bash.ts +15 -6
  82. package/src/tools/index.ts +8 -1
  83. package/src/tools/skill.ts +1 -1
  84. package/src/workflow/workflow-intent-diff.ts +199 -0
  85. package/dist/types/discovery/vscode.d.ts +0 -1
  86. package/src/discovery/vscode.ts +0 -105
@@ -15,7 +15,7 @@ import { formatModelProfileDisplayLabel, recommendModelProfileForProvider } from
15
15
  import { GJC_MODEL_ASSIGNMENT_TARGETS } from "../../config/model-registry";
16
16
  import { formatModelSelectorValue } from "../../config/model-resolver";
17
17
  import type { ModelProfileConfig } from "../../config/models-config-schema";
18
- import { settings } from "../../config/settings";
18
+ import { type Settings, settings } from "../../config/settings";
19
19
  import { DebugSelectorComponent } from "../../debug";
20
20
  import { disableProvider, enableProvider } from "../../discovery";
21
21
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -85,6 +85,7 @@ import {
85
85
  import { SessionObserverOverlayComponent } from "../components/session-observer-overlay";
86
86
  import { SessionSelectorComponent } from "../components/session-selector";
87
87
  import { SettingsSelectorComponent } from "../components/settings-selector";
88
+ import type { StatusLineSettings } from "../components/status-line";
88
89
  import { ThemeSelectorComponent } from "../components/theme-selector";
89
90
  import { ToolExecutionComponent } from "../components/tool-execution";
90
91
  import { TreeSelectorComponent } from "../components/tree-selector";
@@ -108,6 +109,24 @@ function isThemePreviewSuperseded(result: { success: boolean; error?: string }):
108
109
  return !result.success && result.error?.includes("superseded by a newer request") === true;
109
110
  }
110
111
 
112
+ /**
113
+ * Snapshot the persisted status-line settings that the status-line component
114
+ * cares about. Preview, cancel-restore, and commit paths all share this so the
115
+ * previewed row count (and every other field) can never drift out of sync.
116
+ */
117
+ export function buildStatusLineSettings(settingsInstance: Settings): StatusLineSettings {
118
+ return {
119
+ preset: settingsInstance.get("statusLine.preset"),
120
+ leftSegments: settingsInstance.get("statusLine.leftSegments"),
121
+ rightSegments: settingsInstance.get("statusLine.rightSegments"),
122
+ separator: settingsInstance.get("statusLine.separator"),
123
+ showHookStatus: settingsInstance.get("statusLine.showHookStatus"),
124
+ sessionAccent: settingsInstance.get("statusLine.sessionAccent"),
125
+ maxRows: settingsInstance.get("statusLine.maxRows"),
126
+ segmentOptions: settingsInstance.get("statusLine.segmentOptions"),
127
+ };
128
+ }
129
+
111
130
  function formatProviderOnboardingCommandGuide(): string {
112
131
  return [
113
132
  "Provider preset setup:",
@@ -442,13 +461,7 @@ export class SelectorController {
442
461
  onStatusLinePreview: previewSettings => {
443
462
  // Update status line with preview settings
444
463
  this.ctx.statusLine.updateSettings({
445
- preset: settings.get("statusLine.preset"),
446
- leftSegments: settings.get("statusLine.leftSegments"),
447
- rightSegments: settings.get("statusLine.rightSegments"),
448
- separator: settings.get("statusLine.separator"),
449
- showHookStatus: settings.get("statusLine.showHookStatus"),
450
- sessionAccent: settings.get("statusLine.sessionAccent"),
451
- segmentOptions: settings.get("statusLine.segmentOptions"),
464
+ ...buildStatusLineSettings(settings),
452
465
  ...previewSettings,
453
466
  });
454
467
  this.ctx.updateEditorTopBorder();
@@ -458,7 +471,7 @@ export class SelectorController {
458
471
  // Return the rendered status line for inline preview
459
472
  const availableWidth =
460
473
  width ?? this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
461
- return this.ctx.statusLine.getTopBorder(availableWidth).content;
474
+ return this.ctx.statusLine.getPreviewContent(availableWidth);
462
475
  },
463
476
  onPluginsChanged: () => {
464
477
  this.ctx.ui.requestRender();
@@ -466,15 +479,7 @@ export class SelectorController {
466
479
  onCancel: () => {
467
480
  done();
468
481
  // Restore status line to saved settings
469
- this.ctx.statusLine.updateSettings({
470
- preset: settings.get("statusLine.preset"),
471
- leftSegments: settings.get("statusLine.leftSegments"),
472
- rightSegments: settings.get("statusLine.rightSegments"),
473
- separator: settings.get("statusLine.separator"),
474
- showHookStatus: settings.get("statusLine.showHookStatus"),
475
- sessionAccent: settings.get("statusLine.sessionAccent"),
476
- segmentOptions: settings.get("statusLine.segmentOptions"),
477
- });
482
+ this.ctx.statusLine.updateSettings(buildStatusLineSettings(settings));
478
483
  this.ctx.updateEditorTopBorder();
479
484
  this.ctx.ui.requestRender();
480
485
  },
@@ -718,6 +723,7 @@ export class SelectorController {
718
723
  case "statusLineShowHooks":
719
724
  case "statusLine.showHookStatus":
720
725
  case "statusLine.sessionAccent":
726
+ case "statusLine.maxRows":
721
727
  case "statusLine.leftSegments":
722
728
  case "statusLine.rightSegments":
723
729
  case "statusLine.segmentOptions":
@@ -732,16 +738,7 @@ export class SelectorController {
732
738
  case "statusLineGitShowUntracked":
733
739
  case "statusLineTimeFormat":
734
740
  case "statusLineTimeShowSeconds": {
735
- const statusLineSettings = {
736
- preset: settings.get("statusLine.preset"),
737
- leftSegments: settings.get("statusLine.leftSegments"),
738
- rightSegments: settings.get("statusLine.rightSegments"),
739
- separator: settings.get("statusLine.separator"),
740
- showHookStatus: settings.get("statusLine.showHookStatus"),
741
- sessionAccent: settings.get("statusLine.sessionAccent"),
742
- segmentOptions: settings.get("statusLine.segmentOptions"),
743
- };
744
- this.ctx.statusLine.updateSettings(statusLineSettings);
741
+ this.ctx.statusLine.updateSettings(buildStatusLineSettings(settings));
745
742
  this.ctx.updateEditorTopBorder();
746
743
  this.ctx.ui.requestRender();
747
744
  break;
@@ -321,6 +321,7 @@ export class InteractiveMode implements InteractiveModeContext {
321
321
  }
322
322
  autoCompactionEscapeHandler?: () => void;
323
323
  retryEscapeHandler?: () => void;
324
+ retryEscapePrimed = false;
324
325
  retryCountdownTimer?: NodeJS.Timeout;
325
326
  unsubscribe?: () => void;
326
327
  onInputCallback?: (input: SubmittedUserInput) => void;
@@ -422,7 +423,7 @@ export class InteractiveMode implements InteractiveModeContext {
422
423
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
423
424
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
424
425
  this.editor.onAutocompleteCancel = () => {
425
- this.ui.requestRender(true);
426
+ this.ui.requestRender();
426
427
  };
427
428
  this.editor.onAutocompleteUpdate = () => {
428
429
  this.ui.requestRender();
@@ -432,7 +433,7 @@ export class InteractiveMode implements InteractiveModeContext {
432
433
  this.#syncEditorMaxHeight();
433
434
  this.updateEditorChrome();
434
435
  this.editor.invalidate();
435
- this.ui.requestRender(true, "resize");
436
+ this.ui.requestResizeRender();
436
437
  };
437
438
  process.stdout.on("resize", this.#resizeHandler);
438
439
  try {
@@ -530,6 +531,14 @@ export class InteractiveMode implements InteractiveModeContext {
530
531
  }
531
532
 
532
533
  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);
541
+
533
542
  // Add welcome header
534
543
  this.#welcomeComponent = new WelcomeComponent(
535
544
  this.#version,
@@ -538,30 +547,16 @@ export class InteractiveMode implements InteractiveModeContext {
538
547
  recentSessions,
539
548
  this.#getWelcomeLspServers(),
540
549
  welcomeLogoMode,
550
+ {
551
+ getViewportRows: () => this.ui.terminal.rows,
552
+ getReservedBottomRows: getWelcomeReservedBottomRows,
553
+ changelogMarkdown: this.#changelogMarkdown,
554
+ collapseChangelog: settings.get("collapseChangelog"),
555
+ },
541
556
  );
542
557
 
543
- // Setup UI layout
544
- this.ui.addChild(new Spacer(1));
545
558
  this.ui.addChild(this.#welcomeComponent);
546
- this.ui.addChild(new Spacer(1));
547
559
  this.#welcomeComponent.playIntro(() => this.ui.requestRender());
548
-
549
- // Add changelog if provided
550
- if (this.#changelogMarkdown) {
551
- this.ui.addChild(new DynamicBorder());
552
- if (settings.get("collapseChangelog")) {
553
- const versionMatch = this.#changelogMarkdown.match(/##\s+\[?(\d+\.\d+\.\d+)\]?/);
554
- const latestVersion = versionMatch ? versionMatch[1] : this.#version;
555
- const condensedText = `Updated to v${latestVersion}. Use ${theme.bold("/changelog")} to view full changelog.`;
556
- this.ui.addChild(new Text(condensedText, 1, 0));
557
- } else {
558
- this.ui.addChild(new Text(theme.bold(theme.fg("accent", "What's New")), 1, 0));
559
- this.ui.addChild(new Spacer(1));
560
- this.ui.addChild(new Markdown(this.#changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));
561
- this.ui.addChild(new Spacer(1));
562
- }
563
- this.ui.addChild(new DynamicBorder());
564
- }
565
560
  }
566
561
 
567
562
  this.ui.addChild(this.chatContainer);
@@ -1963,6 +1958,8 @@ export class InteractiveMode implements InteractiveModeContext {
1963
1958
  this.loadingAnimation.stop();
1964
1959
  this.loadingAnimation = undefined;
1965
1960
  }
1961
+ this.#welcomeComponent?.dispose();
1962
+ this.#welcomeComponent = undefined;
1966
1963
  this.#cleanupMicAnimation();
1967
1964
  this.#cancelGoalContinuation();
1968
1965
  if (this.#sttController) {
@@ -2076,7 +2073,7 @@ export class InteractiveMode implements InteractiveModeContext {
2076
2073
  nextEditor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
2077
2074
  nextEditor.setAutocompleteMaxVisible(this.settings.get("autocompleteMaxVisible"));
2078
2075
  nextEditor.onAutocompleteCancel = () => {
2079
- this.ui.requestRender(true);
2076
+ this.ui.requestRender();
2080
2077
  };
2081
2078
  nextEditor.onAutocompleteUpdate = () => {
2082
2079
  this.ui.requestRender();
@@ -177,6 +177,9 @@ function getSlashTokenPrefix(textBeforeCursor: string): string | null {
177
177
  return token;
178
178
  }
179
179
 
180
+ function isLeadingSlashToken(textBeforeCursor: string, slashPrefix: string): boolean {
181
+ return textBeforeCursor.trimStart() === slashPrefix;
182
+ }
180
183
  export class PromptActionAutocompleteProvider implements AutocompleteProvider {
181
184
  #baseProvider: CombinedAutocompleteProvider;
182
185
  #actions: PromptActionDefinition[];
@@ -223,10 +226,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
223
226
 
224
227
  const slashPrefix = getSlashTokenPrefix(textBeforeCursor);
225
228
  if (slashPrefix) {
226
- const baseSuggestions = withoutSkillCommandSuggestions(
227
- await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol),
228
- );
229
- const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor);
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;
230
237
  return sortSlashCommandSuggestions(
231
238
  mergeAutocompleteSuggestions(baseSuggestions, skillCommandSuggestions),
232
239
  this.#commands,
@@ -300,7 +307,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
300
307
  const baseSuggestions = withoutSkillCommandSuggestions(
301
308
  this.#baseProvider.trySyncSlashCompletion?.(textBeforeCursor) ?? null,
302
309
  );
303
- const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor);
310
+ const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor, { includeEmpty: false });
304
311
  return sortSlashCommandSuggestions(
305
312
  mergeAutocompleteSuggestions(baseSuggestions, skillCommandSuggestions),
306
313
  this.#commands,
@@ -311,11 +318,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
311
318
  return tryEmojiInlineReplace(textBeforeCursor);
312
319
  }
313
320
 
314
- #getSkillCommandSuggestions(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null {
321
+ #getSkillCommandSuggestions(
322
+ textBeforeCursor: string,
323
+ options: { includeEmpty: boolean },
324
+ ): { items: AutocompleteItem[]; prefix: string } | null {
315
325
  const prefix = getSlashTokenPrefix(textBeforeCursor);
316
326
  if (!prefix) return null;
317
327
  const query = prefix.slice(1).toLowerCase();
318
- if (query.length === 0) return null;
328
+ if (query.length === 0 && !options.includeEmpty) return null;
319
329
  const normalizedQuery = query.startsWith("skill-") ? `skill:${query.slice("skill-".length)}` : query;
320
330
  const exactNonSkillCommand = this.#commands.some(
321
331
  command => command.name === query && !command.name.startsWith("skill:"),
@@ -435,6 +435,7 @@ export interface RpcJsonSchema {
435
435
  items?: RpcJsonSchema;
436
436
  minLength?: number;
437
437
  maxLength?: number;
438
+ pattern?: string;
438
439
  minItems?: number;
439
440
  maxItems?: number;
440
441
  uniqueItems?: boolean;
@@ -131,7 +131,12 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
131
131
  properties: {
132
132
  selected: selectedBase,
133
133
  other: { type: "boolean", description: "set true to provide a free-text answer in `custom`" },
134
- custom: { type: "string", minLength: 1, description: "free-text answer; required when `other` is true" },
134
+ custom: {
135
+ type: "string",
136
+ minLength: 1,
137
+ pattern: "\\S",
138
+ description: "free-text answer; required when `other` is true",
139
+ },
135
140
  action: {
136
141
  type: "string",
137
142
  enum: ["answer", "clarify"],
@@ -140,6 +145,7 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
140
145
  question: {
141
146
  type: "string",
142
147
  minLength: 1,
148
+ pattern: "\\S",
143
149
  description: "clarification question; required when action is `clarify`",
144
150
  },
145
151
  },
@@ -156,7 +162,7 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
156
162
  properties: {
157
163
  selected: selectedWithOther,
158
164
  other: { const: true },
159
- custom: { type: "string", minLength: 1 },
165
+ custom: { type: "string", minLength: 1, pattern: "\\S" },
160
166
  action: { const: "answer" },
161
167
  },
162
168
  required: ["selected", "other", "custom"],
@@ -166,7 +172,7 @@ function questionAnswerSchema(question: AskGateQuestion, labels: string[]): RpcJ
166
172
  type: "object",
167
173
  properties: {
168
174
  action: { const: "clarify" },
169
- question: { type: "string", minLength: 1 },
175
+ question: { type: "string", minLength: 1, pattern: "\\S" },
170
176
  },
171
177
  required: ["action", "question"],
172
178
  additionalProperties: false,
@@ -25,6 +25,7 @@ const SUPPORTED_KEYWORDS = new Set<keyof RpcJsonSchema>([
25
25
  "items",
26
26
  "minLength",
27
27
  "maxLength",
28
+ "pattern",
28
29
  "minItems",
29
30
  "maxItems",
30
31
  "uniqueItems",
@@ -107,6 +108,17 @@ function walkSchema(schema: RpcJsonSchema, depth: number, path: string): void {
107
108
  throw new WorkflowGateSchemaError(`${meta} at ${path} must be a string`);
108
109
  }
109
110
  }
111
+ if (schema.pattern !== undefined) {
112
+ if (typeof schema.pattern !== "string") {
113
+ throw new WorkflowGateSchemaError(`pattern at ${path} must be a string`);
114
+ }
115
+ try {
116
+ new RegExp(schema.pattern);
117
+ } catch (error) {
118
+ const message = error instanceof Error ? error.message : String(error);
119
+ throw new WorkflowGateSchemaError(`pattern at ${path} must be a valid regular expression: ${message}`);
120
+ }
121
+ }
110
122
  for (const limit of ["minLength", "maxLength", "minItems", "maxItems"] as const) {
111
123
  const v = schema[limit];
112
124
  if (v !== undefined && (typeof v !== "number" || !Number.isInteger(v) || v < 0)) {
@@ -242,6 +254,14 @@ function validateValue(schema: RpcJsonSchema, value: unknown, path: string, erro
242
254
  expected: schema.maxLength,
243
255
  });
244
256
  }
257
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
258
+ errors.push({
259
+ path,
260
+ keyword: "pattern",
261
+ message: `does not match pattern ${schema.pattern}`,
262
+ expected: schema.pattern,
263
+ });
264
+ }
245
265
  }
246
266
  if (typeof value === "number") {
247
267
  if (schema.minimum !== undefined && value < schema.minimum) {
@@ -110,6 +110,7 @@ export interface InteractiveModeContext {
110
110
  retryLoader: Loader | undefined;
111
111
  autoCompactionEscapeHandler?: () => void;
112
112
  retryEscapeHandler?: () => void;
113
+ retryEscapePrimed: boolean;
113
114
  retryCountdownTimer?: NodeJS.Timeout;
114
115
  unsubscribe?: () => void;
115
116
  onInputCallback?: (input: SubmittedUserInput) => void;
@@ -26,7 +26,7 @@ import * as path from "node:path";
26
26
  import { promisify } from "node:util";
27
27
  import type { ImageContent, TextContent } from "@gajae-code/ai";
28
28
  import { NotificationServer } from "@gajae-code/natives";
29
- import { logger } from "@gajae-code/utils";
29
+ import { logger, postmortem } from "@gajae-code/utils";
30
30
  import { Settings } from "../config/settings";
31
31
  import type { ExtensionCommandContext, ExtensionContext, ExtensionFactory } from "../extensibility/extensions";
32
32
  import { registerAskAnswerSource } from "../tools/ask-answer-registry";
@@ -348,6 +348,8 @@ interface SessionRuntime {
348
348
  /** Assistant text already flushed before an ask this turn (turn-scoped dedupe
349
349
  * so turn_end does not re-emit the pre-ask lead-in). Reset each turn. */
350
350
  preAskFlushedText?: string;
351
+ /** Cancels the postmortem cleanup that emits `session_closed` on process teardown. */
352
+ cancelPostmortemCleanup: () => void;
351
353
  }
352
354
 
353
355
  interface ResolvedSettings {
@@ -500,6 +502,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
500
502
  if (!rt) return false;
501
503
  runtimes.delete(id);
502
504
  try {
505
+ rt.cancelPostmortemCleanup();
503
506
  rt.disposeAnswerSource();
504
507
  rt.disposeFileSink();
505
508
  } catch {}
@@ -680,6 +683,7 @@ export const createNotificationsExtension: ExtensionFactory = api => {
680
683
  pendingInteractive,
681
684
  disposeAnswerSource,
682
685
  disposeFileSink,
686
+ cancelPostmortemCleanup: () => {},
683
687
  redact,
684
688
  verbosity,
685
689
  sessionTag: tag,
@@ -687,6 +691,15 @@ export const createNotificationsExtension: ExtensionFactory = api => {
687
691
  pendingInbound: new Set<number>(),
688
692
  };
689
693
  runtimes.set(id, runtime);
694
+ // A native terminal close (SIGHUP), SIGTERM, Ctrl+C exit, or fatal error
695
+ // skips AgentSession.dispose(), so the `session_shutdown` extension event
696
+ // never fires and the daemon-side topic would be orphaned. postmortem
697
+ // awaits registered cleanups on those paths, so send the graceful
698
+ // `session_closed` frame from there too. stopSession() cancels this
699
+ // registration on every other teardown path, so it never double-fires.
700
+ runtime.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${id}`, async () => {
701
+ await stopSession(id);
702
+ });
690
703
  logger.info(`notifications: serving session ${id} at ${endpoint.url} (unattended=${unattended})`);
691
704
 
692
705
  if (settingsAvailable && settings && isGloballyConfigured(cfg)) {
@@ -848,9 +861,15 @@ export const createNotificationsExtension: ExtensionFactory = api => {
848
861
  // Re-bind the interactive ask answer source: the ask tool resolves the
849
862
  // source by the current session id, which just changed.
850
863
  try {
864
+ rt.cancelPostmortemCleanup();
851
865
  rt.disposeAnswerSource();
852
866
  rt.disposeFileSink();
853
867
  } catch {}
868
+ // Follow the id change so a later process teardown closes the re-keyed
869
+ // session (the old closure captured the retired id).
870
+ rt.cancelPostmortemCleanup = postmortem.register(`notifications-session-closed:${newId}`, async () => {
871
+ await stopSession(newId);
872
+ });
854
873
  rt.disposeAnswerSource = registerInteractiveAnswerSource(
855
874
  newId,
856
875
  rt.server,
@@ -238,6 +238,7 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
238
238
  // restart, so an unknown or ambiguous prefix fails closed instead of
239
239
  // blindly spawning `gjc --resume <prefix>` against a non-authoritative id.
240
240
  let resumeId = target.sessionIdOrPrefix;
241
+ let resumeCwd = target.path;
241
242
  if (opts.sessionsRoot) {
242
243
  const saved = listRecentSessions({ sessionsRoot: opts.sessionsRoot, limit: 1000 });
243
244
  const prefixed = saved.filter(
@@ -249,11 +250,19 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
249
250
  if (resolved.length > 1) {
250
251
  return { ambiguous: resolved.map(s => ({ sessionId: s.sessionId, path: s.path })) };
251
252
  }
252
- resumeId = resolved[0]!.sessionId;
253
+ const selected = resolved[0]!;
254
+ resumeId = selected.sessionId;
255
+ resumeCwd = selected.path;
256
+ }
257
+ const resolvedResumeCwd = resumeCwd ? path.resolve(resumeCwd) : undefined;
258
+ const resumeCwdStat = resolvedResumeCwd ? fs.statSync(resolvedResumeCwd, { throwIfNoEntry: false }) : undefined;
259
+ if (!resumeCwdStat?.isDirectory()) {
260
+ throw new Error(`gjc_lifecycle_resume_cwd_unavailable: ${resolvedResumeCwd ?? "(missing)"}`);
253
261
  }
254
262
  const tmux = resolveGjcTmuxCommand(env);
255
263
  const name = tmuxSessionNameFor(resumeId);
256
- const command = `exec env GJC_TMUX_LAUNCHED=1 GJC_NOTIFICATIONS=1 gjc --resume ${shellQuote(resumeId)}`;
264
+ const cwdPrefix = resolvedResumeCwd ? `cd ${shellQuote(resolvedResumeCwd)} && ` : "";
265
+ const command = `${cwdPrefix}exec env GJC_TMUX_LAUNCHED=1 GJC_NOTIFICATIONS=1 gjc --resume ${shellQuote(resumeId)}`;
257
266
  const r = Bun.spawnSync([tmux, "new-session", "-d", "-s", name, "sh", "-c", command], {
258
267
  stdout: "pipe",
259
268
  stderr: "pipe",
@@ -261,7 +270,7 @@ export function daemonResumeSession(env: NodeJS.ProcessEnv = process.env, opts:
261
270
  });
262
271
  if (r.exitCode !== 0) throw new Error(r.stderr.toString().trim() || "gjc_lifecycle_resume_failed");
263
272
  const tgt = buildGjcTmuxExactOptionTarget(name);
264
- for (const cmd of buildGjcTmuxProfileCommands(tgt, env, { sessionId: resumeId })) {
273
+ for (const cmd of buildGjcTmuxProfileCommands(tgt, env, { sessionId: resumeId, project: resolvedResumeCwd })) {
265
274
  Bun.spawnSync([tmux, ...cmd.args], { stdout: "pipe", stderr: "pipe", env });
266
275
  }
267
276
  return {
@@ -142,6 +142,9 @@ function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): str
142
142
  if (out) chunks.push(out);
143
143
  return chunks;
144
144
  }
145
+ function endpointGenerationKey(url: string, token: string): string {
146
+ return `${url}\0${token}`;
147
+ }
145
148
 
146
149
  /**
147
150
  * Whether `err` is a transient network failure worth retrying. Telegram API
@@ -772,6 +775,7 @@ export interface TelegramDaemonOptions {
772
775
  interface SessionSocket {
773
776
  sessionId: string;
774
777
  token: string;
778
+ endpointKey: string;
775
779
  ws: WebSocket;
776
780
  pending: Map<string, { sessionId: string; actionId: string }>;
777
781
  /** True once the server advertised the `client_ping_pong` capability. */
@@ -808,6 +812,8 @@ export class TelegramNotificationDaemon {
808
812
  private readonly topicOwnerByIdentity = new Map<string, string>();
809
813
  /** Non-identity frames held until identity creates the correct thread. */
810
814
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
815
+ /** Endpoint generation tombstones for sessions that already sent session_closed. */
816
+ private readonly closedEndpointKeys = new Map<string, string>();
811
817
  /** True once the daemon has nudged the user to enable Threaded Mode. */
812
818
  private threadedFallbackNoticeSent = false;
813
819
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -1223,7 +1229,9 @@ export class TelegramNotificationDaemon {
1223
1229
  matches: msg => msg.type === "session_closed",
1224
1230
  handle: async session => {
1225
1231
  this.busy.delete(session.sessionId);
1232
+ this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
1226
1233
  await this.deleteTopic(session.sessionId);
1234
+ this.dropSession(session, "session_closed");
1227
1235
  },
1228
1236
  });
1229
1237
  }
@@ -1260,6 +1268,9 @@ export class TelegramNotificationDaemon {
1260
1268
  // would chase a dead, token-bearing record forever.
1261
1269
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1262
1270
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
1271
+ const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
1272
+ if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1273
+ this.closedEndpointKeys.delete(sessionId);
1263
1274
  this.connectSession(sessionId, endpoint.url, endpoint.token);
1264
1275
  } catch {}
1265
1276
  }
@@ -1269,9 +1280,12 @@ export class TelegramNotificationDaemon {
1269
1280
  connectSession(sessionId: string, url: string, token: string): void {
1270
1281
  const WS = this.opts.WebSocketImpl ?? WebSocket;
1271
1282
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1283
+ const endpointKey = endpointGenerationKey(url, token);
1284
+ this.closedEndpointKeys.delete(sessionId);
1272
1285
  const session: SessionSocket = {
1273
1286
  sessionId,
1274
1287
  token,
1288
+ endpointKey,
1275
1289
  ws,
1276
1290
  pending: new Map(),
1277
1291
  capable: false,
@@ -61,10 +61,12 @@ Use for read-only plan critique. It approves only when execution can proceed wit
61
61
  <routing>
62
62
  - Clear, low-risk implementation request → implement directly with focused verification.
63
63
  - Do not invoke `deep-interview`, `ralplan`, `ultragoal`, `team`, or role agents for simple clear implementation requests; direct tools and the default launch path are enough.
64
+ - The runtime records a `workflow-intent-diff` CustomEntry for direct-path traceability; it does not participate in LLM context and is not a reason to slow down direct execution.
64
65
  - When a task is clear, bounded, and low-risk, the default action is to make the smallest correct change and verify it, not to interview, plan, open a durable ledger, or delegate.
65
66
  - Small verification needs do not make a task a planning workflow. Escalate only for real ambiguity, non-trivial architecture/sequence risk, durable multi-goal tracking, or useful coordinated workers.
67
+ - Root-cause phase schema is active only for contradiction, regression, or high-risk transition work; otherwise keep ordinary verification and do not add root-cause ceremony.
66
68
  - Vague requirements → use `deep-interview` before planning or execution.
67
- - Clear requirements but non-trivial architecture/sequence risk → use `ralplan` and stop at pending approval.
69
+ - Clear requirements but non-trivial architecture/sequence risk → use `ralplan --deliberate` and stop at pending approval.
68
70
  - Durable goal ledger needed → use `ultragoal`; if no approved plan exists, run `ralplan` first.
69
71
  - Approved work benefits from coordinated persistent workers → use `team`.
70
72
  - Large enough implementation work → delegate bounded slices to `executor` through the task/sub-agent tool when it improves quality or throughput.
@@ -139,6 +141,18 @@ Use tools whenever they materially improve correctness, completeness, or groundi
139
141
  </inventory>
140
142
  {{/if}}
141
143
 
144
+ {{#if toolDiscoveryActive}}
145
+ <tool-discovery>
146
+ Only essential tools are loaded up front; other tools are hidden to save context and are available on demand. Call `{{toolRefs.search_tool_bm25}}` with a short natural-language query to find and activate the tool you need, then call it. Prefer discovering a purpose-built tool over forcing the task onto a resident one.
147
+ {{#if discoverableTools.length}}
148
+ Discoverable tools:
149
+ {{#each discoverableTools}}
150
+ - `{{name}}`{{#if summary}} — {{summary}}{{/if}}
151
+ {{/each}}
152
+ {{/if}}
153
+ </tool-discovery>
154
+ {{/if}}
155
+
142
156
  <inputs>
143
157
  - Keep tool inputs concise where possible.
144
158
  - For `path` or path-like fields, prefer relative paths.
@@ -237,6 +237,34 @@ export async function listMCPServers(filePath: string): Promise<string[]> {
237
237
  return Object.keys(config.mcpServers ?? {});
238
238
  }
239
239
 
240
+ /**
241
+ * Set a server's autoload flag (connect automatically at session startup).
242
+ *
243
+ * Autoload defaults to true, so turning it on removes the key to keep the
244
+ * config file free of redundant fields; turning it off writes `autoload: false`.
245
+ *
246
+ * @throws Error if the server doesn't exist
247
+ */
248
+ export async function setServerAutoload(filePath: string, name: string, autoload: boolean): Promise<void> {
249
+ const existing = await readMCPConfigFile(filePath);
250
+ const server = existing.mcpServers?.[name];
251
+ if (!server) {
252
+ throw new Error(`Server "${name}" not found in ${filePath}`);
253
+ }
254
+
255
+ const { autoload: _removed, ...rest } = server;
256
+ const updatedServer = (autoload ? rest : { ...rest, autoload: false }) as MCPServerConfig;
257
+ const updated: MCPConfigFile = {
258
+ ...existing,
259
+ mcpServers: {
260
+ ...existing.mcpServers,
261
+ [name]: updatedServer,
262
+ },
263
+ };
264
+
265
+ await writeMCPConfigFile(filePath, updated);
266
+ }
267
+
240
268
  /**
241
269
  * Read the disabled servers list from a config file.
242
270
  */
@@ -20,6 +20,8 @@ export interface LoadMCPConfigsOptions {
20
20
  filterExa?: boolean;
21
21
  /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
22
22
  filterBrowser?: boolean;
23
+ /** Only include servers eligible for startup connection, i.e. autoload !== false (default: false) */
24
+ autoloadOnly?: boolean;
23
25
  }
24
26
 
25
27
  /** Result of loading MCP configs */
@@ -40,6 +42,7 @@ function convertToLegacyConfig(server: MCPServer): MCPServerConfig {
40
42
  const transport = server.transport ?? (server.command ? "stdio" : server.url ? "http" : "stdio");
41
43
  const shared = {
42
44
  enabled: server.enabled,
45
+ autoload: server.autoload,
43
46
  timeout: server.timeout,
44
47
  auth: server.auth,
45
48
  oauth: server.oauth,
@@ -96,6 +99,7 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
96
99
  const enableProjectConfig = options?.enableProjectConfig ?? true;
97
100
  const filterExa = options?.filterExa ?? true;
98
101
  const filterBrowser = options?.filterBrowser ?? false;
102
+ const autoloadOnly = options?.autoloadOnly ?? false;
99
103
 
100
104
  // Load MCP servers via capability system
101
105
  const result = await loadCapability<MCPServer>(mcpCapability.id, { cwd });
@@ -115,6 +119,9 @@ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOpt
115
119
  if (config.enabled === false || disabledServers.has(server.name)) {
116
120
  continue;
117
121
  }
122
+ if (autoloadOnly && config.autoload === false) {
123
+ continue;
124
+ }
118
125
  configs[server.name] = config;
119
126
  sources[server.name] = server._source;
120
127
  }
@@ -34,6 +34,8 @@ export interface MCPToolsLoadOptions {
34
34
  filterExa?: boolean;
35
35
  /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
36
36
  filterBrowser?: boolean;
37
+ /** Only connect servers with autoload !== false (default: false) */
38
+ autoloadOnly?: boolean;
37
39
  /** SQLite storage for MCP tool cache (null disables cache) */
38
40
  cacheStorage?: AgentStorage | null;
39
41
  /** Auth storage used to resolve OAuth credentials before initial MCP connect */
@@ -72,6 +74,7 @@ export async function discoverAndLoadMCPTools(cwd: string, options?: MCPToolsLoa
72
74
  enableProjectConfig: options?.enableProjectConfig,
73
75
  filterExa: options?.filterExa,
74
76
  filterBrowser: options?.filterBrowser,
77
+ autoloadOnly: options?.autoloadOnly,
75
78
  });
76
79
  } catch (error) {
77
80
  // If discovery fails entirely, return empty result
@@ -147,6 +147,8 @@ export interface MCPDiscoverOptions {
147
147
  filterExa?: boolean;
148
148
  /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */
149
149
  filterBrowser?: boolean;
150
+ /** Only connect servers with autoload !== false (default: false) */
151
+ autoloadOnly?: boolean;
150
152
  /** Called when starting to connect to servers */
151
153
  onConnecting?: (serverNames: string[]) => void;
152
154
  }
@@ -325,6 +327,7 @@ export class MCPManager {
325
327
  enableProjectConfig: options?.enableProjectConfig,
326
328
  filterExa: options?.filterExa,
327
329
  filterBrowser: options?.filterBrowser,
330
+ autoloadOnly: options?.autoloadOnly,
328
331
  });
329
332
  const result = await this.connectServers(configs, sources, options?.onConnecting);
330
333
  result.exaApiKeys = exaApiKeys;