@gajae-code/coding-agent 0.2.5 → 0.3.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 (234) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/async/job-manager.d.ts +91 -2
  3. package/dist/types/cli/args.d.ts +1 -1
  4. package/dist/types/commands/deep-interview.d.ts +3 -0
  5. package/dist/types/commands/harness.d.ts +37 -0
  6. package/dist/types/config/keybindings.d.ts +5 -0
  7. package/dist/types/config/settings-schema.d.ts +10 -4
  8. package/dist/types/config/settings.d.ts +2 -0
  9. package/dist/types/debug/crash-diagnostics.d.ts +45 -0
  10. package/dist/types/debug/runtime-gauges.d.ts +6 -0
  11. package/dist/types/deep-interview/render-middleware.d.ts +6 -0
  12. package/dist/types/eval/py/executor.d.ts +2 -0
  13. package/dist/types/eval/py/kernel.d.ts +2 -0
  14. package/dist/types/exec/bash-executor.d.ts +10 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  17. package/dist/types/extensibility/shared-events.d.ts +1 -0
  18. package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
  19. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
  20. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  21. package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
  22. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  23. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
  25. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  26. package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  28. package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
  29. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  30. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  31. package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
  32. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  33. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  34. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  35. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  36. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  37. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  38. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  39. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  40. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  41. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  42. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  43. package/dist/types/harness-control-plane/types.d.ts +162 -0
  44. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  45. package/dist/types/hooks/skill-state.d.ts +23 -29
  46. package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
  47. package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
  48. package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
  49. package/dist/types/internal-urls/types.d.ts +4 -0
  50. package/dist/types/lsp/index.d.ts +10 -10
  51. package/dist/types/modes/bridge/auth.d.ts +12 -0
  52. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
  53. package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
  54. package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
  55. package/dist/types/modes/bridge/event-stream.d.ts +8 -0
  56. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  57. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  58. package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
  59. package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
  60. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  61. package/dist/types/modes/components/status-line.d.ts +2 -0
  62. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  63. package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
  64. package/dist/types/modes/index.d.ts +1 -0
  65. package/dist/types/modes/interactive-mode.d.ts +2 -0
  66. package/dist/types/modes/jobs-observer.d.ts +57 -0
  67. package/dist/types/modes/rpc/host-tools.d.ts +1 -16
  68. package/dist/types/modes/rpc/host-uris.d.ts +1 -38
  69. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
  70. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
  71. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
  72. package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
  73. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
  74. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
  75. package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
  76. package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
  77. package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
  78. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
  79. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
  80. package/dist/types/modes/types.d.ts +2 -0
  81. package/dist/types/sdk.d.ts +4 -0
  82. package/dist/types/session/agent-session.d.ts +19 -1
  83. package/dist/types/skill-state/active-state.d.ts +2 -0
  84. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  85. package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
  86. package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
  87. package/dist/types/task/executor.d.ts +3 -0
  88. package/dist/types/task/id.d.ts +7 -0
  89. package/dist/types/task/index.d.ts +5 -0
  90. package/dist/types/task/receipt.d.ts +85 -0
  91. package/dist/types/task/spawn-gate.d.ts +38 -0
  92. package/dist/types/task/types.d.ts +198 -14
  93. package/dist/types/tools/cron.d.ts +6 -0
  94. package/dist/types/tools/index.d.ts +2 -0
  95. package/dist/types/tools/path-utils.d.ts +1 -0
  96. package/dist/types/tools/subagent.d.ts +26 -1
  97. package/package.json +7 -7
  98. package/scripts/build-binary.ts +7 -0
  99. package/src/async/job-manager.ts +334 -6
  100. package/src/cli/args.ts +9 -2
  101. package/src/cli/auth-broker-cli.ts +1 -0
  102. package/src/cli/config-cli.ts +10 -2
  103. package/src/cli.ts +2 -0
  104. package/src/commands/deep-interview.ts +1 -0
  105. package/src/commands/harness.ts +862 -0
  106. package/src/commands/launch.ts +2 -2
  107. package/src/commands/state.ts +2 -1
  108. package/src/commands/team.ts +54 -39
  109. package/src/config/keybindings.ts +6 -0
  110. package/src/config/settings-schema.ts +13 -3
  111. package/src/config/settings.ts +5 -0
  112. package/src/dap/client.ts +17 -3
  113. package/src/debug/crash-diagnostics.ts +223 -0
  114. package/src/debug/runtime-gauges.ts +20 -0
  115. package/src/deep-interview/render-middleware.ts +372 -0
  116. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  117. package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
  118. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  119. package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
  120. package/src/eval/py/executor.ts +21 -1
  121. package/src/eval/py/kernel.ts +15 -0
  122. package/src/exec/bash-executor.ts +41 -0
  123. package/src/extensibility/custom-tools/types.ts +1 -0
  124. package/src/extensibility/extensions/types.ts +6 -0
  125. package/src/extensibility/shared-events.ts +1 -0
  126. package/src/gjc-runtime/cli-write-receipt.ts +31 -0
  127. package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
  128. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  129. package/src/gjc-runtime/ralplan-runtime.ts +235 -43
  130. package/src/gjc-runtime/state-graph.ts +86 -0
  131. package/src/gjc-runtime/state-migrations.ts +179 -0
  132. package/src/gjc-runtime/state-renderer.ts +345 -0
  133. package/src/gjc-runtime/state-runtime.ts +1155 -46
  134. package/src/gjc-runtime/state-schema.ts +192 -0
  135. package/src/gjc-runtime/state-validation.ts +49 -0
  136. package/src/gjc-runtime/state-writer.ts +749 -0
  137. package/src/gjc-runtime/team-runtime.ts +1255 -189
  138. package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
  139. package/src/gjc-runtime/workflow-command-ref.ts +239 -0
  140. package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
  141. package/src/gjc-runtime/workflow-manifest.ts +427 -0
  142. package/src/harness-control-plane/classifier.ts +128 -0
  143. package/src/harness-control-plane/control-endpoint.ts +148 -0
  144. package/src/harness-control-plane/finalize.ts +222 -0
  145. package/src/harness-control-plane/frame-mapper.ts +286 -0
  146. package/src/harness-control-plane/operate.ts +225 -0
  147. package/src/harness-control-plane/owner.ts +600 -0
  148. package/src/harness-control-plane/preserve.ts +102 -0
  149. package/src/harness-control-plane/receipts.ts +216 -0
  150. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  151. package/src/harness-control-plane/seams.ts +39 -0
  152. package/src/harness-control-plane/session-lease.ts +388 -0
  153. package/src/harness-control-plane/state-machine.ts +98 -0
  154. package/src/harness-control-plane/storage.ts +257 -0
  155. package/src/harness-control-plane/types.ts +214 -0
  156. package/src/hooks/skill-keywords.ts +4 -2
  157. package/src/hooks/skill-state.ts +197 -64
  158. package/src/internal-urls/agent-protocol.ts +68 -21
  159. package/src/internal-urls/artifact-protocol.ts +12 -17
  160. package/src/internal-urls/docs-index.generated.ts +3 -2
  161. package/src/internal-urls/registry-helpers.ts +19 -16
  162. package/src/internal-urls/types.ts +4 -0
  163. package/src/lsp/client.ts +18 -2
  164. package/src/main.ts +21 -5
  165. package/src/modes/bridge/auth.ts +41 -0
  166. package/src/modes/bridge/bridge-client-bridge.ts +47 -0
  167. package/src/modes/bridge/bridge-mode.ts +520 -0
  168. package/src/modes/bridge/bridge-ui-context.ts +200 -0
  169. package/src/modes/bridge/event-stream.ts +70 -0
  170. package/src/modes/components/assistant-message.ts +5 -1
  171. package/src/modes/components/custom-editor.ts +101 -0
  172. package/src/modes/components/hook-selector.ts +133 -20
  173. package/src/modes/components/jobs-overlay-model.ts +109 -0
  174. package/src/modes/components/jobs-overlay.ts +172 -0
  175. package/src/modes/components/status-line/presets.ts +7 -5
  176. package/src/modes/components/status-line/segments.ts +25 -0
  177. package/src/modes/components/status-line/types.ts +2 -0
  178. package/src/modes/components/status-line.ts +9 -1
  179. package/src/modes/controllers/event-controller.ts +71 -6
  180. package/src/modes/controllers/extension-ui-controller.ts +43 -1
  181. package/src/modes/controllers/input-controller.ts +105 -9
  182. package/src/modes/controllers/selector-controller.ts +31 -1
  183. package/src/modes/index.ts +1 -0
  184. package/src/modes/interactive-mode.ts +28 -0
  185. package/src/modes/jobs-observer.ts +204 -0
  186. package/src/modes/rpc/host-tools.ts +1 -186
  187. package/src/modes/rpc/host-uris.ts +1 -235
  188. package/src/modes/rpc/rpc-client.ts +25 -10
  189. package/src/modes/rpc/rpc-mode.ts +12 -381
  190. package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
  191. package/src/modes/shared/agent-wire/command-validation.ts +131 -0
  192. package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
  193. package/src/modes/shared/agent-wire/handshake.ts +117 -0
  194. package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
  195. package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
  196. package/src/modes/shared/agent-wire/protocol.ts +96 -0
  197. package/src/modes/shared/agent-wire/responses.ts +17 -0
  198. package/src/modes/shared/agent-wire/scopes.ts +89 -0
  199. package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
  200. package/src/modes/shared/agent-wire/ui-result.ts +48 -0
  201. package/src/modes/types.ts +2 -0
  202. package/src/prompts/agents/executor.md +13 -0
  203. package/src/prompts/tools/subagent.md +39 -4
  204. package/src/prompts/tools/task-summary.md +3 -9
  205. package/src/prompts/tools/task.md +5 -1
  206. package/src/sdk.ts +8 -0
  207. package/src/session/agent-session.ts +445 -71
  208. package/src/session/session-manager.ts +13 -1
  209. package/src/skill-state/active-state.ts +58 -65
  210. package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
  211. package/src/skill-state/initial-phase.ts +2 -0
  212. package/src/skill-state/workflow-state-contract.ts +33 -4
  213. package/src/skill-state/workflow-state-version.ts +3 -0
  214. package/src/slash-commands/builtin-registry.ts +8 -0
  215. package/src/task/executor.ts +79 -13
  216. package/src/task/id.ts +33 -0
  217. package/src/task/index.ts +376 -74
  218. package/src/task/output-manager.ts +5 -4
  219. package/src/task/receipt.ts +297 -0
  220. package/src/task/render.ts +54 -134
  221. package/src/task/spawn-gate.ts +132 -0
  222. package/src/task/types.ts +104 -10
  223. package/src/tools/ask.ts +88 -27
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash.ts +1 -1
  227. package/src/tools/cron.ts +48 -0
  228. package/src/tools/find.ts +4 -1
  229. package/src/tools/index.ts +2 -0
  230. package/src/tools/path-utils.ts +3 -2
  231. package/src/tools/read.ts +1 -0
  232. package/src/tools/search.ts +1 -0
  233. package/src/tools/skill.ts +6 -1
  234. package/src/tools/subagent.ts +423 -79
@@ -167,10 +167,12 @@ import type { HookCommandContext } from "../extensibility/hooks/types";
167
167
  import type { Skill, SkillWarning } from "../extensibility/skills";
168
168
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
169
169
  import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
170
+ import { writeArtifact } from "../gjc-runtime/state-writer";
170
171
  import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
171
172
  import { GoalRuntime } from "../goals/runtime";
172
173
  import type { Goal, GoalModeState } from "../goals/state";
173
174
  import type { HindsightSessionState } from "../hindsight/state";
175
+ import { ensureWorkflowSkillActivationState } from "../hooks/skill-state";
174
176
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
175
177
  import { resolveMemoryBackend } from "../memory-backend";
176
178
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
@@ -263,7 +265,14 @@ export type AgentSessionEvent =
263
265
  /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
264
266
  skipped?: boolean;
265
267
  }
266
- | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
268
+ | {
269
+ type: "auto_retry_start";
270
+ attempt: number;
271
+ maxAttempts: number;
272
+ delayMs: number;
273
+ errorMessage: string;
274
+ unbounded?: boolean;
275
+ }
267
276
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
268
277
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
269
278
  | { type: "retry_fallback_succeeded"; model: string; role: string }
@@ -282,9 +291,14 @@ export type AgentSessionEvent =
282
291
  */
283
292
  const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
284
293
 
294
+ function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
295
+ const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
296
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
297
+ }
298
+
285
299
  /** Listener function for agent session events */
286
300
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
287
- export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
301
+ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime" | "metadata">;
288
302
 
289
303
  export interface AsyncJobSnapshot {
290
304
  running: AsyncJobSnapshotItem[];
@@ -852,12 +866,14 @@ export class AgentSession {
852
866
 
853
867
  // Retry state
854
868
  #retryAbortController: AbortController | undefined = undefined;
869
+ #retryNowRequested = false;
855
870
  #retryAttempt = 0;
856
871
  #retryPromise: Promise<void> | undefined = undefined;
857
872
  #retryResolve: (() => void) | undefined = undefined;
858
873
  #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
859
874
  // Todo completion reminder state
860
875
  #todoReminderCount = 0;
876
+ #lastGoalReminderAssistantTimestamp: number | undefined = undefined;
861
877
  #todoPhases: TodoPhase[] = [];
862
878
  #toolChoiceQueue = new ToolChoiceQueue();
863
879
 
@@ -958,6 +974,12 @@ export class AgentSession {
958
974
  * without producing an aborted message_end). */
959
975
  #planCompactAbortPending = false;
960
976
 
977
+ /** One-shot flag armed by `abort({ silent: true })` (e.g. Esc consuming a
978
+ * queued steer). Consumed in #handleAgentEvent to stamp `SILENT_ABORT_MARKER`
979
+ * on the resulting aborted assistant `message_end` so the interrupt does not
980
+ * surface a red "Operation aborted" line; cleared by a later non-silent abort
981
+ * or by `abort`'s safety net when no aborted message_end is produced. */
982
+ #silentAbortPending = false;
961
983
  /** Monotonic counter for `enqueueCustomMessageDisplay` tag generation;
962
984
  * combined with `Date.now()` so tags stay unique even across rapid
963
985
  * same-tick enqueues. */
@@ -1036,6 +1058,7 @@ export class AgentSession {
1036
1058
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
1037
1059
  if (this.#promptInFlightCount === 0) {
1038
1060
  this.#releasePowerAssertion();
1061
+ this.#flushPendingBackgroundExchanges();
1039
1062
  this.#flushPendingAgentEnd();
1040
1063
  }
1041
1064
  }
@@ -1043,6 +1066,7 @@ export class AgentSession {
1043
1066
  #resetInFlight(): void {
1044
1067
  this.#promptInFlightCount = 0;
1045
1068
  this.#releasePowerAssertion();
1069
+ this.#flushPendingBackgroundExchanges();
1046
1070
  this.#flushPendingAgentEnd();
1047
1071
  }
1048
1072
 
@@ -1471,6 +1495,10 @@ export class AgentSession {
1471
1495
  return tag;
1472
1496
  }
1473
1497
 
1498
+ getAgentId(): string | undefined {
1499
+ return this.#agentId;
1500
+ }
1501
+
1474
1502
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1475
1503
  const manager = AsyncJobManager.instance();
1476
1504
  if (!manager) return null;
@@ -1481,6 +1509,7 @@ export class AgentSession {
1481
1509
  status: job.status,
1482
1510
  label: job.label,
1483
1511
  startTime: job.startTime,
1512
+ metadata: job.metadata,
1484
1513
  }));
1485
1514
  const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
1486
1515
  id: job.id,
@@ -1488,6 +1517,7 @@ export class AgentSession {
1488
1517
  status: job.status,
1489
1518
  label: job.label,
1490
1519
  startTime: job.startTime,
1520
+ metadata: job.metadata,
1491
1521
  }));
1492
1522
  const delivery = manager.getDeliveryState(ownerFilter);
1493
1523
  return { running, recent, delivery };
@@ -1630,10 +1660,11 @@ export class AgentSession {
1630
1660
  event.type === "message_end" &&
1631
1661
  event.message.role === "assistant" &&
1632
1662
  event.message.stopReason === "aborted" &&
1633
- this.#planCompactAbortPending
1663
+ (this.#planCompactAbortPending || this.#silentAbortPending)
1634
1664
  ) {
1635
1665
  (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
1636
1666
  this.#planCompactAbortPending = false;
1667
+ this.#silentAbortPending = false;
1637
1668
  }
1638
1669
 
1639
1670
  // Deobfuscate assistant message content for display emission — the LLM echoes back
@@ -1887,6 +1918,15 @@ export class AgentSession {
1887
1918
  attempt: this.#retryAttempt,
1888
1919
  });
1889
1920
  this.#retryAttempt = 0;
1921
+ // Settle the retry gate here, colocated with the success event, rather
1922
+ // than relying on the generic #resolveRetry() at the end of the
1923
+ // agent_end branch. That tail resolver is bypassed by every early
1924
+ // return in agent_end (successful `yield`, handoff-abort skip-maintenance,
1925
+ // missing assistant message), so a retry that recovers on a yield turn
1926
+ // would otherwise leave #retryPromise unresolved — wedging
1927
+ // #waitForPostPromptRecovery and the session as permanently busy.
1928
+ // #resolveRetry() is idempotent, so the later tail call is a no-op.
1929
+ this.#resolveRetry();
1890
1930
  }
1891
1931
  }
1892
1932
 
@@ -1992,6 +2032,9 @@ export class AgentSession {
1992
2032
 
1993
2033
  if (this.#assistantEndedWithSuccessfulYield(msg)) {
1994
2034
  this.#lastSuccessfulYieldToolCallId = undefined;
2035
+ if (msg.stopReason !== "error" && msg.stopReason !== "aborted" && (await this.#checkGoalCompletion(msg))) {
2036
+ return;
2037
+ }
1995
2038
  return;
1996
2039
  }
1997
2040
  this.#lastSuccessfulYieldToolCallId = undefined;
@@ -2001,6 +2044,18 @@ export class AgentSession {
2001
2044
  const didRetry = await this.#handleRetryableError(msg);
2002
2045
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
2003
2046
  }
2047
+ if (this.#retryAttempt > 0) {
2048
+ // A prior retry ended on a non-retryable (terminal) message: emit
2049
+ // the terminal retry-end and reset so observers clear retry state.
2050
+ const attempt = this.#retryAttempt;
2051
+ this.#retryAttempt = 0;
2052
+ await this.#emitSessionEvent({
2053
+ type: "auto_retry_end",
2054
+ success: false,
2055
+ attempt,
2056
+ finalError: msg.errorMessage,
2057
+ });
2058
+ }
2004
2059
  this.#resolveRetry();
2005
2060
 
2006
2061
  const compactionTask = this.#checkCompaction(msg);
@@ -2015,6 +2070,9 @@ export class AgentSession {
2015
2070
  if (this.#enforceRewindBeforeYield()) {
2016
2071
  return;
2017
2072
  }
2073
+ if (await this.#checkGoalCompletion(msg)) {
2074
+ return;
2075
+ }
2018
2076
  await this.#checkTodoCompletion();
2019
2077
  }
2020
2078
  }
@@ -2103,13 +2161,23 @@ export class AgentSession {
2103
2161
  delayMs?: number;
2104
2162
  generation?: number;
2105
2163
  shouldContinue?: () => boolean;
2106
- onSkip?: () => void;
2107
- onError?: () => void;
2164
+ onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
2165
+ onError?: (error: unknown) => void;
2108
2166
  }): void {
2167
+ const scheduledGeneration = options?.generation;
2168
+ const signal = this.#postPromptTasksAbortController.signal;
2109
2169
  this.#schedulePostPromptTask(
2110
2170
  async () => {
2171
+ if (signal.aborted) {
2172
+ options?.onSkip?.("aborted_signal");
2173
+ return;
2174
+ }
2175
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
2176
+ options?.onSkip?.("generation_changed");
2177
+ return;
2178
+ }
2111
2179
  if (options?.shouldContinue && !options.shouldContinue()) {
2112
- options.onSkip?.();
2180
+ options.onSkip?.("queue_drained");
2113
2181
  return;
2114
2182
  }
2115
2183
  try {
@@ -2119,17 +2187,45 @@ export class AgentSession {
2119
2187
  logger.warn("agent.continue failed after scheduling", {
2120
2188
  error: error instanceof Error ? error.message : String(error),
2121
2189
  });
2122
- options?.onError?.();
2190
+ options?.onError?.(error);
2123
2191
  }
2124
2192
  },
2125
- {
2126
- delayMs: options?.delayMs,
2127
- generation: options?.generation,
2128
- onSkip: options?.onSkip,
2129
- },
2193
+ { delayMs: options?.delayMs },
2130
2194
  );
2131
2195
  }
2132
2196
 
2197
+ #logCompactionContinuationSkipped(
2198
+ source: "auto_continue_prompt" | "queued_continue" | "overflow_retry",
2199
+ reason: string,
2200
+ ): void {
2201
+ logger.warn("Auto-compaction continuation skipped", { source, reason });
2202
+ }
2203
+
2204
+ #logCompactionContinuationError(
2205
+ source: "auto_continue_prompt" | "queued_continue" | "overflow_retry",
2206
+ error: unknown,
2207
+ ): void {
2208
+ logger.warn("Auto-compaction continuation failed", {
2209
+ source,
2210
+ reason: error instanceof Error && error.name === "AgentBusyError" ? "queue_drained" : "not_resumable_tail",
2211
+ error: error instanceof Error ? error.message : String(error),
2212
+ });
2213
+ }
2214
+
2215
+ #isResumableAgentTail(): boolean {
2216
+ const lastMsg = this.agent.state.messages.at(-1);
2217
+ return lastMsg !== undefined && lastMsg.role !== "assistant";
2218
+ }
2219
+
2220
+ #stripOverflowFailedTurnForRetry(): void {
2221
+ const messages = this.agent.state.messages;
2222
+ const lastMsg = messages.at(-1);
2223
+ const contextWindow = this.model?.contextWindow ?? 0;
2224
+ if (lastMsg?.role === "assistant" && isContextOverflow(lastMsg as AssistantMessage, contextWindow)) {
2225
+ this.agent.replaceMessages(messages.slice(0, -1));
2226
+ }
2227
+ }
2228
+
2133
2229
  #scheduleAutoContinuePrompt(generation: number): void {
2134
2230
  const continuePrompt = async () => {
2135
2231
  await this.#promptWithMessage(
@@ -2140,16 +2236,28 @@ export class AgentSession {
2140
2236
  timestamp: Date.now(),
2141
2237
  },
2142
2238
  autoContinuePrompt,
2143
- { skipPostPromptRecoveryWait: true },
2239
+ { skipPostPromptRecoveryWait: true, skipCompactionCheck: true },
2144
2240
  );
2145
2241
  };
2146
- this.#schedulePostPromptTask(
2147
- async signal => {
2242
+ const scheduledGeneration = generation;
2243
+ const signal = this.#postPromptTasksAbortController.signal;
2244
+ this.#trackPostPromptTask(
2245
+ (async () => {
2148
2246
  await Promise.resolve();
2149
- if (signal.aborted) return;
2150
- await continuePrompt();
2151
- },
2152
- { generation },
2247
+ if (signal.aborted) {
2248
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
2249
+ return;
2250
+ }
2251
+ if (this.#promptGeneration !== scheduledGeneration) {
2252
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
2253
+ return;
2254
+ }
2255
+ try {
2256
+ await continuePrompt();
2257
+ } catch (error) {
2258
+ this.#logCompactionContinuationError("auto_continue_prompt", error);
2259
+ }
2260
+ })(),
2153
2261
  );
2154
2262
  }
2155
2263
 
@@ -2871,6 +2979,7 @@ export class AgentSession {
2871
2979
  maxAttempts: event.maxAttempts,
2872
2980
  delayMs: event.delayMs,
2873
2981
  errorMessage: event.errorMessage,
2982
+ unbounded: event.unbounded,
2874
2983
  });
2875
2984
  } else if (event.type === "auto_retry_end") {
2876
2985
  await this.#extensionRunner.emit({
@@ -3485,7 +3594,7 @@ export class AgentSession {
3485
3594
  * prompts or tool execution can run.
3486
3595
  */
3487
3596
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3488
- if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
3597
+ if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3489
3598
  return new Proxy(tool, {
3490
3599
  get: (target, prop) => {
3491
3600
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -4291,12 +4400,17 @@ export class AgentSession {
4291
4400
  // Canonical GJC workflow skills (deep-interview, ralplan, ultragoal, team)
4292
4401
  // own their `.gjc/state/skill-active-state.json` row through the
4293
4402
  // `gjc state handoff` and `gjc state clear` runtime verbs. The prompt
4294
- // observer here used to overwrite the row with `phase: running` and
4295
- // later remove it with `active:false`, which clobbered handoff lineage
4296
- // (`handoff_from`/`handoff_at`) and made the HUD inconsistent with
4297
- // mode-state. The observational filesystem write is now skipped for
4298
- // canonical skills; the in-memory `#activeSkillState` tracking below
4299
- // keeps `getActiveSkillState` accurate for the chain guard.
4403
+ // observer must not overwrite an existing row (that clobbered handoff
4404
+ // lineage `handoff_from`/`handoff_at` and desynced the HUD). But a fresh
4405
+ // `/skill:<name>` invocation has no row yet, so seed `.gjc/state`
4406
+ // idempotently here: `ensureWorkflowSkillActivationState` writes the
4407
+ // initial mode-state + active row only when the skill is not already
4408
+ // active, so the mutation guard and Stop hook engage immediately instead
4409
+ // of relying on the skill prompt to run its own state-init steps.
4410
+ if (active) {
4411
+ await ensureWorkflowSkillActivationState({ cwd: this.sessionManager.getCwd(), skill, sessionId });
4412
+ }
4413
+ // In-memory tracking keeps `getActiveSkillState` accurate for the chain guard.
4300
4414
  this.#activeSkillState = active ? { skill, sessionId } : undefined;
4301
4415
  }
4302
4416
 
@@ -4942,6 +5056,13 @@ export class AgentSession {
4942
5056
  return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
4943
5057
  }
4944
5058
 
5059
+ /** Whether the agent has queued steering messages that a `user_interrupt`
5060
+ * abort would resume into (steer-on-interrupt). Drives the Esc-on-steer UX:
5061
+ * the first Esc consumes the steer and auto-continues, a second Esc aborts. */
5062
+ get hasQueuedSteering(): boolean {
5063
+ return this.agent.hasQueuedSteering();
5064
+ }
5065
+
4945
5066
  /** Get pending messages (read-only). Returns the public text-only view;
4946
5067
  * internal `{text, tag?}` records are mapped to `.text` so callers
4947
5068
  * (`updatePendingMessagesDisplay`, `restoreQueuedMessagesToEditor`) see
@@ -5027,7 +5148,28 @@ export class AgentSession {
5027
5148
  /**
5028
5149
  * Abort current operation and wait for agent to become idle.
5029
5150
  */
5030
- async abort(options?: { goalReason?: "interrupted" | "internal"; timeoutMs?: number }): Promise<void> {
5151
+ async abort(options?: {
5152
+ goalReason?: "interrupted" | "internal";
5153
+ timeoutMs?: number;
5154
+ cause?:
5155
+ | "user_interrupt"
5156
+ | "new_session"
5157
+ | "session_switch"
5158
+ | "compaction"
5159
+ | "handoff"
5160
+ | "tool_abort"
5161
+ | "internal";
5162
+ /** Suppress the "Operation aborted" line on the resulting aborted message
5163
+ * by stamping `SILENT_ABORT_MARKER`. Used when Esc consumes a queued steer
5164
+ * and resumes via steer-on-interrupt, so the interrupt reads as a quiet
5165
+ * hand-off rather than a failure. */
5166
+ silent?: boolean;
5167
+ }): Promise<void> {
5168
+ if (options?.silent) {
5169
+ this.#silentAbortPending = true;
5170
+ } else {
5171
+ this.#silentAbortPending = false;
5172
+ }
5031
5173
  this.abortRetry();
5032
5174
  this.#promptGeneration++;
5033
5175
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -5067,6 +5209,10 @@ export class AgentSession {
5067
5209
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
5068
5210
  // a subsequent prompt() can incorrectly observe the session as busy after an abort.
5069
5211
  this.#resetInFlight();
5212
+ // Safety net: clear the silent-abort flag if it was never consumed (the
5213
+ // abort produced no aborted assistant message_end to stamp). Prevents the
5214
+ // marker from leaking onto a later, unrelated abort.
5215
+ this.#silentAbortPending = false;
5070
5216
  // Safety net: if the agent loop aborted without producing an assistant
5071
5217
  // message (e.g. failed before the first stream), the in-flight yield was
5072
5218
  // never resolved or rejected by the normal message_end path. Reject it now
@@ -5074,6 +5220,18 @@ export class AgentSession {
5074
5220
  if (this.#toolChoiceQueue.hasInFlight) {
5075
5221
  this.#toolChoiceQueue.reject("aborted");
5076
5222
  }
5223
+
5224
+ // Steer-on-interrupt: after a genuine user interrupt, resume with any
5225
+ // queued steering instead of going idle. Lifecycle/teardown causes
5226
+ // (default "internal") suppress this; new-session/handoff additionally
5227
+ // clear the steering queue, and compaction resumes via its own path.
5228
+ if ((options?.cause ?? "internal") === "user_interrupt" && this.agent.hasQueuedSteering()) {
5229
+ this.#scheduleAgentContinue({
5230
+ delayMs: 1,
5231
+ generation: this.#promptGeneration,
5232
+ shouldContinue: () => this.agent.hasQueuedSteering(),
5233
+ });
5234
+ }
5077
5235
  }
5078
5236
 
5079
5237
  /**
@@ -5129,6 +5287,9 @@ export class AgentSession {
5129
5287
  this.#scheduledHiddenNextTurnGeneration = undefined;
5130
5288
 
5131
5289
  this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
5290
+ if (this.model) {
5291
+ this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
5292
+ }
5132
5293
  this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
5133
5294
  if (nextDiscoverySessionToolNames) {
5134
5295
  await this.#applyActiveToolsByName(nextDiscoverySessionToolNames, { persistMCPSelection: false });
@@ -5920,6 +6081,11 @@ export class AgentSession {
5920
6081
  this.#pendingNextTurnMessages = [];
5921
6082
  this.#scheduledHiddenNextTurnGeneration = undefined;
5922
6083
  this.#todoReminderCount = 0;
6084
+ if (model) {
6085
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`);
6086
+ }
6087
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
6088
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
5923
6089
 
5924
6090
  // Inject the handoff document as a custom message
5925
6091
  const handoffContent = createHandoffContext(handoffText);
@@ -5931,7 +6097,14 @@ export class AgentSession {
5931
6097
  if (artifactsDir) {
5932
6098
  const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
5933
6099
  try {
5934
- await Bun.write(handoffFilePath, `${handoffText}\n`);
6100
+ if (isUnderProjectGjc(this.sessionManager.getCwd(), handoffFilePath)) {
6101
+ await writeArtifact(handoffFilePath, `${handoffText}\n`, {
6102
+ cwd: this.sessionManager.getCwd(),
6103
+ audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
6104
+ });
6105
+ } else {
6106
+ await Bun.write(handoffFilePath, `${handoffText}\n`);
6107
+ }
5935
6108
  savedPath = handoffFilePath;
5936
6109
  } catch (error) {
5937
6110
  logger.warn("Failed to save handoff document to disk", {
@@ -6200,6 +6373,39 @@ export class AgentSession {
6200
6373
  toolChoice: todoWriteToolChoice,
6201
6374
  };
6202
6375
  }
6376
+
6377
+ async #checkGoalCompletion(assistantMessage: AssistantMessage): Promise<boolean> {
6378
+ const state = this.getGoalModeState();
6379
+ if (!state?.enabled || state.goal.status !== "active") {
6380
+ this.#lastGoalReminderAssistantTimestamp = undefined;
6381
+ return false;
6382
+ }
6383
+ if (this.#lastGoalReminderAssistantTimestamp === assistantMessage.timestamp) {
6384
+ return false;
6385
+ }
6386
+ this.#lastGoalReminderAssistantTimestamp = assistantMessage.timestamp;
6387
+
6388
+ const continuationPrompt = this.#goalRuntime.buildContinuationPrompt();
6389
+ if (!continuationPrompt) return false;
6390
+ const reminder = [
6391
+ "<system-reminder>",
6392
+ "You stopped while a goal is still active and uncleared.",
6393
+ "Continue working on the active goal until it is verified complete, paused, or dropped.",
6394
+ "",
6395
+ continuationPrompt,
6396
+ "</system-reminder>",
6397
+ ].join("\n");
6398
+
6399
+ logger.debug("Goal completion: sending active-goal reminder", { goalId: state.goal.id });
6400
+ this.agent.appendMessage({
6401
+ role: "developer",
6402
+ content: [{ type: "text", text: reminder }],
6403
+ attribution: "agent",
6404
+ timestamp: Date.now(),
6405
+ });
6406
+ this.#scheduleAgentContinue({ generation: this.#promptGeneration });
6407
+ return true;
6408
+ }
6203
6409
  /**
6204
6410
  * Check if agent stopped with incomplete todos and prompt to continue.
6205
6411
  */
@@ -6843,12 +7049,34 @@ export class AgentSession {
6843
7049
  willRetry: false,
6844
7050
  skipped: true,
6845
7051
  });
6846
- if (!willRetry && this.agent.hasQueuedMessages()) {
7052
+ if (willRetry) {
7053
+ this.#stripOverflowFailedTurnForRetry();
7054
+ if (this.#isResumableAgentTail()) {
7055
+ this.#scheduleAgentContinue({
7056
+ delayMs: 100,
7057
+ generation,
7058
+ onSkip: skipReason => this.#logCompactionContinuationSkipped("overflow_retry", skipReason),
7059
+ onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7060
+ });
7061
+ } else {
7062
+ const tail = this.agent.state.messages.at(-1) as AssistantMessage | undefined;
7063
+ logger.warn("Auto-compaction continuation skipped", {
7064
+ source: "overflow_retry",
7065
+ reason: "not_resumable_tail",
7066
+ role: tail?.role,
7067
+ stopReason: tail?.stopReason,
7068
+ });
7069
+ }
7070
+ } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
6847
7071
  this.#scheduleAgentContinue({
6848
7072
  delayMs: 100,
6849
7073
  generation,
6850
7074
  shouldContinue: () => this.agent.hasQueuedMessages(),
7075
+ onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
7076
+ onError: error => this.#logCompactionContinuationError("queued_continue", error),
6851
7077
  });
7078
+ } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7079
+ this.#scheduleAutoContinuePrompt(generation);
6852
7080
  }
6853
7081
  return;
6854
7082
  }
@@ -7050,26 +7278,36 @@ export class AgentSession {
7050
7278
  };
7051
7279
  await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
7052
7280
 
7053
- if (!willRetry && reason !== "idle" && compactionSettings.autoContinue !== false) {
7054
- this.#scheduleAutoContinuePrompt(generation);
7055
- }
7056
-
7057
7281
  if (willRetry) {
7058
- const messages = this.agent.state.messages;
7059
- const lastMsg = messages[messages.length - 1];
7060
- if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
7061
- this.agent.replaceMessages(messages.slice(0, -1));
7282
+ this.#stripOverflowFailedTurnForRetry();
7283
+ if (!this.#isResumableAgentTail()) {
7284
+ const tail = this.agent.state.messages.at(-1) as AssistantMessage | undefined;
7285
+ logger.warn("Auto-compaction continuation skipped", {
7286
+ source: "overflow_retry",
7287
+ reason: "not_resumable_tail",
7288
+ role: tail?.role,
7289
+ stopReason: tail?.stopReason,
7290
+ });
7291
+ } else {
7292
+ this.#scheduleAgentContinue({
7293
+ delayMs: 100,
7294
+ generation,
7295
+ onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
7296
+ onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7297
+ });
7062
7298
  }
7063
-
7064
- this.#scheduleAgentContinue({ delayMs: 100, generation });
7065
- } else if (this.agent.hasQueuedMessages()) {
7299
+ } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
7066
7300
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
7067
7301
  // Kick the loop so queued messages are actually delivered.
7068
7302
  this.#scheduleAgentContinue({
7069
7303
  delayMs: 100,
7070
7304
  generation,
7071
7305
  shouldContinue: () => this.agent.hasQueuedMessages(),
7306
+ onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
7307
+ onError: error => this.#logCompactionContinuationError("queued_continue", error),
7072
7308
  });
7309
+ } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7310
+ this.#scheduleAutoContinuePrompt(generation);
7073
7311
  }
7074
7312
  } catch (error) {
7075
7313
  if (autoCompactionSignal.aborted) {
@@ -7121,19 +7359,14 @@ export class AgentSession {
7121
7359
  // =========================================================================
7122
7360
 
7123
7361
  /**
7124
- * Check if an error is retryable (transient errors or usage limits).
7125
- * Context overflow errors are NOT retryable (handled by compaction instead).
7126
- * Usage-limit errors are retryable because the retry handler performs credential switching.
7362
+ * Whether an error should be retried. Uses the ordered classifier:
7363
+ * context-overflow routes to compaction; clearly-terminal coded errors
7364
+ * (auth/400/not-found) surface immediately; usage-limit, transient, and
7365
+ * unknown/no-code errors are retryable.
7127
7366
  */
7128
7367
  #isRetryableError(message: AssistantMessage): boolean {
7129
- if (message.stopReason !== "error" || !message.errorMessage) return false;
7130
-
7131
- // Context overflow is handled by compaction, not retry
7132
- const contextWindow = this.model?.contextWindow ?? 0;
7133
- if (isContextOverflow(message, contextWindow)) return false;
7134
-
7135
- const err = message.errorMessage;
7136
- return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
7368
+ const classification = this.#classifyErrorForRetry(message);
7369
+ return classification === "usage_limit" || classification === "transient" || classification === "unknown";
7137
7370
  }
7138
7371
 
7139
7372
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -7159,6 +7392,63 @@ export class AgentSession {
7159
7392
  );
7160
7393
  }
7161
7394
 
7395
+ #isTerminalErrorMessage(errorMessage: string): boolean {
7396
+ // Errors that will never succeed on retry (auth/permission, malformed
7397
+ // request, unknown/unsupported model). These surface immediately rather
7398
+ // than retry forever.
7399
+ return /unauthorized|forbidden|authentication_error|permission_error|permission denied|invalid api key|invalid_request_error|invalid request|bad request|bad_request|validation_error|unprocessable|payload too large|payment required|insufficient_quota|insufficient credits|missing required (parameter|field)|invalid schema|invalid tool_choice|unsupported (parameter|value|model)|model_not_found|no such model|unknown model|does not (exist|support)|request was aborted|request aborted|the user aborted/i.test(
7400
+ errorMessage,
7401
+ );
7402
+ }
7403
+
7404
+ #extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
7405
+ // Parse only explicit HTTP/status wording. Do not treat generic
7406
+ // `error: 400` as an HTTP status because rate-limit copy can say
7407
+ // "rate limit error: 400 requests per minute".
7408
+ const match = /\b(?:http(?:\s+status)?|status(?:[\s_-]+code)?)(?:\s+|[:=]\s*)(\d{3})\b/i.exec(errorMessage);
7409
+ if (!match) return undefined;
7410
+ const status = Number(match[1]);
7411
+ return Number.isFinite(status) && status >= 100 && status <= 599 ? status : undefined;
7412
+ }
7413
+
7414
+ /**
7415
+ * Ordered retry classification: overflow (compaction) -> terminal (surface)
7416
+ * -> usage_limit (rotation) -> transient (retry) -> unknown (retry).
7417
+ */
7418
+ #classifyErrorForRetry(
7419
+ message: AssistantMessage,
7420
+ ): "none" | "overflow" | "terminal" | "usage_limit" | "transient" | "unknown" {
7421
+ if (message.stopReason !== "error" || !message.errorMessage) return "none";
7422
+ const contextWindow = this.model?.contextWindow ?? 0;
7423
+ if (isContextOverflow(message, contextWindow)) return "overflow";
7424
+ const err = message.errorMessage;
7425
+ // Stream-envelope errors are only transient in the pre-message_start
7426
+ // variant; any other envelope failure is structural and must surface.
7427
+ if (/anthropic stream envelope error:/i.test(err)) {
7428
+ return this.#isTransientEnvelopeErrorMessage(err) ? "transient" : "terminal";
7429
+ }
7430
+ const explicitStatus = this.#extractExplicitHttpStatusFromErrorMessage(err);
7431
+ const structuredStatus = message.errorStatus;
7432
+ const terminalStatus = explicitStatus ?? structuredStatus;
7433
+ const isTerminalHttp4xx =
7434
+ terminalStatus !== undefined &&
7435
+ terminalStatus >= 400 &&
7436
+ terminalStatus < 500 &&
7437
+ terminalStatus !== 408 &&
7438
+ terminalStatus !== 425 &&
7439
+ terminalStatus !== 429;
7440
+ if (this.#isTerminalErrorMessage(err)) return "terminal";
7441
+ if (isUsageLimitError(err)) return "usage_limit";
7442
+ // Explicit HTTP/status wording is authoritative. Structured provider status
7443
+ // is also authoritative except for rate-limit copy where providers may have
7444
+ // parsed an incidental quota number such as "400 requests per minute".
7445
+ if (isTerminalHttp4xx && (explicitStatus !== undefined || !/rate.?limit|too many requests/i.test(err))) {
7446
+ return "terminal";
7447
+ }
7448
+ if (this.#isTransientErrorMessage(err)) return "transient";
7449
+ return "unknown";
7450
+ }
7451
+
7162
7452
  #getRetryFallbackChains(): RetryFallbackChains {
7163
7453
  const configuredChains = this.settings.get("retry.fallbackChains");
7164
7454
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -7428,6 +7718,8 @@ export class AgentSession {
7428
7718
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
7429
7719
  const retrySettings = this.settings.getGroup("retry");
7430
7720
  if (!retrySettings.enabled) return false;
7721
+ const retryClassification = this.#classifyErrorForRetry(message);
7722
+ const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
7431
7723
 
7432
7724
  const generation = this.#promptGeneration;
7433
7725
  this.#retryAttempt++;
@@ -7440,7 +7732,7 @@ export class AgentSession {
7440
7732
  this.#retryResolve = resolve;
7441
7733
  }
7442
7734
 
7443
- if (this.#retryAttempt > retrySettings.maxRetries) {
7735
+ if (!unboundedClass && this.#retryAttempt > retrySettings.maxRetries) {
7444
7736
  // Max retries exceeded, emit final failure and reset
7445
7737
  await this.#emitSessionEvent({
7446
7738
  type: "auto_retry_end",
@@ -7497,7 +7789,16 @@ export class AgentSession {
7497
7789
  // assistant error message is preserved in agent state so the caller
7498
7790
  // can act on it.
7499
7791
  const maxDelayMs = retrySettings.maxDelayMs;
7500
- if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7792
+ if (unboundedClass && !switchedCredential && !switchedModel) {
7793
+ // Retry forever: honor a provider-supplied wait, otherwise cap the
7794
+ // exponential backoff at the ceiling instead of giving up.
7795
+ if (parsedRetryAfterMs !== undefined) {
7796
+ delayMs = Math.max(delayMs, parsedRetryAfterMs);
7797
+ } else if (maxDelayMs > 0) {
7798
+ delayMs = Math.min(delayMs, maxDelayMs);
7799
+ }
7800
+ }
7801
+ if (!unboundedClass && maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7501
7802
  const attempt = this.#retryAttempt;
7502
7803
  this.#retryAttempt = 0;
7503
7804
  await this.#emitSessionEvent({
@@ -7510,12 +7811,22 @@ export class AgentSession {
7510
7811
  return false;
7511
7812
  }
7512
7813
 
7814
+ // Create and install the backoff abort controller BEFORE emitting
7815
+ // auto_retry_start, so a synchronous retryNow()/abortRetry() invoked from
7816
+ // an event subscriber (e.g. the TUI Esc handler) is not lost in the gap
7817
+ // between the event and the controller assignment.
7818
+ const retryAbortController = new AbortController();
7819
+ this.#retryAbortController?.abort();
7820
+ this.#retryAbortController = retryAbortController;
7821
+ this.#retryNowRequested = false;
7822
+
7513
7823
  await this.#emitSessionEvent({
7514
7824
  type: "auto_retry_start",
7515
7825
  attempt: this.#retryAttempt,
7516
7826
  maxAttempts: retrySettings.maxRetries,
7517
7827
  delayMs,
7518
7828
  errorMessage,
7829
+ unbounded: unboundedClass,
7519
7830
  });
7520
7831
 
7521
7832
  // Remove error message from agent state (keep in session for history)
@@ -7525,34 +7836,49 @@ export class AgentSession {
7525
7836
  }
7526
7837
 
7527
7838
  // Wait with exponential backoff (abortable).
7528
- const retryAbortController = new AbortController();
7529
- this.#retryAbortController?.abort();
7530
- this.#retryAbortController = retryAbortController;
7531
7839
  try {
7532
7840
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
7533
7841
  } catch {
7534
7842
  if (this.#retryAbortController !== retryAbortController) {
7535
7843
  return false;
7536
7844
  }
7537
- // Aborted during sleep - emit end event so UI can clean up
7538
- const attempt = this.#retryAttempt;
7539
- this.#retryAttempt = 0;
7540
7845
  this.#retryAbortController = undefined;
7541
- await this.#emitSessionEvent({
7542
- type: "auto_retry_end",
7543
- success: false,
7544
- attempt,
7545
- finalError: "Retry cancelled",
7546
- });
7547
- this.#resolveRetry();
7548
- return false;
7846
+ if (this.#retryNowRequested) {
7847
+ // Retry-now: skip the remaining backoff and fall through to
7848
+ // re-attempt immediately (keeps the retry session alive).
7849
+ this.#retryNowRequested = false;
7850
+ } else {
7851
+ // Aborted during sleep (cancel) - emit end event so UI can clean up
7852
+ const attempt = this.#retryAttempt;
7853
+ this.#retryAttempt = 0;
7854
+ await this.#emitSessionEvent({
7855
+ type: "auto_retry_end",
7856
+ success: false,
7857
+ attempt,
7858
+ finalError: "Retry cancelled",
7859
+ });
7860
+ this.#resolveRetry();
7861
+ return false;
7862
+ }
7549
7863
  }
7550
7864
  if (this.#retryAbortController === retryAbortController) {
7551
7865
  this.#retryAbortController = undefined;
7552
7866
  }
7553
7867
 
7554
7868
  // Retry via continue() outside the agent_end event callback chain.
7555
- this.#scheduleAgentContinue({ delayMs: 1, generation });
7869
+ // If the scheduled continue cannot run — it throws (e.g. AgentBusyError from a
7870
+ // concurrent turn, or "Cannot continue ...") or is skipped because a newer
7871
+ // generation took over — the agent_end that normally resolves #retryPromise
7872
+ // never arrives. Finalize the retry in that case so #waitForPostPromptRecovery
7873
+ // (and the in-flight prompt holding it open) cannot wedge the session as
7874
+ // permanently busy, which would turn every later prompt() into a
7875
+ // non-recoverable AgentBusyError loop.
7876
+ this.#scheduleAgentContinue({
7877
+ delayMs: 1,
7878
+ generation,
7879
+ onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
7880
+ onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
7881
+ });
7556
7882
 
7557
7883
  return true;
7558
7884
  }
@@ -7561,8 +7887,41 @@ export class AgentSession {
7561
7887
  * Cancel in-progress retry.
7562
7888
  */
7563
7889
  abortRetry(): void {
7890
+ this.#retryNowRequested = false;
7564
7891
  this.#retryAbortController?.abort();
7565
- // Note: _retryAttempt is reset in the catch block of _autoRetry
7892
+ // Note: #retryAttempt is reset in the catch block of #handleRetryableError
7893
+ this.#resolveRetry();
7894
+ }
7895
+
7896
+ /**
7897
+ * Skip the current retry backoff and re-attempt immediately. Distinct from
7898
+ * abortRetry(), which cancels the retry and returns to idle. No-op when no
7899
+ * retry backoff is active.
7900
+ */
7901
+ retryNow(): void {
7902
+ if (!this.#retryAbortController) return;
7903
+ this.#retryNowRequested = true;
7904
+ this.#retryAbortController.abort();
7905
+ }
7906
+
7907
+ /**
7908
+ * Finalize a pending auto-retry that can no longer reach a resolving agent_end
7909
+ * (the scheduled continue threw or was superseded). Without this, #retryPromise
7910
+ * stays unresolved, #waitForPostPromptRecovery never returns, the owning
7911
+ * prompt's in-flight count is never released, and the session reports
7912
+ * `isStreaming === true` forever — turning every later prompt() into a
7913
+ * non-recoverable AgentBusyError. No-op once the retry has already settled.
7914
+ */
7915
+ #failRetryRecovery(reason: string): void {
7916
+ if (!this.#retryPromise) return;
7917
+ const attempt = this.#retryAttempt;
7918
+ this.#retryAttempt = 0;
7919
+ void this.#emitSessionEvent({
7920
+ type: "auto_retry_end",
7921
+ success: false,
7922
+ attempt,
7923
+ finalError: reason,
7924
+ });
7566
7925
  this.#resolveRetry();
7567
7926
  }
7568
7927
 
@@ -8190,13 +8549,17 @@ export class AgentSession {
8190
8549
  return;
8191
8550
  }
8192
8551
  if (this.isStreaming) {
8193
- setTimeout(attempt, 50);
8552
+ // Re-poll while streaming, but do not let this housekeeping timer
8553
+ // keep the event loop alive on its own (CPU-7).
8554
+ const pollTimer = setTimeout(attempt, 50);
8555
+ pollTimer.unref?.();
8194
8556
  return;
8195
8557
  }
8196
8558
  this.#scheduledBackgroundExchangeFlush = false;
8197
8559
  this.#flushPendingBackgroundExchanges();
8198
8560
  };
8199
- setTimeout(attempt, 0);
8561
+ const kickoff = setTimeout(attempt, 0);
8562
+ kickoff.unref?.();
8200
8563
  }
8201
8564
 
8202
8565
  #flushPendingBackgroundExchanges(): void {
@@ -8279,6 +8642,8 @@ export class AgentSession {
8279
8642
  const previousFallbackSelectedMCPToolNames = previousSessionFile
8280
8643
  ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
8281
8644
  : undefined;
8645
+ const previousAgentSteeringQueue = this.agent.snapshotSteering();
8646
+ const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
8282
8647
 
8283
8648
  this.#steeringMessages = [];
8284
8649
  this.#followUpMessages = [];
@@ -8297,6 +8662,12 @@ export class AgentSession {
8297
8662
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
8298
8663
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
8299
8664
 
8665
+ // The target session is loaded and MCP selections are restored: the
8666
+ // switch is committed far enough to discard pre-switch delivery queues.
8667
+ // Clear before session_switch hooks, so messages enqueued by hooks belong
8668
+ // to the new session and remain deliverable.
8669
+ this.agent.clearAllQueues();
8670
+
8300
8671
  // Emit session_switch event to hooks
8301
8672
  if (this.#extensionRunner) {
8302
8673
  await this.#extensionRunner.emit({
@@ -8391,6 +8762,9 @@ export class AgentSession {
8391
8762
  this.#followUpMessages = previousFollowUpMessages;
8392
8763
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
8393
8764
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
8765
+ this.agent.clearAllQueues();
8766
+ this.agent.restoreSteering(previousAgentSteeringQueue);
8767
+ this.agent.restoreFollowUp(previousAgentFollowUpQueue);
8394
8768
  if (previousModel) {
8395
8769
  this.agent.setModel(previousModel);
8396
8770
  }