@gajae-code/coding-agent 0.12.11 → 0.12.13

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 (140) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/types/cli/marketplace-hint.d.ts +28 -0
  3. package/dist/types/cli/notify-cli.d.ts +42 -1
  4. package/dist/types/cli/update-cli.d.ts +9 -3
  5. package/dist/types/cli.d.ts +10 -0
  6. package/dist/types/commands/notify.d.ts +6 -0
  7. package/dist/types/config/file-lock-gc.d.ts +13 -1
  8. package/dist/types/config/file-lock.d.ts +3 -0
  9. package/dist/types/config/model-registry.d.ts +6 -0
  10. package/dist/types/config/models-config-schema.d.ts +99 -0
  11. package/dist/types/coordinator/contract.d.ts +1 -1
  12. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  14. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  15. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  16. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  17. package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
  18. package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
  19. package/dist/types/main.d.ts +6 -2
  20. package/dist/types/modes/acp/acp-agent.d.ts +24 -1
  21. package/dist/types/modes/components/session-selector.d.ts +1 -1
  22. package/dist/types/modes/utils/host-status.d.ts +23 -0
  23. package/dist/types/runtime-mcp/types.d.ts +2 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  25. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  26. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  28. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  29. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  30. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  31. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  32. package/dist/types/sdk/bus/html-format.d.ts +10 -0
  33. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  34. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  35. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  36. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  37. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  38. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
  41. package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
  42. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  43. package/dist/types/sdk/host/host.d.ts +52 -0
  44. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  45. package/dist/types/sdk/session-activation.d.ts +41 -0
  46. package/dist/types/sdk/session.d.ts +18 -0
  47. package/dist/types/sdk/startup-capability.d.ts +8 -1
  48. package/dist/types/session/agent-session.d.ts +7 -2
  49. package/dist/types/session/artifacts.d.ts +2 -2
  50. package/dist/types/session/session-manager.d.ts +40 -6
  51. package/dist/types/tools/index.d.ts +15 -0
  52. package/dist/types/utils/npm-registry.d.ts +71 -0
  53. package/examples/extensions/README.md +38 -4
  54. package/examples/extensions/ooo-bridge.ts +15 -0
  55. package/package.json +8 -8
  56. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  57. package/src/async/job-manager.ts +9 -2
  58. package/src/cli/fast-help.ts +1 -0
  59. package/src/cli/marketplace-hint.ts +39 -0
  60. package/src/cli/notify-cli.ts +226 -2
  61. package/src/cli/plugin-cli.ts +10 -0
  62. package/src/cli/update-cli.ts +90 -23
  63. package/src/cli.ts +23 -0
  64. package/src/commands/notify.ts +27 -3
  65. package/src/commands/sdk.ts +1 -0
  66. package/src/config/file-lock-gc.ts +65 -26
  67. package/src/config/file-lock.ts +61 -16
  68. package/src/config/model-profiles.ts +46 -30
  69. package/src/config/model-registry.ts +17 -5
  70. package/src/config/models-config-schema.ts +9 -4
  71. package/src/coordinator/contract.ts +1 -0
  72. package/src/coordinator-mcp/question-state.ts +2 -0
  73. package/src/coordinator-mcp/server.ts +358 -7
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  76. package/src/extensibility/extensions/index.ts +1 -0
  77. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  78. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  79. package/src/extensibility/extensions/runner.ts +15 -2
  80. package/src/extensibility/extensions/types.ts +2 -0
  81. package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
  82. package/src/gjc-runtime/deep-interview-state.ts +27 -3
  83. package/src/gjc-runtime/gc-render.ts +8 -1
  84. package/src/gjc-runtime/gc-runtime.ts +15 -1
  85. package/src/gjc-runtime/launch-tmux.ts +53 -1
  86. package/src/internal-urls/docs-index.generated.ts +14 -13
  87. package/src/main.ts +214 -70
  88. package/src/modes/DESIGN.md +8 -5
  89. package/src/modes/acp/acp-agent.ts +123 -7
  90. package/src/modes/components/footer.ts +10 -18
  91. package/src/modes/components/model-selector.ts +52 -5
  92. package/src/modes/components/session-selector.ts +26 -3
  93. package/src/modes/components/status-line/gh.ts +23 -2
  94. package/src/modes/controllers/event-controller.ts +3 -0
  95. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  96. package/src/modes/controllers/input-controller.ts +11 -0
  97. package/src/modes/controllers/selector-controller.ts +8 -2
  98. package/src/modes/interactive-mode.ts +7 -1
  99. package/src/modes/print-mode.ts +10 -0
  100. package/src/modes/utils/host-status.ts +38 -0
  101. package/src/prompts/system/project-prompt.md +19 -0
  102. package/src/runtime-mcp/types.ts +2 -0
  103. package/src/sdk/broker/lifecycle.ts +60 -3
  104. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  105. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  106. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  107. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  108. package/src/sdk/bus/control-plane-frames.ts +51 -0
  109. package/src/sdk/bus/conversation-store.ts +29 -1
  110. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  111. package/src/sdk/bus/html-format.ts +19 -3
  112. package/src/sdk/bus/index.ts +91 -12
  113. package/src/sdk/bus/notification-service.ts +28 -1
  114. package/src/sdk/bus/rich-render.ts +3 -2
  115. package/src/sdk/bus/slack-daemon.ts +99 -4
  116. package/src/sdk/bus/slack-live-provider.ts +32 -2
  117. package/src/sdk/bus/slack-provider.ts +11 -0
  118. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  119. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  120. package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
  121. package/src/sdk/bus/telegram-daemon.ts +2007 -509
  122. package/src/sdk/bus/telegram-reference.ts +114 -15
  123. package/src/sdk/bus/topic-registry.ts +674 -181
  124. package/src/sdk/host/host.ts +180 -1
  125. package/src/sdk/lifecycle-session.ts +8 -2
  126. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  127. package/src/sdk/session-activation.ts +127 -0
  128. package/src/sdk/session.ts +144 -29
  129. package/src/sdk/startup-capability.ts +10 -1
  130. package/src/session/agent-session.ts +220 -13
  131. package/src/session/artifacts.ts +56 -13
  132. package/src/session/internal/managed-session-storage.ts +228 -7
  133. package/src/session/session-manager.ts +225 -60
  134. package/src/setup/provider-presets.json +3 -3
  135. package/src/task/index.ts +92 -44
  136. package/src/thinking-metadata.ts +2 -1
  137. package/src/tools/ask.ts +10 -0
  138. package/src/tools/index.ts +15 -0
  139. package/src/tools/todo-write.ts +4 -4
  140. package/src/utils/npm-registry.ts +660 -0
@@ -1721,9 +1721,13 @@ export class ModelSelectorComponent extends Container {
1721
1721
  : choice.role === null
1722
1722
  ? "temporary model"
1723
1723
  : GJC_MODEL_ASSIGNMENT_TARGETS[choice.role].name;
1724
+ // Show the highlighted reasoning level — never the model id (that mislabel
1725
+ // made "Reasoning for Default: gpt-5.6-luna" look like a level).
1726
+ const selectedLevel = choice.levels[this.#selectedThinkingIndex];
1727
+ const selectedLevelLabel = selectedLevel ? getThinkingLevelMetadata(selectedLevel).label : "—";
1724
1728
  this.#listContainer.addChild(new Spacer(1));
1725
1729
  this.#listContainer.addChild(
1726
- new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${choice.item.model.id}`), 0, 0),
1730
+ new Text(theme.fg("muted", ` Reasoning for ${targetLabel}: ${selectedLevelLabel}`), 0, 0),
1727
1731
  );
1728
1732
  this.#listContainer.addChild(new Spacer(1));
1729
1733
  for (let i = 0; i < choice.levels.length; i++) {
@@ -2079,8 +2083,13 @@ export class ModelSelectorComponent extends Container {
2079
2083
  this.#updateList();
2080
2084
  }
2081
2085
  }
2082
- #getInitialThinkingChoiceIndex(item: ModelItem | CanonicalModelItem, levels: ThinkingLevel[]): number {
2083
- const preferred = this.#getPreferredThinkingLevel(item);
2086
+ #getInitialThinkingChoiceIndex(
2087
+ item: ModelItem | CanonicalModelItem,
2088
+ levels: ThinkingLevel[],
2089
+ role: GjcModelAssignmentTargetId | null = null,
2090
+ roles?: readonly GjcModelAssignmentTargetId[],
2091
+ ): number {
2092
+ const preferred = this.#getPreferredThinkingLevel(item, role, roles);
2084
2093
  if (preferred && preferred !== ThinkingLevel.Inherit) {
2085
2094
  const index = levels.indexOf(preferred);
2086
2095
  if (index !== -1) return index;
@@ -2088,13 +2097,51 @@ export class ModelSelectorComponent extends Container {
2088
2097
  return 0;
2089
2098
  }
2090
2099
 
2091
- #getPreferredThinkingLevel(item: ModelItem | CanonicalModelItem): ThinkingLevel | undefined {
2100
+ /**
2101
+ * Prefer the role binding the user is editing when it already points at this
2102
+ * model so the reasoning menu opens on the level role badges already show
2103
+ * (e.g. EXECUTOR (xhigh) must not leave the cursor on "off").
2104
+ */
2105
+ #getPreferredThinkingLevel(
2106
+ item: ModelItem | CanonicalModelItem,
2107
+ role: GjcModelAssignmentTargetId | null = null,
2108
+ roles?: readonly GjcModelAssignmentTargetId[],
2109
+ ): ThinkingLevel | undefined {
2110
+ const roleThinking = this.#getAssignedThinkingLevelForModel(item.model, role, roles);
2111
+ if (roleThinking && roleThinking !== ThinkingLevel.Inherit) {
2112
+ return roleThinking;
2113
+ }
2092
2114
  if (item.thinkingLevel && item.thinkingLevel !== ThinkingLevel.Inherit) {
2093
2115
  return item.thinkingLevel;
2094
2116
  }
2095
2117
  return undefined;
2096
2118
  }
2097
2119
 
2120
+ #getAssignedThinkingLevelForModel(
2121
+ model: Model,
2122
+ role: GjcModelAssignmentTargetId | null,
2123
+ roles?: readonly GjcModelAssignmentTargetId[],
2124
+ ): ThinkingLevel | undefined {
2125
+ if (roles && roles.length > 0) {
2126
+ const assignedLevels = roles
2127
+ .map(targetRole => this.#roles[targetRole])
2128
+ .filter(
2129
+ (assigned): assigned is RoleAssignment =>
2130
+ assigned !== undefined &&
2131
+ modelsAreEqual(assigned.model, model) &&
2132
+ assigned.thinkingLevel !== ThinkingLevel.Inherit,
2133
+ )
2134
+ .map(assigned => assigned.thinkingLevel);
2135
+ if (assignedLevels.length === 0) return undefined;
2136
+ const first = assignedLevels[0];
2137
+ return assignedLevels.every(level => level === first) ? first : undefined;
2138
+ }
2139
+ if (role === null) return undefined;
2140
+ const assigned = this.#roles[role];
2141
+ if (!assigned || !modelsAreEqual(assigned.model, model)) return undefined;
2142
+ return assigned.thinkingLevel;
2143
+ }
2144
+
2098
2145
  #handleSelect(
2099
2146
  item: ModelItem | CanonicalModelItem,
2100
2147
  role: GjcModelAssignmentTargetId | null,
@@ -2109,7 +2156,7 @@ export class ModelSelectorComponent extends Container {
2109
2156
  if (!hasExplicitThinkingChoice && needsExplicitThinkingChoice) {
2110
2157
  const levels = getSelectableThinkingLevels(item.model);
2111
2158
  this.#pendingThinkingChoice = { item, role, roles, levels };
2112
- this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels);
2159
+ this.#selectedThinkingIndex = this.#getInitialThinkingChoiceIndex(item, levels, role, roles);
2113
2160
  this.#updateList();
2114
2161
  return;
2115
2162
  }
@@ -197,7 +197,7 @@ export class SessionSelectorComponent extends Container {
197
197
 
198
198
  constructor(
199
199
  sessions: SessionInfo[],
200
- private readonly onSelect: (sessionPath: string) => void,
200
+ private readonly onSelect: (sessionPath: string) => void | Promise<void>,
201
201
  private readonly onCancel: () => void,
202
202
  private readonly onExit: () => void,
203
203
  private readonly onDelete?: (session: SessionInfo) => Promise<boolean>,
@@ -214,7 +214,7 @@ export class SessionSelectorComponent extends Container {
214
214
  this.#sessionList = new SessionList(sessions);
215
215
  this.#sessionList.onSelect = session => {
216
216
  if (this.inspector) void this.#inspect(session);
217
- else this.onSelect(session.path);
217
+ else this.#dispatchSelect(session.path);
218
218
  };
219
219
  this.#sessionList.onCancel = () => this.#cancel();
220
220
  this.#sessionList.onExit = () => this.#exit();
@@ -241,6 +241,29 @@ export class SessionSelectorComponent extends Container {
241
241
  this.#onRequestRender?.();
242
242
  }
243
243
 
244
+ /**
245
+ * Observe async `onSelect` handlers. Callers (e.g. resume) may return a
246
+ * Promise; leaving it unobserved turns preparation/switch failures into
247
+ * process-killing unhandled rejections. Inline recovery keeps the picker
248
+ * usable when the host has not already closed it.
249
+ */
250
+ #dispatchSelect(sessionPath: string): void {
251
+ void Promise.resolve(this.onSelect(sessionPath)).catch((error: unknown) => {
252
+ if (this.#state.kind === "settled") {
253
+ // Host already closed the picker (interactive resume path). Surface the
254
+ // message only if we still have a render surface; controller catch is
255
+ // the primary recovery path after `done()`.
256
+ this.#showError(error instanceof Error ? error.message : String(error));
257
+ this.#requestRender();
258
+ return;
259
+ }
260
+ this.#state = { kind: "browsing" };
261
+ this.#sessionList.setInputFrozen(false);
262
+ this.#showError(error instanceof Error ? error.message : String(error));
263
+ this.#requestRender();
264
+ });
265
+ }
266
+
244
267
  #settle(selection: SessionSelectionResult): void {
245
268
  if (this.#state.kind === "settled") return;
246
269
  this.#state = { kind: "settled" };
@@ -249,7 +272,7 @@ export class SessionSelectorComponent extends Container {
249
272
  this.#confirmationDialog = null;
250
273
  if (dialog) this.removeChild(dialog);
251
274
  if (this.onSelection) this.onSelection(selection);
252
- else if (selection.kind === "selected") this.onSelect(selection.path);
275
+ else if (selection.kind === "selected") this.#dispatchSelect(selection.path);
253
276
  else this.onCancel();
254
277
  }
255
278
  #cancel(): void {
@@ -1,6 +1,26 @@
1
1
  import { type RunGh, runGhDefault } from "../../../utils/gh";
2
2
 
3
3
  const STATUS_LINE_GH_TIMEOUT_MS = 5_000;
4
+ const C0_C1_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
5
+
6
+ function canonicalPrUrl(value: unknown, number: number): string | null {
7
+ if (typeof value !== "string" || C0_C1_CONTROL_CHARACTERS.test(value)) return null;
8
+
9
+ try {
10
+ const url = new URL(value);
11
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
12
+ if (!url.hostname || url.username || url.password || url.search || url.hash) return null;
13
+
14
+ const suffix = `/pull/${number}`;
15
+ if (!url.pathname.endsWith(suffix)) return null;
16
+ const repositoryPath = url.pathname.slice(1, -suffix.length).split("/");
17
+ if (repositoryPath.length !== 2 || repositoryPath.some(component => component === "")) return null;
18
+
19
+ return url.href;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
4
24
 
5
25
  export async function lookupCurrentPr(runGh: RunGh = runGhDefault): Promise<{ number: number; url: string } | null> {
6
26
  try {
@@ -8,8 +28,9 @@ export async function lookupCurrentPr(runGh: RunGh = runGhDefault): Promise<{ nu
8
28
  if (result.exitCode !== 0 || result.timedOut) return null;
9
29
 
10
30
  const pr = JSON.parse(result.stdout) as { number?: unknown; url?: unknown };
11
- if (typeof pr.number !== "number" || typeof pr.url !== "string") return null;
12
- return { number: pr.number, url: pr.url };
31
+ if (typeof pr.number !== "number" || !Number.isSafeInteger(pr.number) || pr.number <= 0) return null;
32
+ const url = canonicalPrUrl(pr.url, pr.number);
33
+ return url ? { number: pr.number, url } : null;
13
34
  } catch {
14
35
  return null;
15
36
  }
@@ -32,6 +32,7 @@ import { computeIrcSplitWidths, getIrcSidebarSemanticToken } from "../components
32
32
  import type { IrcObservationRecord } from "../irc-observation-ledger";
33
33
  import { interruptHint } from "../shared";
34
34
  import { buildAbortDisplayMessage } from "../utils/abort-message";
35
+ import { emitHostStatus } from "../utils/host-status";
35
36
  import { consumeInjectedOptimisticSignature } from "../utils/injected-user-submission";
36
37
  import { parseIrcMessage } from "../utils/irc-message";
37
38
  import { ringTerminalBell } from "../utils/terminal-bell";
@@ -298,6 +299,7 @@ export class EventController {
298
299
  }
299
300
 
300
301
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
302
+ emitHostStatus("working");
301
303
  this.ctx.promptSuggestion?.onAgentStart();
302
304
  this.#lastIntent = undefined;
303
305
  this.#readToolCallArgs.clear();
@@ -924,6 +926,7 @@ export class EventController {
924
926
  this.ctx.updateEditorBorderColor();
925
927
  this.ctx.ui.requestRender();
926
928
  this.#scheduleIdleCompaction();
929
+ emitHostStatus("finished");
927
930
  this.sendCompletionNotification();
928
931
  this.ctx.promptSuggestion?.onAgentEnd();
929
932
  }
@@ -40,6 +40,7 @@ import { createReadonlySessionManager } from "../../session/session-manager";
40
40
  import { parseThinkingLevel } from "../../thinking";
41
41
  import type { TodoPhase } from "../../tools/todo-write";
42
42
  import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-generator";
43
+ import { emitHostStatus } from "../utils/host-status";
43
44
  import { applyInjectedUserSubmission } from "../utils/injected-user-submission";
44
45
  import { classifyHookSelectorBellEvent, ringTerminalBell } from "../utils/terminal-bell";
45
46
  import { prepareTranscriptRebuild } from "../utils/ui-helpers";
@@ -1258,6 +1259,7 @@ export class ExtensionUiController {
1258
1259
  computeBudget();
1259
1260
 
1260
1261
  ringTerminalBell(classifyHookSelectorBellEvent(title));
1262
+ emitHostStatus("attention");
1261
1263
 
1262
1264
  this.ctx.hookSelector = new HookSelectorComponent(
1263
1265
  title,
@@ -822,6 +822,17 @@ export class InputController {
822
822
  if (runner?.hasHandlers("input")) {
823
823
  const result = await runner.emitInput(text, inputImages, "interactive");
824
824
  if (result?.handled) {
825
+ const handledText = result.text === undefined ? "" : sanitizeText(result.text).trim();
826
+ if (handledText) {
827
+ this.ctx.addMessageToChat({
828
+ role: "custom",
829
+ customType: "extension-input-result",
830
+ content: handledText,
831
+ display: true,
832
+ timestamp: Date.now(),
833
+ });
834
+ this.ctx.ui.requestRender();
835
+ }
825
836
  if (this.#canModifyComposer(composer)) {
826
837
  this.ctx.editor.setText("");
827
838
  }
@@ -2638,9 +2638,15 @@ export class SelectorController {
2638
2638
  this.showSelector(done => {
2639
2639
  const selector = new SessionSelectorComponent(
2640
2640
  sessions,
2641
- async sessionPath => {
2641
+ sessionPath => {
2642
+ // `onSelect` is a void dispatch boundary: close the picker first, then
2643
+ // observe resume failures so a managed-candidate race (or any other
2644
+ // preparation/switch rejection) surfaces as UI error instead of an
2645
+ // unhandled rejection that can kill the process. Do not auto-retry.
2642
2646
  done();
2643
- await this.handleResumeSession(sessionPath);
2647
+ void this.handleResumeSession(sessionPath).catch(error => {
2648
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
2649
+ });
2644
2650
  },
2645
2651
  () => {
2646
2652
  done();
@@ -1036,9 +1036,15 @@ export class InteractiveMode implements InteractiveModeContext {
1036
1036
  if (this.session.getGoalModeState()?.mode === "exiting") {
1037
1037
  await this.#goalModeController.beforeGetUserInput();
1038
1038
  }
1039
- const { promise, resolve } = Promise.withResolvers<SubmittedUserInput>();
1039
+ const { promise, resolve, reject } = Promise.withResolvers<SubmittedUserInput>();
1040
+ let unsubscribeStop = () => {};
1041
+ unsubscribeStop = this.onStop(() => {
1042
+ this.onInputCallback = undefined;
1043
+ reject(Object.assign(new Error("Interactive mode stopped"), { code: "cancelled" }));
1044
+ });
1040
1045
  this.onInputCallback = input => {
1041
1046
  this.onInputCallback = undefined;
1047
+ unsubscribeStop();
1042
1048
  resolve(input);
1043
1049
  };
1044
1050
  this.#goalModeController.scheduleContinuation();
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { type AssistantMessage, type ImageContent, isContextOverflow } from "@gajae-code/ai";
9
9
  import { isKnownSinkPeerClosedError, logger, sanitizeText } from "@gajae-code/utils";
10
+ import { loadSlashCommands } from "../extensibility/slash-commands";
10
11
  import type { AgentSession } from "../session/agent-session";
11
12
  import { isSilentAbort } from "../session/messages";
12
13
  import { initializeExtensions } from "./runtime-init";
@@ -207,6 +208,15 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
207
208
  if (header) stdout.write(`${JSON.stringify(header)}\n`);
208
209
  }
209
210
 
211
+ // Bundled and file-based slash commands (`/init`, project `commands/*.md`, …) expand
212
+ // inside AgentSession#prompt from the list handed over by setSlashCommands. Interactive
213
+ // mode loads it while building its autocomplete; print mode has no autocomplete, so
214
+ // without this the list stays empty and a leading-slash prompt reaches the model as
215
+ // literal prose — no expansion, no error, and an answer that looks like the command ran.
216
+ await logger.time("print:slash-commands", async () => {
217
+ session.setSlashCommands(await loadSlashCommands({ cwd: session.sessionManager.getCwd() }));
218
+ });
219
+
210
220
  // Set up extensions for print mode (no UI, no command context).
211
221
  await initializeExtensions(session, {
212
222
  reportSendError: (action, err) => {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Host-terminal agent status markers.
3
+ *
4
+ * Interactive turns announce their state as an OSC 777 sequence:
5
+ *
6
+ * ESC ] 777 ; notify;Terax;gjc;<event> BEL
7
+ *
8
+ * A terminal that understands the sequence (Terax reads it to drive its per-tab
9
+ * agent status and header bell) reacts; every other terminal discards the OSC
10
+ * as unknown, exactly like the OSC 133 prompt marks and OSC 7 cwd reports that
11
+ * shells emit unconditionally. So there is no host sniffing and no env gate:
12
+ * any host that wants agent status can parse the stream without gjc knowing it
13
+ * exists.
14
+ *
15
+ * `notify;Terax;` is the wire identifier of the only protocol that currently
16
+ * carries per-turn agent state, not a host check - the payload names gjc as the
17
+ * reporting agent.
18
+ *
19
+ * Interactive TUI only. Print and RPC mode stdout stays byte-for-byte parseable
20
+ * because they never reach these controllers.
21
+ */
22
+
23
+ export type HostStatusEvent = "working" | "attention" | "finished";
24
+
25
+ function marker(event: HostStatusEvent): string {
26
+ return `\x1b]777;notify;Terax;gjc;${event}\x07`;
27
+ }
28
+
29
+ export function emitHostStatus(
30
+ event: HostStatusEvent,
31
+ output: Pick<NodeJS.WriteStream, "write"> = process.stdout,
32
+ ): void {
33
+ try {
34
+ output.write(marker(event));
35
+ } catch {
36
+ // Best-effort host integration only.
37
+ }
38
+ }
@@ -21,6 +21,25 @@ MUST read before making changes within:
21
21
  {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
22
22
  </dir-context>
23
23
  {{/if}}
24
+
25
+ {{#if alwaysApplyRules.length}}
26
+ {{#each alwaysApplyRules}}
27
+ {{content}}
28
+ {{/each}}
29
+ {{/if}}
30
+ {{#if rules.length}}
31
+ Rules are local constraints. You MUST read `rule://<name>` when working in that domain.
32
+ <rules>
33
+ {{#list rules join="\n"}}
34
+ <rule name="{{name}}">
35
+ {{description}}
36
+ {{#if globs.length}}
37
+ {{#list globs join="\n"}}<glob>{{this}}</glob>{{/list}}
38
+ {{/if}}
39
+ </rule>
40
+ {{/list}}
41
+ </rules>
42
+ {{/if}}
24
43
  <critical>
25
44
  - Each response MUST advance the task. There is no stopping condition other than completion.
26
45
  - You MUST default to informed action; do not ask for confirmation when tools or repo context can answer.
@@ -217,6 +217,8 @@ export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent;
217
217
  export interface MCPToolCallResult {
218
218
  content: MCPContent[];
219
219
  isError?: boolean;
220
+ /** MCP result metadata, encoded as `_meta` on the wire. */
221
+ _meta?: Record<string, unknown>;
220
222
  }
221
223
 
222
224
  // =============================================================================
@@ -31,6 +31,7 @@ import {
31
31
  } from "../../session/session-storage";
32
32
  import type { SessionLifecycleMcpServer } from "../acp/mcp";
33
33
  import { SdkClient, SdkClientError } from "../client/client";
34
+ import { SESSION_PREPARED_EVENT } from "../host/host";
34
35
  import {
35
36
  type LogicalSessionCandidate,
36
37
  listManagedSessionCandidates,
@@ -202,6 +203,16 @@ export interface SessionLifecycleTranscriptIdentity {
202
203
  sha256: string;
203
204
  }
204
205
 
206
+ /**
207
+ * When a lifecycle-managed session publishes its replayable readiness signal.
208
+ *
209
+ * `immediate` is the stock contract. `deferred` prepares the session instead:
210
+ * the child publishes a distinct prepared signal, keeps `session_ready`
211
+ * withheld, and stays unusable for input until it is explicitly activated. It
212
+ * is broker-issued and session-scoped precisely so a prepared session can never
213
+ * be produced by an inherited process-global flag.
214
+ */
215
+ export type SessionLifecycleReadiness = "immediate" | "deferred";
205
216
  export interface SessionLifecycleLaunchRequest {
206
217
  operation: "session.create" | "session.fork" | "session.resume";
207
218
  sessionId: string;
@@ -218,6 +229,8 @@ export interface SessionLifecycleLaunchRequest {
218
229
  modelPreset?: string;
219
230
  mcpServers?: SessionLifecycleMcpServer[];
220
231
  worktree?: SessionLifecycleWorktreeTarget;
232
+ /** Absent means the stock immediate contract; `deferred` prepares the session. */
233
+ readiness?: SessionLifecycleReadiness;
221
234
  receivedAt: number;
222
235
  requestedReadinessTimeoutMs: number;
223
236
  semanticReadyDeadlineAt: number;
@@ -363,6 +376,8 @@ export function readSessionLifecycleLaunchRequest(
363
376
  now,
364
377
  ) ||
365
378
  (request.worktree !== undefined && !isLifecycleWorktreeTarget(request.worktree)) ||
379
+ (request.readiness !== undefined && request.readiness !== "immediate" && request.readiness !== "deferred") ||
380
+ (request.readiness === "deferred" && request.operation !== "session.create") ||
366
381
  (request.operation === "session.resume" &&
367
382
  !hasValidTranscriptAuthority(request.sessionPath, request.sessionIdentity)) ||
368
383
  (request.operation === "session.fork" &&
@@ -386,6 +401,7 @@ type SessionLaunch = {
386
401
  modelPreset?: string;
387
402
  mcpServers?: SessionLifecycleMcpServer[];
388
403
  worktree?: SessionLifecycleWorktreeTarget;
404
+ readiness?: SessionLifecycleReadiness;
389
405
  worktreePlan?: GjcLaunchWorktreePlan;
390
406
  };
391
407
 
@@ -2294,6 +2310,16 @@ function sameReadyAuthority(left: ReadyAuthority, right: ReadyAuthority): boolea
2294
2310
  );
2295
2311
  }
2296
2312
 
2313
+ /**
2314
+ * Wait for the child's semantic completion signal at exactly this endpoint.
2315
+ *
2316
+ * `session_ready` is the stock signal. A deferred launch waits on
2317
+ * `session_prepared` instead: it is the same authenticated, replayable proof
2318
+ * that the child finished initializing and owns its endpoint, minus the
2319
+ * readiness no consumer may act on yet. Both are additionally bound to the
2320
+ * owner-proved lifecycle receipt through `currentReadyAuthority`, so an
2321
+ * endpoint file appearing on its own never satisfies either wait.
2322
+ */
2297
2323
  async function waitForReady(
2298
2324
  broker: Broker,
2299
2325
  id: string,
@@ -2301,6 +2327,7 @@ async function waitForReady(
2301
2327
  deadline: number,
2302
2328
  expected: EffectMarker,
2303
2329
  timing: LifecycleTiming,
2330
+ signal: "session_ready" | typeof SESSION_PREPARED_EVENT = "session_ready",
2304
2331
  ): Promise<ReadinessResult> {
2305
2332
  while (timing.now() < deadline) {
2306
2333
  const startupFailure = await readSessionLifecycleFailure(root, id, expected);
@@ -2349,7 +2376,7 @@ async function waitForReady(
2349
2376
  const frame = event as Record<string, unknown>;
2350
2377
  return (
2351
2378
  frame.type === "event" &&
2352
- frame.name === "session_ready" &&
2379
+ frame.name === signal &&
2353
2380
  frame.sessionId === id &&
2354
2381
  frame.generation === authority.endpointGeneration
2355
2382
  );
@@ -2446,8 +2473,28 @@ async function launchInput(
2446
2473
  return fail("invalid_input", "mcpServers must contain unique valid stdio, HTTP, or SSE server definitions.");
2447
2474
  const mcpServers = input.mcpServers as SessionLifecycleMcpServer[] | undefined;
2448
2475
 
2476
+ /**
2477
+ * Prepared readiness is an explicit creation-only intent. Only the two exact
2478
+ * enum values are admissible, and a foreign value is refused rather than
2479
+ * collapsed into the stock immediate contract.
2480
+ */
2481
+ if (input.readiness !== undefined && input.readiness !== "immediate" && input.readiness !== "deferred")
2482
+ return fail("invalid_input", "readiness must be either immediate or deferred.");
2483
+ const readiness = input.readiness as SessionLifecycleReadiness | undefined;
2484
+ if (readiness === "deferred" && operation !== "session.create")
2485
+ return fail("invalid_input", "readiness deferred is only supported for session.create.");
2486
+
2449
2487
  if (operation === "session.create")
2450
- return { id: randomUUID(), cwd, root: resolvedRoot, modelPreset, mcpServers, worktree, worktreePlan };
2488
+ return {
2489
+ id: randomUUID(),
2490
+ cwd,
2491
+ root: resolvedRoot,
2492
+ modelPreset,
2493
+ mcpServers,
2494
+ worktree,
2495
+ worktreePlan,
2496
+ ...(readiness ? { readiness } : {}),
2497
+ };
2451
2498
  if (operation === "session.resume") {
2452
2499
  if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
2453
2500
  const savedPath = text(input.sessionPath);
@@ -3107,6 +3154,7 @@ async function executeLifecycleResponse(
3107
3154
  ...(launch.modelPreset ? { modelPreset: launch.modelPreset } : {}),
3108
3155
  ...(launch.mcpServers ? { mcpServers: launch.mcpServers } : {}),
3109
3156
  ...(launch.worktree ? { worktree: launch.worktree } : {}),
3157
+ ...(launch.readiness ? { readiness: launch.readiness } : {}),
3110
3158
  };
3111
3159
  let child: ChildProcess | undefined;
3112
3160
  let spawnedAuthority: EffectMarker | undefined;
@@ -3161,7 +3209,15 @@ async function executeLifecycleResponse(
3161
3209
  if (!child || !spawnedAuthority)
3162
3210
  return fail("spawn_failed", "Unable to retain the spawned session process identity.");
3163
3211
  await broker.ledger.transition(identity, "awaiting_ready", { intendedSessionId: launch.id, effectMarker });
3164
- const readiness = await waitForReady(broker, launch.id, launch.root, readinessDeadline, spawnedAuthority, timing);
3212
+ const readiness = await waitForReady(
3213
+ broker,
3214
+ launch.id,
3215
+ launch.root,
3216
+ readinessDeadline,
3217
+ spawnedAuthority,
3218
+ timing,
3219
+ launch.readiness === "deferred" ? SESSION_PREPARED_EVENT : "session_ready",
3220
+ );
3165
3221
 
3166
3222
  if (readiness.kind !== "ready") {
3167
3223
  const terminated = await terminateSpawnedChild(
@@ -3220,6 +3276,7 @@ async function executeLifecycleResponse(
3220
3276
  sessionId: launch.id,
3221
3277
  cwd: launch.cwd,
3222
3278
  endpoint: verified.endpoint,
3279
+ ...(launch.readiness === "deferred" ? { readiness: "prepared" as const } : {}),
3223
3280
  ...(worktreeReceipt ? { worktree: worktreeReceipt } : {}),
3224
3281
  },
3225
3282
  };
@@ -1,9 +1,16 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as path from "node:path";
3
+ import {
4
+ type ChatDaemonCommandBindInput,
5
+ type ChatDaemonCommandOutcome,
6
+ serveChatDaemonCommandsOnce,
7
+ } from "./chat-daemon-command-channel";
3
8
  import {
4
9
  acquireChatDaemonOwnership,
5
10
  type ChatDaemonKind,
11
+ chatDaemonGeneration,
6
12
  clearChatDaemonControlRequest,
13
+ hasSafeChatDaemonStateShape,
7
14
  readChatDaemonControlRequest,
8
15
  readChatDaemonState,
9
16
  releaseChatDaemonOwnership,
@@ -22,6 +29,8 @@ export interface ChatDaemonRuntimeHandle {
22
29
  start(): Promise<void>;
23
30
  stop(): Promise<void>;
24
31
  transportHealthy?(): boolean;
32
+ /** Executes operator commands that must run inside the owning daemon. */
33
+ bindExistingRoot?(request: ChatDaemonCommandBindInput): Promise<ChatDaemonCommandOutcome>;
25
34
  }
26
35
 
27
36
  export interface RunChatDaemonInternalDeps {
@@ -212,12 +221,45 @@ export async function runChatDaemonInternal(
212
221
  }
213
222
  })();
214
223
  }, 5_000);
224
+ const ownedIncarnation = incarnation;
225
+ const stillOwner = async (): Promise<boolean> => {
226
+ const current = await readChatDaemonState(agentDir, kind);
227
+ return (
228
+ hasSafeChatDaemonStateShape(current) &&
229
+ current.kind === kind &&
230
+ current.ownerId === ownerId &&
231
+ current.pid === daemonPid &&
232
+ current.incarnation === ownedIncarnation &&
233
+ current.generation === chatDaemonGeneration(kind) &&
234
+ current.stoppedAt === undefined
235
+ );
236
+ };
237
+ const bindExistingRoot = activeRuntime.bindExistingRoot?.bind(activeRuntime);
215
238
  while (!stopping) {
216
239
  const request = await readChatDaemonControlRequest(agentDir, kind);
217
240
  if (request?.ownerId === ownerId && request.incarnation === incarnation) {
218
241
  await clearChatDaemonControlRequest(agentDir, kind, request.requestId);
219
242
  break;
220
243
  }
244
+ // Operator commands are served in place: unlike a lifecycle request they
245
+ // must never end this loop. A serving failure degrades to the caller's
246
+ // timeout rather than terminating a healthy transport.
247
+ if (bindExistingRoot) {
248
+ try {
249
+ await serveChatDaemonCommandsOnce({
250
+ agentDir,
251
+ kind,
252
+ ownerId,
253
+ pid: daemonPid,
254
+ incarnation: ownedIncarnation,
255
+ generation: chatDaemonGeneration(kind),
256
+ handler: { bindExistingRoot },
257
+ verifyOwnership: stillOwner,
258
+ });
259
+ } catch {
260
+ // Retried on the next poll; the submitter observes a timeout.
261
+ }
262
+ }
221
263
  await new Promise(resolve => setTimeout(resolve, 100));
222
264
  }
223
265
  } finally {