@gajae-code/coding-agent 0.2.4 → 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 (266) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +1 -1
  3. package/dist/types/async/job-manager.d.ts +145 -2
  4. package/dist/types/commands/harness.d.ts +37 -0
  5. package/dist/types/config/settings-schema.d.ts +13 -3
  6. package/dist/types/config/settings.d.ts +3 -1
  7. package/dist/types/deep-interview/render-middleware.d.ts +5 -0
  8. package/dist/types/discovery/helpers.d.ts +1 -0
  9. package/dist/types/exec/bash-executor.d.ts +8 -1
  10. package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
  11. package/dist/types/extensibility/extensions/types.d.ts +6 -0
  12. package/dist/types/extensibility/shared-events.d.ts +1 -0
  13. package/dist/types/gjc-runtime/restricted-role-agent-bash.d.ts +2 -0
  14. package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
  15. package/dist/types/gjc-runtime/state-migrations.d.ts +24 -0
  16. package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
  17. package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
  18. package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
  19. package/dist/types/gjc-runtime/state-writer.d.ts +137 -0
  20. package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
  21. package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
  22. package/dist/types/harness-control-plane/classifier.d.ts +13 -0
  23. package/dist/types/harness-control-plane/control-endpoint.d.ts +30 -0
  24. package/dist/types/harness-control-plane/finalize.d.ts +47 -0
  25. package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
  26. package/dist/types/harness-control-plane/operate.d.ts +35 -0
  27. package/dist/types/harness-control-plane/owner.d.ts +46 -0
  28. package/dist/types/harness-control-plane/preserve.d.ts +19 -0
  29. package/dist/types/harness-control-plane/receipts.d.ts +88 -0
  30. package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
  31. package/dist/types/harness-control-plane/seams.d.ts +21 -0
  32. package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
  33. package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
  34. package/dist/types/harness-control-plane/storage.d.ts +53 -0
  35. package/dist/types/harness-control-plane/types.d.ts +162 -0
  36. package/dist/types/hooks/skill-keywords.d.ts +2 -1
  37. package/dist/types/hooks/skill-state.d.ts +2 -29
  38. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -1
  39. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  40. package/dist/types/modes/components/skill-hud/render.d.ts +1 -1
  41. package/dist/types/modes/interactive-mode.d.ts +2 -0
  42. package/dist/types/modes/theme/defaults/index.d.ts +45 -9477
  43. package/dist/types/modes/theme/theme.d.ts +1 -5
  44. package/dist/types/modes/types.d.ts +2 -0
  45. package/dist/types/sdk.d.ts +4 -0
  46. package/dist/types/session/agent-session.d.ts +8 -0
  47. package/dist/types/session/streaming-output.d.ts +11 -0
  48. package/dist/types/skill-state/active-state.d.ts +3 -0
  49. package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
  50. package/dist/types/skill-state/workflow-state-contract.d.ts +24 -0
  51. package/dist/types/task/executor.d.ts +3 -0
  52. package/dist/types/task/types.d.ts +56 -3
  53. package/dist/types/tools/bash-allowed-prefixes.d.ts +5 -0
  54. package/dist/types/tools/bash.d.ts +24 -0
  55. package/dist/types/tools/cron.d.ts +110 -0
  56. package/dist/types/tools/index.d.ts +4 -0
  57. package/dist/types/tools/monitor.d.ts +54 -0
  58. package/dist/types/tools/subagent.d.ts +11 -1
  59. package/dist/types/web/search/index.d.ts +1 -0
  60. package/dist/types/web/search/provider.d.ts +11 -4
  61. package/dist/types/web/search/providers/duckduckgo.d.ts +57 -0
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +7 -7
  64. package/src/async/job-manager.ts +522 -6
  65. package/src/cli/agents-cli.ts +3 -0
  66. package/src/cli/auth-broker-cli.ts +1 -0
  67. package/src/cli/config-cli.ts +10 -2
  68. package/src/cli.ts +2 -0
  69. package/src/commands/harness.ts +592 -0
  70. package/src/commands/team.ts +36 -39
  71. package/src/config/settings-schema.ts +15 -2
  72. package/src/config/settings.ts +49 -7
  73. package/src/deep-interview/render-middleware.ts +366 -0
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +9 -2
  75. package/src/defaults/gjc/skills/ralplan/SKILL.md +8 -4
  76. package/src/defaults/gjc/skills/team/SKILL.md +47 -21
  77. package/src/defaults/gjc/skills/ultragoal/SKILL.md +78 -11
  78. package/src/discovery/helpers.ts +5 -0
  79. package/src/eval/js/shared/rewrite-imports.ts +1 -2
  80. package/src/exec/bash-executor.ts +20 -9
  81. package/src/extensibility/custom-tools/types.ts +1 -0
  82. package/src/extensibility/extensions/types.ts +6 -0
  83. package/src/extensibility/shared-events.ts +1 -0
  84. package/src/gjc-runtime/deep-interview-runtime.ts +40 -21
  85. package/src/gjc-runtime/goal-mode-request.ts +11 -3
  86. package/src/gjc-runtime/ralplan-runtime.ts +27 -10
  87. package/src/gjc-runtime/restricted-role-agent-bash.ts +5 -0
  88. package/src/gjc-runtime/state-graph.ts +86 -0
  89. package/src/gjc-runtime/state-migrations.ts +132 -0
  90. package/src/gjc-runtime/state-renderer.ts +345 -0
  91. package/src/gjc-runtime/state-runtime.ts +733 -21
  92. package/src/gjc-runtime/state-validation.ts +49 -0
  93. package/src/gjc-runtime/state-writer.ts +718 -0
  94. package/src/gjc-runtime/team-runtime.ts +1083 -89
  95. package/src/gjc-runtime/ultragoal-runtime.ts +348 -19
  96. package/src/gjc-runtime/workflow-manifest.generated.json +1497 -0
  97. package/src/gjc-runtime/workflow-manifest.ts +425 -0
  98. package/src/harness-control-plane/classifier.ts +128 -0
  99. package/src/harness-control-plane/control-endpoint.ts +137 -0
  100. package/src/harness-control-plane/finalize.ts +222 -0
  101. package/src/harness-control-plane/frame-mapper.ts +286 -0
  102. package/src/harness-control-plane/operate.ts +225 -0
  103. package/src/harness-control-plane/owner.ts +553 -0
  104. package/src/harness-control-plane/preserve.ts +102 -0
  105. package/src/harness-control-plane/receipts.ts +216 -0
  106. package/src/harness-control-plane/rpc-adapter.ts +276 -0
  107. package/src/harness-control-plane/seams.ts +39 -0
  108. package/src/harness-control-plane/session-lease.ts +388 -0
  109. package/src/harness-control-plane/state-machine.ts +97 -0
  110. package/src/harness-control-plane/storage.ts +257 -0
  111. package/src/harness-control-plane/types.ts +214 -0
  112. package/src/hooks/skill-keywords.ts +4 -2
  113. package/src/hooks/skill-state.ts +25 -42
  114. package/src/internal-urls/docs-index.generated.ts +6 -4
  115. package/src/lsp/render.ts +1 -1
  116. package/src/modes/acp/acp-agent.ts +1 -1
  117. package/src/modes/acp/acp-client-bridge.ts +1 -1
  118. package/src/modes/components/agent-dashboard.ts +1 -1
  119. package/src/modes/components/assistant-message.ts +5 -1
  120. package/src/modes/components/diff.ts +2 -2
  121. package/src/modes/components/hook-selector.ts +72 -2
  122. package/src/modes/components/skill-hud/render.ts +7 -2
  123. package/src/modes/controllers/event-controller.ts +71 -6
  124. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  125. package/src/modes/controllers/input-controller.ts +19 -3
  126. package/src/modes/controllers/selector-controller.ts +3 -2
  127. package/src/modes/interactive-mode.ts +21 -2
  128. package/src/modes/theme/defaults/index.ts +0 -196
  129. package/src/modes/theme/theme.ts +35 -35
  130. package/src/modes/types.ts +2 -0
  131. package/src/prompts/agents/architect.md +5 -1
  132. package/src/prompts/agents/critic.md +5 -1
  133. package/src/prompts/agents/executor.md +13 -0
  134. package/src/prompts/agents/frontmatter.md +1 -0
  135. package/src/prompts/agents/planner.md +5 -1
  136. package/src/prompts/tools/bash.md +9 -0
  137. package/src/prompts/tools/cron.md +25 -0
  138. package/src/prompts/tools/monitor.md +30 -0
  139. package/src/prompts/tools/subagent.md +33 -3
  140. package/src/runtime-mcp/oauth-flow.ts +4 -2
  141. package/src/sdk.ts +7 -0
  142. package/src/session/agent-session.ts +247 -38
  143. package/src/session/session-manager.ts +13 -1
  144. package/src/session/streaming-output.ts +21 -0
  145. package/src/skill-state/active-state.ts +222 -78
  146. package/src/skill-state/deep-interview-mutation-guard.ts +91 -13
  147. package/src/skill-state/initial-phase.ts +2 -0
  148. package/src/skill-state/workflow-state-contract.ts +26 -0
  149. package/src/task/agents.ts +1 -0
  150. package/src/task/executor.ts +51 -8
  151. package/src/task/index.ts +120 -8
  152. package/src/task/render.ts +6 -3
  153. package/src/task/types.ts +57 -3
  154. package/src/tools/ask.ts +28 -7
  155. package/src/tools/bash-allowed-prefixes.ts +169 -0
  156. package/src/tools/bash.ts +190 -29
  157. package/src/tools/browser/tab-worker.ts +1 -1
  158. package/src/tools/cron.ts +665 -0
  159. package/src/tools/index.ts +20 -2
  160. package/src/tools/monitor.ts +136 -0
  161. package/src/tools/subagent.ts +255 -64
  162. package/src/vim/engine.ts +3 -3
  163. package/src/web/search/index.ts +31 -18
  164. package/src/web/search/provider.ts +57 -12
  165. package/src/web/search/providers/duckduckgo.ts +279 -0
  166. package/src/web/search/types.ts +2 -0
  167. package/src/modes/theme/dark.json +0 -95
  168. package/src/modes/theme/defaults/alabaster.json +0 -93
  169. package/src/modes/theme/defaults/amethyst.json +0 -96
  170. package/src/modes/theme/defaults/anthracite.json +0 -93
  171. package/src/modes/theme/defaults/basalt.json +0 -91
  172. package/src/modes/theme/defaults/birch.json +0 -95
  173. package/src/modes/theme/defaults/dark-abyss.json +0 -91
  174. package/src/modes/theme/defaults/dark-arctic.json +0 -104
  175. package/src/modes/theme/defaults/dark-aurora.json +0 -95
  176. package/src/modes/theme/defaults/dark-catppuccin.json +0 -107
  177. package/src/modes/theme/defaults/dark-cavern.json +0 -91
  178. package/src/modes/theme/defaults/dark-copper.json +0 -95
  179. package/src/modes/theme/defaults/dark-cosmos.json +0 -90
  180. package/src/modes/theme/defaults/dark-cyberpunk.json +0 -102
  181. package/src/modes/theme/defaults/dark-dracula.json +0 -98
  182. package/src/modes/theme/defaults/dark-eclipse.json +0 -91
  183. package/src/modes/theme/defaults/dark-ember.json +0 -95
  184. package/src/modes/theme/defaults/dark-equinox.json +0 -90
  185. package/src/modes/theme/defaults/dark-forest.json +0 -96
  186. package/src/modes/theme/defaults/dark-github.json +0 -105
  187. package/src/modes/theme/defaults/dark-gruvbox.json +0 -112
  188. package/src/modes/theme/defaults/dark-lavender.json +0 -95
  189. package/src/modes/theme/defaults/dark-lunar.json +0 -89
  190. package/src/modes/theme/defaults/dark-midnight.json +0 -95
  191. package/src/modes/theme/defaults/dark-monochrome.json +0 -94
  192. package/src/modes/theme/defaults/dark-monokai.json +0 -98
  193. package/src/modes/theme/defaults/dark-nebula.json +0 -90
  194. package/src/modes/theme/defaults/dark-nord.json +0 -97
  195. package/src/modes/theme/defaults/dark-ocean.json +0 -101
  196. package/src/modes/theme/defaults/dark-one.json +0 -100
  197. package/src/modes/theme/defaults/dark-poimandres.json +0 -141
  198. package/src/modes/theme/defaults/dark-rainforest.json +0 -91
  199. package/src/modes/theme/defaults/dark-reef.json +0 -91
  200. package/src/modes/theme/defaults/dark-retro.json +0 -92
  201. package/src/modes/theme/defaults/dark-rose-pine.json +0 -96
  202. package/src/modes/theme/defaults/dark-sakura.json +0 -95
  203. package/src/modes/theme/defaults/dark-slate.json +0 -95
  204. package/src/modes/theme/defaults/dark-solarized.json +0 -97
  205. package/src/modes/theme/defaults/dark-solstice.json +0 -90
  206. package/src/modes/theme/defaults/dark-starfall.json +0 -91
  207. package/src/modes/theme/defaults/dark-sunset.json +0 -99
  208. package/src/modes/theme/defaults/dark-swamp.json +0 -90
  209. package/src/modes/theme/defaults/dark-synthwave.json +0 -103
  210. package/src/modes/theme/defaults/dark-taiga.json +0 -91
  211. package/src/modes/theme/defaults/dark-terminal.json +0 -95
  212. package/src/modes/theme/defaults/dark-tokyo-night.json +0 -101
  213. package/src/modes/theme/defaults/dark-tundra.json +0 -91
  214. package/src/modes/theme/defaults/dark-twilight.json +0 -91
  215. package/src/modes/theme/defaults/dark-volcanic.json +0 -91
  216. package/src/modes/theme/defaults/graphite.json +0 -92
  217. package/src/modes/theme/defaults/light-arctic.json +0 -107
  218. package/src/modes/theme/defaults/light-aurora-day.json +0 -91
  219. package/src/modes/theme/defaults/light-canyon.json +0 -91
  220. package/src/modes/theme/defaults/light-catppuccin.json +0 -106
  221. package/src/modes/theme/defaults/light-cirrus.json +0 -90
  222. package/src/modes/theme/defaults/light-coral.json +0 -95
  223. package/src/modes/theme/defaults/light-cyberpunk.json +0 -96
  224. package/src/modes/theme/defaults/light-dawn.json +0 -90
  225. package/src/modes/theme/defaults/light-dunes.json +0 -91
  226. package/src/modes/theme/defaults/light-eucalyptus.json +0 -95
  227. package/src/modes/theme/defaults/light-forest.json +0 -100
  228. package/src/modes/theme/defaults/light-frost.json +0 -95
  229. package/src/modes/theme/defaults/light-github.json +0 -115
  230. package/src/modes/theme/defaults/light-glacier.json +0 -91
  231. package/src/modes/theme/defaults/light-gruvbox.json +0 -108
  232. package/src/modes/theme/defaults/light-haze.json +0 -90
  233. package/src/modes/theme/defaults/light-honeycomb.json +0 -95
  234. package/src/modes/theme/defaults/light-lagoon.json +0 -91
  235. package/src/modes/theme/defaults/light-lavender.json +0 -95
  236. package/src/modes/theme/defaults/light-meadow.json +0 -91
  237. package/src/modes/theme/defaults/light-mint.json +0 -95
  238. package/src/modes/theme/defaults/light-monochrome.json +0 -101
  239. package/src/modes/theme/defaults/light-ocean.json +0 -99
  240. package/src/modes/theme/defaults/light-one.json +0 -99
  241. package/src/modes/theme/defaults/light-opal.json +0 -91
  242. package/src/modes/theme/defaults/light-orchard.json +0 -91
  243. package/src/modes/theme/defaults/light-paper.json +0 -95
  244. package/src/modes/theme/defaults/light-poimandres.json +0 -141
  245. package/src/modes/theme/defaults/light-prism.json +0 -90
  246. package/src/modes/theme/defaults/light-retro.json +0 -98
  247. package/src/modes/theme/defaults/light-sand.json +0 -95
  248. package/src/modes/theme/defaults/light-savanna.json +0 -91
  249. package/src/modes/theme/defaults/light-solarized.json +0 -102
  250. package/src/modes/theme/defaults/light-soleil.json +0 -90
  251. package/src/modes/theme/defaults/light-sunset.json +0 -99
  252. package/src/modes/theme/defaults/light-synthwave.json +0 -98
  253. package/src/modes/theme/defaults/light-tokyo-night.json +0 -111
  254. package/src/modes/theme/defaults/light-wetland.json +0 -91
  255. package/src/modes/theme/defaults/light-zenith.json +0 -89
  256. package/src/modes/theme/defaults/limestone.json +0 -94
  257. package/src/modes/theme/defaults/mahogany.json +0 -97
  258. package/src/modes/theme/defaults/marble.json +0 -93
  259. package/src/modes/theme/defaults/obsidian.json +0 -91
  260. package/src/modes/theme/defaults/onyx.json +0 -91
  261. package/src/modes/theme/defaults/pearl.json +0 -93
  262. package/src/modes/theme/defaults/porcelain.json +0 -91
  263. package/src/modes/theme/defaults/quartz.json +0 -96
  264. package/src/modes/theme/defaults/sandstone.json +0 -95
  265. package/src/modes/theme/defaults/titanium.json +0 -90
  266. package/src/modes/theme/light.json +0 -93
package/src/sdk.ts CHANGED
@@ -294,6 +294,8 @@ export interface CreateAgentSessionOptions {
294
294
  agentId?: string;
295
295
  /** Display name for the agent in IRC. Default: "main" or "sub". */
296
296
  agentDisplayName?: string;
297
+ /** Optional restricted bash command prefixes for read-only role agents. */
298
+ bashAllowedPrefixes?: string[];
297
299
  /** Optional shared agent registry for IRC routing. Default: AgentRegistry.global(). */
298
300
  agentRegistry?: AgentRegistry;
299
301
  /** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
@@ -325,6 +327,8 @@ export interface CreateAgentSessionOptions {
325
327
  forkContextSeed?: ForkContextSeed;
326
328
  /** Optional provider state override. Fork-context children should omit this by default. */
327
329
  providerSessionState?: Map<string, ProviderSessionState>;
330
+ /** Cooperative pause checkpoint passed through to Agent. */
331
+ shouldPause?: () => boolean;
328
332
  }
329
333
 
330
334
  /** Result from createAgentSession */
@@ -655,6 +659,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
655
659
  reason: "auto_retry_start",
656
660
  attempt: event.attempt,
657
661
  maxAttempts: event.maxAttempts,
662
+ unbounded: event.unbounded,
658
663
  delayMs: event.delayMs,
659
664
  errorMessage: event.errorMessage,
660
665
  },
@@ -1166,6 +1171,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1166
1171
  return agent?.state.model ?? model;
1167
1172
  },
1168
1173
  getAgentId: () => resolvedAgentId,
1174
+ bashAllowedPrefixes: options.bashAllowedPrefixes,
1169
1175
  getToolByName: name => session?.getToolByName(name),
1170
1176
  agentRegistry,
1171
1177
  getSessionSpawns: () => options.spawns ?? "*",
@@ -1794,6 +1800,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1794
1800
  requestMaxRetries: retrySettings.requestMaxRetries,
1795
1801
  streamMaxRetries: retrySettings.streamMaxRetries,
1796
1802
  kimiApiFormat: settings.get("providers.kimiApiFormat") ?? "anthropic",
1803
+ shouldPause: options.shouldPause,
1797
1804
  preferWebsockets: preferOpenAICodexWebsockets,
1798
1805
  getToolContext: tc => toolContextStore.getContext(tc),
1799
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">;
@@ -629,7 +642,11 @@ function createHandoffFileName(date = new Date()): string {
629
642
  // ============================================================================
630
643
 
631
644
  /** Tools that require user permission before execution when an ACP client is connected. */
632
- const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
645
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "monitor", "edit", "delete", "move"]);
646
+
647
+ function isShellExecutionPermissionTool(toolName: string): boolean {
648
+ return toolName === "bash" || toolName === "monitor";
649
+ }
633
650
 
634
651
  /** Permission options presented to the client on each gated tool call. */
635
652
  const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
@@ -694,7 +711,7 @@ function getPermissionIntent(
694
711
  args: unknown,
695
712
  ): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
696
713
  const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
697
- if (toolName === "bash") {
714
+ if (isShellExecutionPermissionTool(toolName)) {
698
715
  const cmd = getStringProperty(a, "command")?.slice(0, 80);
699
716
  return { toolName, title: cmd || toolName, cacheKey: toolName };
700
717
  }
@@ -848,6 +865,7 @@ export class AgentSession {
848
865
 
849
866
  // Retry state
850
867
  #retryAbortController: AbortController | undefined = undefined;
868
+ #retryNowRequested = false;
851
869
  #retryAttempt = 0;
852
870
  #retryPromise: Promise<void> | undefined = undefined;
853
871
  #retryResolve: (() => void) | undefined = undefined;
@@ -1497,7 +1515,13 @@ export class AgentSession {
1497
1515
  */
1498
1516
  #cancelOwnAsyncJobs(): void {
1499
1517
  if (!this.#agentId) return;
1500
- AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
1518
+ const manager = AsyncJobManager.instance();
1519
+ if (!manager) return;
1520
+ // Run owner cleanups first so cron timers (and any other owner-scoped
1521
+ // resource cleanup) cannot register fresh jobs while we tear down the
1522
+ // existing ones. Cleanup callbacks are error-isolated inside the manager.
1523
+ manager.runOwnerCleanups({ ownerId: this.#agentId });
1524
+ manager.cancelAll({ ownerId: this.#agentId });
1501
1525
  }
1502
1526
 
1503
1527
  // =========================================================================
@@ -1877,6 +1901,15 @@ export class AgentSession {
1877
1901
  attempt: this.#retryAttempt,
1878
1902
  });
1879
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();
1880
1913
  }
1881
1914
  }
1882
1915
 
@@ -1991,6 +2024,18 @@ export class AgentSession {
1991
2024
  const didRetry = await this.#handleRetryableError(msg);
1992
2025
  if (didRetry) return; // Retry was initiated, don't proceed to compaction
1993
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
+ }
1994
2039
  this.#resolveRetry();
1995
2040
 
1996
2041
  const compactionTask = this.#checkCompaction(msg);
@@ -2861,6 +2906,7 @@ export class AgentSession {
2861
2906
  maxAttempts: event.maxAttempts,
2862
2907
  delayMs: event.delayMs,
2863
2908
  errorMessage: event.errorMessage,
2909
+ unbounded: event.unbounded,
2864
2910
  });
2865
2911
  } else if (event.type === "auto_retry_end") {
2866
2912
  await this.#extensionRunner.emit({
@@ -3395,8 +3441,9 @@ export class AgentSession {
3395
3441
  if (!permissionIntent) {
3396
3442
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3397
3443
  }
3444
+ const isShellExecutionTool = isShellExecutionPermissionTool(target.name);
3398
3445
  const command =
3399
- target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
3446
+ isShellExecutionTool && args && typeof args === "object" && !Array.isArray(args)
3400
3447
  ? getStringProperty(args as Record<string, unknown>, "command")
3401
3448
  : undefined;
3402
3449
  const commandContent = command
@@ -3426,7 +3473,7 @@ export class AgentSession {
3426
3473
  toolCallId,
3427
3474
  toolName: target.name,
3428
3475
  title: permissionIntent.title,
3429
- ...(target.name === "bash" ? { kind: "execute" } : {}),
3476
+ ...(isShellExecutionTool ? { kind: "execute" } : {}),
3430
3477
  status: "pending",
3431
3478
  rawInput: args,
3432
3479
  ...(commandContent ? { content: commandContent } : {}),
@@ -3474,7 +3521,7 @@ export class AgentSession {
3474
3521
  * prompts or tool execution can run.
3475
3522
  */
3476
3523
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3477
- if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
3524
+ if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3478
3525
  return new Proxy(tool, {
3479
3526
  get: (target, prop) => {
3480
3527
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -5016,7 +5063,18 @@ export class AgentSession {
5016
5063
  /**
5017
5064
  * Abort current operation and wait for agent to become idle.
5018
5065
  */
5019
- 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> {
5020
5078
  this.abortRetry();
5021
5079
  this.#promptGeneration++;
5022
5080
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -5063,6 +5121,18 @@ export class AgentSession {
5063
5121
  if (this.#toolChoiceQueue.hasInFlight) {
5064
5122
  this.#toolChoiceQueue.reject("aborted");
5065
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
+ }
5066
5136
  }
5067
5137
 
5068
5138
  /**
@@ -5920,7 +5990,14 @@ export class AgentSession {
5920
5990
  if (artifactsDir) {
5921
5991
  const handoffFilePath = path.join(artifactsDir, createHandoffFileName());
5922
5992
  try {
5923
- 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
+ }
5924
6001
  savedPath = handoffFilePath;
5925
6002
  } catch (error) {
5926
6003
  logger.warn("Failed to save handoff document to disk", {
@@ -7110,19 +7187,14 @@ export class AgentSession {
7110
7187
  // =========================================================================
7111
7188
 
7112
7189
  /**
7113
- * Check if an error is retryable (transient errors or usage limits).
7114
- * Context overflow errors are NOT retryable (handled by compaction instead).
7115
- * 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.
7116
7194
  */
7117
7195
  #isRetryableError(message: AssistantMessage): boolean {
7118
- if (message.stopReason !== "error" || !message.errorMessage) return false;
7119
-
7120
- // Context overflow is handled by compaction, not retry
7121
- const contextWindow = this.model?.contextWindow ?? 0;
7122
- if (isContextOverflow(message, contextWindow)) return false;
7123
-
7124
- const err = message.errorMessage;
7125
- return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
7196
+ const classification = this.#classifyErrorForRetry(message);
7197
+ return classification === "usage_limit" || classification === "transient" || classification === "unknown";
7126
7198
  }
7127
7199
 
7128
7200
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -7148,6 +7220,63 @@ export class AgentSession {
7148
7220
  );
7149
7221
  }
7150
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
+
7151
7280
  #getRetryFallbackChains(): RetryFallbackChains {
7152
7281
  const configuredChains = this.settings.get("retry.fallbackChains");
7153
7282
  if (!configuredChains || typeof configuredChains !== "object") return {};
@@ -7417,6 +7546,8 @@ export class AgentSession {
7417
7546
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
7418
7547
  const retrySettings = this.settings.getGroup("retry");
7419
7548
  if (!retrySettings.enabled) return false;
7549
+ const retryClassification = this.#classifyErrorForRetry(message);
7550
+ const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
7420
7551
 
7421
7552
  const generation = this.#promptGeneration;
7422
7553
  this.#retryAttempt++;
@@ -7429,7 +7560,7 @@ export class AgentSession {
7429
7560
  this.#retryResolve = resolve;
7430
7561
  }
7431
7562
 
7432
- if (this.#retryAttempt > retrySettings.maxRetries) {
7563
+ if (!unboundedClass && this.#retryAttempt > retrySettings.maxRetries) {
7433
7564
  // Max retries exceeded, emit final failure and reset
7434
7565
  await this.#emitSessionEvent({
7435
7566
  type: "auto_retry_end",
@@ -7486,7 +7617,16 @@ export class AgentSession {
7486
7617
  // assistant error message is preserved in agent state so the caller
7487
7618
  // can act on it.
7488
7619
  const maxDelayMs = retrySettings.maxDelayMs;
7489
- 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) {
7490
7630
  const attempt = this.#retryAttempt;
7491
7631
  this.#retryAttempt = 0;
7492
7632
  await this.#emitSessionEvent({
@@ -7499,12 +7639,22 @@ export class AgentSession {
7499
7639
  return false;
7500
7640
  }
7501
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
+
7502
7651
  await this.#emitSessionEvent({
7503
7652
  type: "auto_retry_start",
7504
7653
  attempt: this.#retryAttempt,
7505
7654
  maxAttempts: retrySettings.maxRetries,
7506
7655
  delayMs,
7507
7656
  errorMessage,
7657
+ unbounded: unboundedClass,
7508
7658
  });
7509
7659
 
7510
7660
  // Remove error message from agent state (keep in session for history)
@@ -7514,34 +7664,49 @@ export class AgentSession {
7514
7664
  }
7515
7665
 
7516
7666
  // Wait with exponential backoff (abortable).
7517
- const retryAbortController = new AbortController();
7518
- this.#retryAbortController?.abort();
7519
- this.#retryAbortController = retryAbortController;
7520
7667
  try {
7521
7668
  await scheduler.wait(delayMs, { signal: retryAbortController.signal });
7522
7669
  } catch {
7523
7670
  if (this.#retryAbortController !== retryAbortController) {
7524
7671
  return false;
7525
7672
  }
7526
- // Aborted during sleep - emit end event so UI can clean up
7527
- const attempt = this.#retryAttempt;
7528
- this.#retryAttempt = 0;
7529
7673
  this.#retryAbortController = undefined;
7530
- await this.#emitSessionEvent({
7531
- type: "auto_retry_end",
7532
- success: false,
7533
- attempt,
7534
- finalError: "Retry cancelled",
7535
- });
7536
- this.#resolveRetry();
7537
- 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
+ }
7538
7691
  }
7539
7692
  if (this.#retryAbortController === retryAbortController) {
7540
7693
  this.#retryAbortController = undefined;
7541
7694
  }
7542
7695
 
7543
7696
  // Retry via continue() outside the agent_end event callback chain.
7544
- 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
+ });
7545
7710
 
7546
7711
  return true;
7547
7712
  }
@@ -7550,8 +7715,41 @@ export class AgentSession {
7550
7715
  * Cancel in-progress retry.
7551
7716
  */
7552
7717
  abortRetry(): void {
7718
+ this.#retryNowRequested = false;
7553
7719
  this.#retryAbortController?.abort();
7554
- // 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
+ });
7555
7753
  this.#resolveRetry();
7556
7754
  }
7557
7755
 
@@ -8268,6 +8466,8 @@ export class AgentSession {
8268
8466
  const previousFallbackSelectedMCPToolNames = previousSessionFile
8269
8467
  ? this.#getSessionDefaultSelectedMCPToolNames(previousSessionFile)
8270
8468
  : undefined;
8469
+ const previousAgentSteeringQueue = this.agent.snapshotSteering();
8470
+ const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
8271
8471
 
8272
8472
  this.#steeringMessages = [];
8273
8473
  this.#followUpMessages = [];
@@ -8286,6 +8486,12 @@ export class AgentSession {
8286
8486
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
8287
8487
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
8288
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
+
8289
8495
  // Emit session_switch event to hooks
8290
8496
  if (this.#extensionRunner) {
8291
8497
  await this.#extensionRunner.emit({
@@ -8380,6 +8586,9 @@ export class AgentSession {
8380
8586
  this.#followUpMessages = previousFollowUpMessages;
8381
8587
  this.#pendingNextTurnMessages = previousPendingNextTurnMessages;
8382
8588
  this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
8589
+ this.agent.clearAllQueues();
8590
+ this.agent.restoreSteering(previousAgentSteeringQueue);
8591
+ this.agent.restoreFollowUp(previousAgentFollowUpQueue);
8383
8592
  if (previousModel) {
8384
8593
  this.agent.setModel(previousModel);
8385
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
  /**
@@ -58,6 +58,17 @@ export interface OutputSinkOptions {
58
58
  onChunk?: (chunk: string) => void;
59
59
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
60
60
  chunkThrottleMs?: number;
61
+ /**
62
+ * Unthrottled per-chunk callback fired *after* sanitization but *before*
63
+ * any throttle gating, column capping, or head/tail bookkeeping. Used by
64
+ * background-job substrate to record the complete process stream for the
65
+ * Monitor tool while keeping `onChunk` cheap for UI/progress.
66
+ *
67
+ * Receives the sanitized chunk verbatim; never receives the column-capped
68
+ * or minimized text. Implementations must be fast and side-effect-free
69
+ * relative to the sink (the sink does not catch errors from this callback).
70
+ */
71
+ onRawChunk?: (chunk: string) => void;
61
72
  }
62
73
 
63
74
  export interface TruncationResult {
@@ -672,6 +683,7 @@ export class OutputSink {
672
683
  readonly #spillThreshold: number;
673
684
  readonly #headLimit: number;
674
685
  readonly #onChunk?: (chunk: string) => void;
686
+ readonly #onRawChunk?: (chunk: string) => void;
675
687
  readonly #chunkThrottleMs: number;
676
688
  readonly #maxColumns: number;
677
689
 
@@ -684,6 +696,7 @@ export class OutputSink {
684
696
  maxColumns = 0,
685
697
  onChunk,
686
698
  chunkThrottleMs = 0,
699
+ onRawChunk,
687
700
  } = options ?? {};
688
701
  this.#artifactPath = artifactPath;
689
702
  this.#artifactId = artifactId;
@@ -691,6 +704,7 @@ export class OutputSink {
691
704
  this.#headLimit = Math.max(0, headBytes);
692
705
  this.#maxColumns = Math.max(0, maxColumns);
693
706
  this.#onChunk = onChunk;
707
+ this.#onRawChunk = onRawChunk;
694
708
  this.#chunkThrottleMs = chunkThrottleMs;
695
709
  }
696
710
 
@@ -701,6 +715,13 @@ export class OutputSink {
701
715
  push(chunk: string): void {
702
716
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
703
717
 
718
+ // Unthrottled raw-chunk hook fires before any throttle/cap gating so
719
+ // downstream consumers (e.g. AsyncJobManager.appendOutput) can record
720
+ // the complete process stream while UI/progress callbacks remain throttled.
721
+ if (this.#onRawChunk && chunk.length > 0) {
722
+ this.#onRawChunk(chunk);
723
+ }
724
+
704
725
  // Throttled onChunk: only call the callback when enough time has passed.
705
726
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
706
727
  // what reached the sink — the column cap is for the persisted LLM view.