@gajae-code/coding-agent 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/types/async/job-manager.d.ts +84 -2
  3. package/dist/types/commands/harness.d.ts +37 -0
  4. package/dist/types/config/settings-schema.d.ts +6 -0
  5. package/dist/types/config/settings.d.ts +2 -0
  6. package/dist/types/deep-interview/render-middleware.d.ts +5 -0
  7. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -0
  10. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  11. package/dist/types/gjc-runtime/state-migrations.d.ts +24 -0
  12. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  13. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  14. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  15. package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
  16. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  17. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  18. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  19. package/dist/types/harness-control-plane/control-endpoint.d.ts +30 -0
  20. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  21. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  22. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  23. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  24. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  25. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  26. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  27. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  28. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  29. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  30. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  31. package/dist/types/harness-control-plane/types.d.ts +162 -0
  32. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  33. package/dist/types/hooks/skill-state.d.ts +2 -29
  34. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  35. package/dist/types/modes/interactive-mode.d.ts +1 -0
  36. package/dist/types/modes/types.d.ts +1 -0
  37. package/dist/types/sdk.d.ts +2 -0
  38. package/dist/types/session/agent-session.d.ts +8 -0
  39. package/dist/types/skill-state/active-state.d.ts +2 -0
  40. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  41. package/dist/types/skill-state/workflow-state-contract.d.ts +24 -0
  42. package/dist/types/task/executor.d.ts +3 -0
  43. package/dist/types/task/types.d.ts +55 -3
  44. package/dist/types/tools/subagent.d.ts +11 -1
  45. package/package.json +7 -7
  46. package/src/async/job-manager.ts +298 -6
  47. package/src/cli/auth-broker-cli.ts +1 -0
  48. package/src/cli/config-cli.ts +10 -2
  49. package/src/cli.ts +2 -0
  50. package/src/commands/harness.ts +592 -0
  51. package/src/commands/team.ts +36 -39
  52. package/src/config/settings-schema.ts +7 -0
  53. package/src/config/settings.ts +5 -0
  54. package/src/deep-interview/render-middleware.ts +366 -0
  55. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  56. package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
  57. package/src/extensibility/custom-tools/types.ts +1 -0
  58. package/src/extensibility/extensions/types.ts +6 -0
  59. package/src/extensibility/shared-events.ts +1 -0
  60. package/src/gjc-runtime/deep-interview-runtime.ts +40 -21
  61. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  62. package/src/gjc-runtime/ralplan-runtime.ts +25 -10
  63. package/src/gjc-runtime/state-graph.ts +86 -0
  64. package/src/gjc-runtime/state-migrations.ts +132 -0
  65. package/src/gjc-runtime/state-renderer.ts +345 -0
  66. package/src/gjc-runtime/state-runtime.ts +733 -21
  67. package/src/gjc-runtime/state-validation.ts +49 -0
  68. package/src/gjc-runtime/state-writer.ts +718 -0
  69. package/src/gjc-runtime/team-runtime.ts +1083 -89
  70. package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
  71. package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
  72. package/src/gjc-runtime/workflow-manifest.ts +425 -0
  73. package/src/harness-control-plane/classifier.ts +128 -0
  74. package/src/harness-control-plane/control-endpoint.ts +137 -0
  75. package/src/harness-control-plane/finalize.ts +222 -0
  76. package/src/harness-control-plane/frame-mapper.ts +286 -0
  77. package/src/harness-control-plane/operate.ts +225 -0
  78. package/src/harness-control-plane/owner.ts +553 -0
  79. package/src/harness-control-plane/preserve.ts +102 -0
  80. package/src/harness-control-plane/receipts.ts +216 -0
  81. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  82. package/src/harness-control-plane/seams.ts +39 -0
  83. package/src/harness-control-plane/session-lease.ts +388 -0
  84. package/src/harness-control-plane/state-machine.ts +97 -0
  85. package/src/harness-control-plane/storage.ts +257 -0
  86. package/src/harness-control-plane/types.ts +214 -0
  87. package/src/hooks/skill-keywords.ts +4 -2
  88. package/src/hooks/skill-state.ts +24 -41
  89. package/src/internal-urls/docs-index.generated.ts +1 -1
  90. package/src/modes/components/assistant-message.ts +5 -1
  91. package/src/modes/components/hook-selector.ts +72 -2
  92. package/src/modes/controllers/event-controller.ts +71 -6
  93. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  94. package/src/modes/controllers/input-controller.ts +9 -1
  95. package/src/modes/controllers/selector-controller.ts +2 -1
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/types.ts +1 -0
  98. package/src/prompts/agents/executor.md +13 -0
  99. package/src/prompts/tools/subagent.md +33 -3
  100. package/src/sdk.ts +4 -0
  101. package/src/session/agent-session.ts +231 -33
  102. package/src/session/session-manager.ts +13 -1
  103. package/src/skill-state/active-state.ts +58 -65
  104. package/src/skill-state/deep-interview-mutation-guard.ts +91 -13
  105. package/src/skill-state/initial-phase.ts +2 -0
  106. package/src/skill-state/workflow-state-contract.ts +26 -0
  107. package/src/task/executor.ts +50 -8
  108. package/src/task/index.ts +120 -8
  109. package/src/task/render.ts +6 -3
  110. package/src/task/types.ts +56 -3
  111. package/src/tools/ask.ts +28 -7
  112. package/src/tools/subagent.ts +255 -64
@@ -31,7 +31,7 @@ export class InputController {
31
31
  constructor(private ctx: InteractiveModeContext) {}
32
32
 
33
33
  #abortInteractive(): Promise<void> {
34
- return this.ctx.session.abort({ timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS });
34
+ return this.ctx.session.abort({ timeoutMs: INTERACTIVE_ABORT_CLEANUP_TIMEOUT_MS, cause: "user_interrupt" });
35
35
  }
36
36
 
37
37
  setupKeyHandlers(): void {
@@ -568,6 +568,14 @@ export class InputController {
568
568
  this.ctx.retryLoader.stop();
569
569
  this.ctx.retryLoader = undefined;
570
570
  }
571
+ if (this.ctx.retryCountdownTimer) {
572
+ clearInterval(this.ctx.retryCountdownTimer);
573
+ this.ctx.retryCountdownTimer = undefined;
574
+ }
575
+ if (this.ctx.retryEscapeHandler) {
576
+ this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
577
+ this.ctx.retryEscapeHandler = undefined;
578
+ }
571
579
  this.ctx.statusContainer.clear();
572
580
  this.ctx.statusLine.dispose();
573
581
 
@@ -57,12 +57,13 @@ import { TreeSelectorComponent } from "../components/tree-selector";
57
57
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
58
58
  import type { SessionObserverRegistry } from "../session-observer-registry";
59
59
 
60
- const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
60
+ const CALLBACK_SERVER_PROVIDERS = new Set<string>([
61
61
  "anthropic",
62
62
  "openai-codex",
63
63
  "gitlab-duo",
64
64
  "google-gemini-cli",
65
65
  "google-antigravity",
66
+ "xai",
66
67
  ]);
67
68
 
68
69
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
@@ -276,6 +276,7 @@ export class InteractiveMode implements InteractiveModeContext {
276
276
  }
277
277
  autoCompactionEscapeHandler?: () => void;
278
278
  retryEscapeHandler?: () => void;
279
+ retryCountdownTimer?: ReturnType<typeof setInterval>;
279
280
  unsubscribe?: () => void;
280
281
  onInputCallback?: (input: SubmittedUserInput) => void;
281
282
  optimisticUserMessageSignature: string | undefined = undefined;
@@ -109,6 +109,7 @@ export interface InteractiveModeContext {
109
109
  retryLoader: Loader | undefined;
110
110
  autoCompactionEscapeHandler?: () => void;
111
111
  retryEscapeHandler?: () => void;
112
+ retryCountdownTimer?: ReturnType<typeof setInterval>;
112
113
  unsubscribe?: () => void;
113
114
  onInputCallback?: (input: SubmittedUserInput) => void;
114
115
  optimisticUserMessageSignature: string | undefined;
@@ -31,6 +31,19 @@ Explore just enough context, implement the smallest correct change, and leave co
31
31
  5. Remove debug leftovers and report changed files plus evidence.
32
32
  </execution_loop>
33
33
 
34
+ <ultragoal_red_team_mode>
35
+ This mode activates only when the assignment explicitly labels Executor as Ultragoal completion QA/red-team or asks for `executorQa` red-team evidence. Otherwise, preserve ordinary Executor behavior.
36
+
37
+ When active:
38
+ - Start from the approved plan/spec/acceptance criteria, then user-facing contracts, then implementation code only as supporting evidence. Treat plan/code mismatches as blockers.
39
+ - Exercise the real user-facing invocation rather than inspecting internals alone: GUI/web uses browser automation plus screenshot or image verdict; CLI uses logs or terminal transcripts; API/package uses external consumer or black-box tests through the public interface; algorithm/math uses boundary, property, adversarial, and failure-mode cases.
40
+ - Try to break the work with adversarial cases, not just happy-path confirmations.
41
+ - Report the QA matrix with the final field names `executorQa.contractCoverage`, `executorQa.surfaceEvidence`, `executorQa.adversarialCases`, and `executorQa.artifactRefs`.
42
+ - Include artifact refs for every executed surface and adversarial case: transcript ids, log paths, screenshots, image verdicts, test outputs, or other durable evidence.
43
+ - Use `status: "not_applicable"` only for rows in `executorQa.contractCoverage` and `executorQa.surfaceEvidence`; each not-applicable row requires `contractRef` plus `reason`. `executorQa.adversarialCases` rows cannot be not-applicable.
44
+ - Report blockers for any missing plan/spec/acceptance source, contract ambiguity, plan/code mismatch, untestable surface, failed adversarial case, shallow evidence, or missing artifact ref.
45
+ </ultragoal_red_team_mode>
46
+
34
47
  <success_criteria>
35
48
  - Requested behavior is implemented in the assigned scope.
36
49
  - Modified files match existing style and contracts.
@@ -1,11 +1,11 @@
1
- Lists, inspects, awaits, or cancels detached task subagents.
1
+ Lists, inspects, awaits, pauses, resumes, steers, or cancels detached task subagents.
2
2
 
3
3
  Task launches return immediately. Use this tool when you need direct control over those running subagents. Prefer `subagent` for task subagents; generic `job` remains available for non-subagent jobs and compatibility fallback access.
4
4
 
5
5
  # Operations
6
6
 
7
7
  ## `action: "list"`
8
- Snapshot your visible detached subagents.
8
+ Snapshot your visible detached subagents, including `running`, `paused`, `queued`, and terminal subagents when retained.
9
9
 
10
10
  ## `action: "inspect"`
11
11
  Inspect selected subagents by `ids`; omit `ids` to inspect current running subagents. Terminal subagents include final output when retained.
@@ -16,6 +16,36 @@ Wait for selected subagents by `ids`; omit `ids` to wait for current running sub
16
16
  - Await timeout only bounds this tool call's wait; it does not stop the subagent and is not a failure reason.
17
17
  - On timeout, inspect progress and keep doing independent work. Never cancel just because an await timed out; cancel only if the subagent has actually failed, gone off-track, or become unrecoverably wrong.
18
18
 
19
+ ## `action: "pause"`
20
+ Request a graceful safe-boundary pause for selected subagents by `ids`.
21
+ - Non-running subagents are a no-op and return their current status snapshot.
22
+ - A paused subagent keeps its session context and can be resumed later.
23
+
24
+ ## `action: "resume"`
25
+ Resume selected non-running subagents by `ids`.
26
+ - Optional `message` is delivered into the resumed run.
27
+ - Running subagents are a no-op and return their current status snapshot.
28
+ - Terminal subagents require `message` to start a follow-up resume run; without `message`, the tool returns the current snapshot with guidance.
29
+ - `paused` subagents resume from saved context; `queued` subagents are already waiting for capacity.
30
+
31
+ ## `action: "steer"`
32
+ Send a non-empty `message` to selected subagents by `ids`.
33
+ - Running subagents receive the message through their live handle.
34
+ - Optional `pause: true` requests a safe-boundary pause after steering a running subagent.
35
+ - `pause` only matters while the target is running.
36
+ - Non-active subagents (`paused`, `queued`, or terminal) automatically resume with the message; `pause` is ignored for these targets.
37
+
19
38
  ## `action: "cancel"`
20
- Stop selected running subagents by `ids`.
39
+ Stop selected subagents by `ids`, including running, paused, or queued subagents.
21
40
  - Use only when the subagent has actually failed, gone off-track, or become unrecoverably wrong; an await timeout alone is never a cancellation reason.
41
+ - Cancellation keeps the subagent session file for possible later context recovery.
42
+
43
+ # Statuses
44
+
45
+ - `running` — currently executing.
46
+ - `paused` — stopped at a safe boundary with resumable context.
47
+ - `queued` — resume requested and waiting for execution capacity.
48
+ - `completed` — finished successfully.
49
+ - `failed` — finished with an error.
50
+ - `cancelled` — stopped by cancellation.
51
+ - `not_found` — no visible subagent matches the requested id.
package/src/sdk.ts CHANGED
@@ -327,6 +327,8 @@ export interface CreateAgentSessionOptions {
327
327
  forkContextSeed?: ForkContextSeed;
328
328
  /** Optional provider state override. Fork-context children should omit this by default. */
329
329
  providerSessionState?: Map<string, ProviderSessionState>;
330
+ /** Cooperative pause checkpoint passed through to Agent. */
331
+ shouldPause?: () => boolean;
330
332
  }
331
333
 
332
334
  /** Result from createAgentSession */
@@ -657,6 +659,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
657
659
  reason: "auto_retry_start",
658
660
  attempt: event.attempt,
659
661
  maxAttempts: event.maxAttempts,
662
+ unbounded: event.unbounded,
660
663
  delayMs: event.delayMs,
661
664
  errorMessage: event.errorMessage,
662
665
  },
@@ -1797,6 +1800,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1797
1800
  requestMaxRetries: retrySettings.requestMaxRetries,
1798
1801
  streamMaxRetries: retrySettings.streamMaxRetries,
1799
1802
  kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
1803
+ shouldPause: options.shouldPause,
1800
1804
  preferWebsockets: preferOpenAICodexWebsockets,
1801
1805
  getToolContext: tc => toolContextStore.getContext(tc),
1802
1806
  getApiKey: async provider => {
@@ -167,6 +167,7 @@ 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";
@@ -263,7 +264,14 @@ export type AgentSessionEvent =
263
264
  /** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
264
265
  skipped?: boolean;
265
266
  }
266
- | { type: "auto_retry_start"; attempt: number; maxAttempts: number; delayMs: number; errorMessage: string }
267
+ | {
268
+ type: "auto_retry_start";
269
+ attempt: number;
270
+ maxAttempts: number;
271
+ delayMs: number;
272
+ errorMessage: string;
273
+ unbounded?: boolean;
274
+ }
267
275
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
268
276
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
269
277
  | { type: "retry_fallback_succeeded"; model: string; role: string }
@@ -282,6 +290,11 @@ export type AgentSessionEvent =
282
290
  */
283
291
  const SAFE_PATH_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}$/;
284
292
 
293
+ function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
294
+ const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
295
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
296
+ }
297
+
285
298
  /** Listener function for agent session events */
286
299
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
287
300
  export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
@@ -852,6 +865,7 @@ export class AgentSession {
852
865
 
853
866
  // Retry state
854
867
  #retryAbortController: AbortController | undefined = undefined;
868
+ #retryNowRequested = false;
855
869
  #retryAttempt = 0;
856
870
  #retryPromise: Promise<void> | undefined = undefined;
857
871
  #retryResolve: (() => void) | undefined = undefined;
@@ -1887,6 +1901,15 @@ export class AgentSession {
1887
1901
  attempt: this.#retryAttempt,
1888
1902
  });
1889
1903
  this.#retryAttempt = 0;
1904
+ // Settle the retry gate here, colocated with the success event, rather
1905
+ // than relying on the generic #resolveRetry() at the end of the
1906
+ // agent_end branch. That tail resolver is bypassed by every early
1907
+ // return in agent_end (successful `yield`, handoff-abort skip-maintenance,
1908
+ // missing assistant message), so a retry that recovers on a yield turn
1909
+ // would otherwise leave #retryPromise unresolved — wedging
1910
+ // #waitForPostPromptRecovery and the session as permanently busy.
1911
+ // #resolveRetry() is idempotent, so the later tail call is a no-op.
1912
+ this.#resolveRetry();
1890
1913
  }
1891
1914
  }
1892
1915
 
@@ -2001,6 +2024,18 @@ export class AgentSession {
2001
2024
  const didRetry = await this.#handleRetryableError(msg);
2002
2025
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
2003
2026
  }
2027
+ if (this.#retryAttempt > 0) {
2028
+ // A prior retry ended on a non-retryable (terminal) message: emit
2029
+ // the terminal retry-end and reset so observers clear retry state.
2030
+ const attempt = this.#retryAttempt;
2031
+ this.#retryAttempt = 0;
2032
+ await this.#emitSessionEvent({
2033
+ type: "auto_retry_end",
2034
+ success: false,
2035
+ attempt,
2036
+ finalError: msg.errorMessage,
2037
+ });
2038
+ }
2004
2039
  this.#resolveRetry();
2005
2040
 
2006
2041
  const compactionTask = this.#checkCompaction(msg);
@@ -2871,6 +2906,7 @@ export class AgentSession {
2871
2906
  maxAttempts: event.maxAttempts,
2872
2907
  delayMs: event.delayMs,
2873
2908
  errorMessage: event.errorMessage,
2909
+ unbounded: event.unbounded,
2874
2910
  });
2875
2911
  } else if (event.type === "auto_retry_end") {
2876
2912
  await this.#extensionRunner.emit({
@@ -3485,7 +3521,7 @@ export class AgentSession {
3485
3521
  * prompts or tool execution can run.
3486
3522
  */
3487
3523
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3488
- if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
3524
+ if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3489
3525
  return new Proxy(tool, {
3490
3526
  get: (target, prop) => {
3491
3527
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -5027,7 +5063,18 @@ export class AgentSession {
5027
5063
  /**
5028
5064
  * Abort current operation and wait for agent to become idle.
5029
5065
  */
5030
- async abort(options?: { goalReason?: "interrupted" | "internal"; timeoutMs?: number }): Promise<void> {
5066
+ async abort(options?: {
5067
+ goalReason?: "interrupted" | "internal";
5068
+ timeoutMs?: number;
5069
+ cause?:
5070
+ | "user_interrupt"
5071
+ | "new_session"
5072
+ | "session_switch"
5073
+ | "compaction"
5074
+ | "handoff"
5075
+ | "tool_abort"
5076
+ | "internal";
5077
+ }): Promise<void> {
5031
5078
  this.abortRetry();
5032
5079
  this.#promptGeneration++;
5033
5080
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -5074,6 +5121,18 @@ export class AgentSession {
5074
5121
  if (this.#toolChoiceQueue.hasInFlight) {
5075
5122
  this.#toolChoiceQueue.reject("aborted");
5076
5123
  }
5124
+
5125
+ // Steer-on-interrupt: after a genuine user interrupt, resume with any
5126
+ // queued steering instead of going idle. Lifecycle/teardown causes
5127
+ // (default "internal") suppress this; new-session/handoff additionally
5128
+ // clear the steering queue, and compaction resumes via its own path.
5129
+ if ((options?.cause ?? "internal") === "user_interrupt" && this.agent.hasQueuedSteering()) {
5130
+ this.#scheduleAgentContinue({
5131
+ delayMs: 1,
5132
+ generation: this.#promptGeneration,
5133
+ shouldContinue: () => this.agent.hasQueuedSteering(),
5134
+ });
5135
+ }
5077
5136
  }
5078
5137
 
5079
5138
  /**
@@ -5931,7 +5990,14 @@ export class AgentSession {
5931
5990
  if (artifactsDir) {
5932
5991
  const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
5933
5992
  try {
5934
- await Bun.write(handoffFilePath, `${handoffText}\n`);
5993
+ if (isUnderProjectGjc(this.sessionManager.getCwd(), handoffFilePath)) {
5994
+ await writeArtifact(handoffFilePath, `${handoffText}\n`, {
5995
+ cwd: this.sessionManager.getCwd(),
5996
+ audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
5997
+ });
5998
+ } else {
5999
+ await Bun.write(handoffFilePath, `${handoffText}\n`);
6000
+ }
5935
6001
  savedPath = handoffFilePath;
5936
6002
  } catch (error) {
5937
6003
  logger.warn("Failed to save handoff document to disk", {
@@ -7121,19 +7187,14 @@ export class AgentSession {
7121
7187
  // =========================================================================
7122
7188
 
7123
7189
  /**
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.
7190
+ * Whether an error should be retried. Uses the ordered classifier:
7191
+ * context-overflow routes to compaction; clearly-terminal coded errors
7192
+ * (auth/400/not-found) surface immediately; usage-limit, transient, and
7193
+ * unknown/no-code errors are retryable.
7127
7194
  */
7128
7195
  #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);
7196
+ const classification = this.#classifyErrorForRetry(message);
7197
+ return classification === "usage_limit" || classification === "transient" || classification === "unknown";
7137
7198
  }
7138
7199
 
7139
7200
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -7159,6 +7220,63 @@ export class AgentSession {
7159
7220
  );
7160
7221
  }
7161
7222
 
7223
+ #isTerminalErrorMessage(errorMessage: string): boolean {
7224
+ // Errors that will never succeed on retry (auth/permission, malformed
7225
+ // request, unknown/unsupported model). These surface immediately rather
7226
+ // than retry forever.
7227
+ 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(
7228
+ errorMessage,
7229
+ );
7230
+ }
7231
+
7232
+ #extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
7233
+ // Parse only explicit HTTP/status wording. Do not treat generic
7234
+ // `error: 400` as an HTTP status because rate-limit copy can say
7235
+ // "rate limit error: 400 requests per minute".
7236
+ const match = /\b(?:http(?:\s+status)?|status(?:[\s_-]+code)?)(?:\s+|[:=]\s*)(\d{3})\b/i.exec(errorMessage);
7237
+ if (!match) return undefined;
7238
+ const status = Number(match[1]);
7239
+ return Number.isFinite(status) && status >= 100 && status <= 599 ? status : undefined;
7240
+ }
7241
+
7242
+ /**
7243
+ * Ordered retry classification: overflow (compaction) -> terminal (surface)
7244
+ * -> usage_limit (rotation) -> transient (retry) -> unknown (retry).
7245
+ */
7246
+ #classifyErrorForRetry(
7247
+ message: AssistantMessage,
7248
+ ): "none" | "overflow" | "terminal" | "usage_limit" | "transient" | "unknown" {
7249
+ if (message.stopReason !== "error" || !message.errorMessage) return "none";
7250
+ const contextWindow = this.model?.contextWindow ?? 0;
7251
+ if (isContextOverflow(message, contextWindow)) return "overflow";
7252
+ const err = message.errorMessage;
7253
+ // Stream-envelope errors are only transient in the pre-message_start
7254
+ // variant; any other envelope failure is structural and must surface.
7255
+ if (/anthropic stream envelope error:/i.test(err)) {
7256
+ return this.#isTransientEnvelopeErrorMessage(err) ? "transient" : "terminal";
7257
+ }
7258
+ const explicitStatus = this.#extractExplicitHttpStatusFromErrorMessage(err);
7259
+ const structuredStatus = message.errorStatus;
7260
+ const terminalStatus = explicitStatus ?? structuredStatus;
7261
+ const isTerminalHttp4xx =
7262
+ terminalStatus !== undefined &&
7263
+ terminalStatus >= 400 &&
7264
+ terminalStatus < 500 &&
7265
+ terminalStatus !== 408 &&
7266
+ terminalStatus !== 425 &&
7267
+ terminalStatus !== 429;
7268
+ if (this.#isTerminalErrorMessage(err)) return "terminal";
7269
+ if (isUsageLimitError(err)) return "usage_limit";
7270
+ // Explicit HTTP/status wording is authoritative. Structured provider status
7271
+ // is also authoritative except for rate-limit copy where providers may have
7272
+ // parsed an incidental quota number such as "400 requests per minute".
7273
+ if (isTerminalHttp4xx && (explicitStatus !== undefined || !/rate.?limit|too many requests/i.test(err))) {
7274
+ return "terminal";
7275
+ }
7276
+ if (this.#isTransientErrorMessage(err)) return "transient";
7277
+ return "unknown";
7278
+ }
7279
+
7162
7280
  #getRetryFallbackChains(): RetryFallbackChains {
7163
7281
  const configuredChains = this.settings.get("retry.fallbackChains");
7164
7282
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -7428,6 +7546,8 @@ export class AgentSession {
7428
7546
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
7429
7547
  const retrySettings = this.settings.getGroup("retry");
7430
7548
  if (!retrySettings.enabled) return false;
7549
+ const retryClassification = this.#classifyErrorForRetry(message);
7550
+ const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
7431
7551
 
7432
7552
  const generation = this.#promptGeneration;
7433
7553
  this.#retryAttempt++;
@@ -7440,7 +7560,7 @@ export class AgentSession {
7440
7560
  this.#retryResolve = resolve;
7441
7561
  }
7442
7562
 
7443
- if (this.#retryAttempt > retrySettings.maxRetries) {
7563
+ if (!unboundedClass && this.#retryAttempt > retrySettings.maxRetries) {
7444
7564
  // Max retries exceeded, emit final failure and reset
7445
7565
  await this.#emitSessionEvent({
7446
7566
  type: "auto_retry_end",
@@ -7497,7 +7617,16 @@ export class AgentSession {
7497
7617
  // assistant error message is preserved in agent state so the caller
7498
7618
  // can act on it.
7499
7619
  const maxDelayMs = retrySettings.maxDelayMs;
7500
- if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7620
+ if (unboundedClass && !switchedCredential && !switchedModel) {
7621
+ // Retry forever: honor a provider-supplied wait, otherwise cap the
7622
+ // exponential backoff at the ceiling instead of giving up.
7623
+ if (parsedRetryAfterMs !== undefined) {
7624
+ delayMs = Math.max(delayMs, parsedRetryAfterMs);
7625
+ } else if (maxDelayMs > 0) {
7626
+ delayMs = Math.min(delayMs, maxDelayMs);
7627
+ }
7628
+ }
7629
+ if (!unboundedClass && maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
7501
7630
  const attempt = this.#retryAttempt;
7502
7631
  this.#retryAttempt = 0;
7503
7632
  await this.#emitSessionEvent({
@@ -7510,12 +7639,22 @@ export class AgentSession {
7510
7639
  return false;
7511
7640
  }
7512
7641
 
7642
+ // Create and install the backoff abort controller BEFORE emitting
7643
+ // auto_retry_start, so a synchronous retryNow()/abortRetry() invoked from
7644
+ // an event subscriber (e.g. the TUI Esc handler) is not lost in the gap
7645
+ // between the event and the controller assignment.
7646
+ const retryAbortController = new AbortController();
7647
+ this.#retryAbortController?.abort();
7648
+ this.#retryAbortController = retryAbortController;
7649
+ this.#retryNowRequested = false;
7650
+
7513
7651
  await this.#emitSessionEvent({
7514
7652
  type: "auto_retry_start",
7515
7653
  attempt: this.#retryAttempt,
7516
7654
  maxAttempts: retrySettings.maxRetries,
7517
7655
  delayMs,
7518
7656
  errorMessage,
7657
+ unbounded: unboundedClass,
7519
7658
  });
7520
7659
 
7521
7660
  // Remove error message from agent state (keep in session for history)
@@ -7525,34 +7664,49 @@ export class AgentSession {
7525
7664
  }
7526
7665
 
7527
7666
  // Wait with exponential backoff (abortable).
7528
- const retryAbortController = new AbortController();
7529
- this.#retryAbortController?.abort();
7530
- this.#retryAbortController = retryAbortController;
7531
7667
  try {
7532
7668
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
7533
7669
  } catch {
7534
7670
  if (this.#retryAbortController !== retryAbortController) {
7535
7671
  return false;
7536
7672
  }
7537
- // Aborted during sleep - emit end event so UI can clean up
7538
- const attempt = this.#retryAttempt;
7539
- this.#retryAttempt = 0;
7540
7673
  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;
7674
+ if (this.#retryNowRequested) {
7675
+ // Retry-now: skip the remaining backoff and fall through to
7676
+ // re-attempt immediately (keeps the retry session alive).
7677
+ this.#retryNowRequested = false;
7678
+ } else {
7679
+ // Aborted during sleep (cancel) - emit end event so UI can clean up
7680
+ const attempt = this.#retryAttempt;
7681
+ this.#retryAttempt = 0;
7682
+ await this.#emitSessionEvent({
7683
+ type: "auto_retry_end",
7684
+ success: false,
7685
+ attempt,
7686
+ finalError: "Retry cancelled",
7687
+ });
7688
+ this.#resolveRetry();
7689
+ return false;
7690
+ }
7549
7691
  }
7550
7692
  if (this.#retryAbortController === retryAbortController) {
7551
7693
  this.#retryAbortController = undefined;
7552
7694
  }
7553
7695
 
7554
7696
  // Retry via continue() outside the agent_end event callback chain.
7555
- this.#scheduleAgentContinue({ delayMs: 1, generation });
7697
+ // If the scheduled continue cannot run — it throws (e.g. AgentBusyError from a
7698
+ // concurrent turn, or "Cannot continue ...") or is skipped because a newer
7699
+ // generation took over — the agent_end that normally resolves #retryPromise
7700
+ // never arrives. Finalize the retry in that case so #waitForPostPromptRecovery
7701
+ // (and the in-flight prompt holding it open) cannot wedge the session as
7702
+ // permanently busy, which would turn every later prompt() into a
7703
+ // non-recoverable AgentBusyError loop.
7704
+ this.#scheduleAgentContinue({
7705
+ delayMs: 1,
7706
+ generation,
7707
+ onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
7708
+ onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
7709
+ });
7556
7710
 
7557
7711
  return true;
7558
7712
  }
@@ -7561,8 +7715,41 @@ export class AgentSession {
7561
7715
  * Cancel in-progress retry.
7562
7716
  */
7563
7717
  abortRetry(): void {
7718
+ this.#retryNowRequested = false;
7564
7719
  this.#retryAbortController?.abort();
7565
- // Note: _retryAttempt is reset in the catch block of _autoRetry
7720
+ // Note: #retryAttempt is reset in the catch block of #handleRetryableError
7721
+ this.#resolveRetry();
7722
+ }
7723
+
7724
+ /**
7725
+ * Skip the current retry backoff and re-attempt immediately. Distinct from
7726
+ * abortRetry(), which cancels the retry and returns to idle. No-op when no
7727
+ * retry backoff is active.
7728
+ */
7729
+ retryNow(): void {
7730
+ if (!this.#retryAbortController) return;
7731
+ this.#retryNowRequested = true;
7732
+ this.#retryAbortController.abort();
7733
+ }
7734
+
7735
+ /**
7736
+ * Finalize a pending auto-retry that can no longer reach a resolving agent_end
7737
+ * (the scheduled continue threw or was superseded). Without this, #retryPromise
7738
+ * stays unresolved, #waitForPostPromptRecovery never returns, the owning
7739
+ * prompt's in-flight count is never released, and the session reports
7740
+ * `isStreaming === true` forever — turning every later prompt() into a
7741
+ * non-recoverable AgentBusyError. No-op once the retry has already settled.
7742
+ */
7743
+ #failRetryRecovery(reason: string): void {
7744
+ if (!this.#retryPromise) return;
7745
+ const attempt = this.#retryAttempt;
7746
+ this.#retryAttempt = 0;
7747
+ void this.#emitSessionEvent({
7748
+ type: "auto_retry_end",
7749
+ success: false,
7750
+ attempt,
7751
+ finalError: reason,
7752
+ });
7566
7753
  this.#resolveRetry();
7567
7754
  }
7568
7755
 
@@ -8279,6 +8466,8 @@ export class AgentSession {
8279
8466
  const previousFallbackSelectedMCPToolNames = previousSessionFile
8280
8467
  ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
8281
8468
  : undefined;
8469
+ const previousAgentSteeringQueue = this.agent.snapshotSteering();
8470
+ const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
8282
8471
 
8283
8472
  this.#steeringMessages = [];
8284
8473
  this.#followUpMessages = [];
@@ -8297,6 +8486,12 @@ export class AgentSession {
8297
8486
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
8298
8487
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
8299
8488
 
8489
+ // The target session is loaded and MCP selections are restored: the
8490
+ // switch is committed far enough to discard pre-switch delivery queues.
8491
+ // Clear before session_switch hooks, so messages enqueued by hooks belong
8492
+ // to the new session and remain deliverable.
8493
+ this.agent.clearAllQueues();
8494
+
8300
8495
  // Emit session_switch event to hooks
8301
8496
  if (this.#extensionRunner) {
8302
8497
  await this.#extensionRunner.emit({
@@ -8391,6 +8586,9 @@ export class AgentSession {
8391
8586
  this.#followUpMessages = previousFollowUpMessages;
8392
8587
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
8393
8588
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
8589
+ this.agent.clearAllQueues();
8590
+ this.agent.restoreSteering(previousAgentSteeringQueue);
8591
+ this.agent.restoreFollowUp(previousAgentFollowUpQueue);
8394
8592
  if (previousModel) {
8395
8593
  this.agent.setModel(previousModel);
8396
8594
  }
@@ -27,6 +27,7 @@ import {
27
27
  Snowflake,
28
28
  toError,
29
29
  } from "@gajae-code/utils";
30
+ import { writeTextAtomic } from "../gjc-runtime/state-writer";
30
31
  import { ArtifactManager } from "./artifacts";
31
32
  import {
32
33
  type BlobPutResult,
@@ -56,6 +57,10 @@ import type { SessionStorage, SessionStorageWriter } from "./session-storage";
56
57
  import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
57
58
 
58
59
  export const CURRENT_SESSION_VERSION = 3;
60
+ function isUnderProjectGjc(cwd: string, targetPath: string): boolean {
61
+ const relative = path.relative(path.join(path.resolve(cwd), ".gjc"), path.resolve(targetPath));
62
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
63
+ }
59
64
 
60
65
  export interface SessionHeader {
61
66
  type: "session";
@@ -384,6 +389,7 @@ const migratedSessionRoots = new Set<string>();
384
389
  * Best effort: callers decide whether migration failures should surface.
385
390
  */
386
391
  function migrateSessionDirPath(oldPath: string, newPath: string): void {
392
+ // Session-dir lifecycle migration: moves/removes whole directories, not file content writes.
387
393
  const existing = fs.statSync(newPath, { throwIfNoEntry: false });
388
394
  if (existing?.isDirectory()) {
389
395
  for (const file of fs.readdirSync(oldPath)) {
@@ -752,7 +758,13 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
752
758
  const breadcrumbFile = path.join(breadcrumbDir, terminalId);
753
759
  const content = `${cwd}\n${sessionFile}\n`;
754
760
  // Best-effort — don't break session creation if breadcrumb fails
755
- Bun.write(breadcrumbFile, content).catch(() => {});
761
+ const write = isUnderProjectGjc(cwd, breadcrumbFile)
762
+ ? writeTextAtomic(breadcrumbFile, content, {
763
+ cwd,
764
+ audit: { category: "artifact", verb: "write", owner: "gjc-runtime" },
765
+ })
766
+ : Bun.write(breadcrumbFile, content);
767
+ write.catch(() => {});
756
768
  }
757
769
 
758
770
  /**