@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.2.4",
4
+ "version": "0.3.0",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -48,12 +48,12 @@
48
48
  "@agentclientprotocol/sdk": "0.21.0",
49
49
  "@babel/parser": "^7.29.3",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "@gajae-code/stats": "0.2.4",
52
- "@gajae-code/agent-core": "0.2.4",
53
- "@gajae-code/ai": "0.2.4",
54
- "@gajae-code/natives": "0.2.4",
55
- "@gajae-code/tui": "0.2.4",
56
- "@gajae-code/utils": "0.2.4",
51
+ "@gajae-code/stats": "0.3.0",
52
+ "@gajae-code/agent-core": "0.3.0",
53
+ "@gajae-code/ai": "0.3.0",
54
+ "@gajae-code/natives": "0.3.0",
55
+ "@gajae-code/tui": "0.3.0",
56
+ "@gajae-code/utils": "0.3.0",
57
57
  "@puppeteer/browsers": "^2.13.0",
58
58
  "@types/turndown": "5.0.6",
59
59
  "@xterm/headless": "^6.0.0",
@@ -10,7 +10,7 @@ const DEFAULT_MAX_RUNNING_JOBS = 15;
10
10
  export interface AsyncJob {
11
11
  id: string;
12
12
  type: "bash" | "task";
13
- status: "running" | "completed" | "failed" | "cancelled";
13
+ status: "running" | "completed" | "failed" | "cancelled" | "paused";
14
14
  startTime: number;
15
15
  label: string;
16
16
  abortController: AbortController;
@@ -37,6 +37,64 @@ export interface AsyncJobMetadata {
37
37
  };
38
38
  }
39
39
 
40
+ /**
41
+ * Typed outcome a subagent task run may produce. A `paused` outcome is
42
+ * non-terminal and non-delivering: the run suspended at a safe boundary and the
43
+ * subagent can be resumed from its persisted sessionFile. `completed` always
44
+ * wins a race with a late pause because the run returns it once it has actually
45
+ * finished.
46
+ */
47
+ export type SubagentRunOutcome = { kind: "completed"; text: string } | { kind: "paused"; note?: string };
48
+
49
+ /** Canonical lifecycle of a subagent across pause/resume cycles. */
50
+ export type SubagentLifecycle = "running" | "paused" | "queued" | "completed" | "failed" | "cancelled";
51
+
52
+ /**
53
+ * Live, executor-owned control handle for a RUNNING subagent. Registered when a
54
+ * subagent run starts and removed on pause/terminal so a paused subagent retains
55
+ * no live `AgentSession` reference (leak-free).
56
+ */
57
+ export interface SubagentLiveHandle {
58
+ /** Request a cooperative safe-boundary pause (never aborts the in-flight tool). */
59
+ requestPause(): void;
60
+ /** Inject a steering message into the live session. */
61
+ injectMessage(content: string, deliverAs: "steer" | "followUp" | "nextTurn"): Promise<void>;
62
+ }
63
+
64
+ /**
65
+ * Canonical, stable-id-keyed record for a subagent. Survives `AsyncJob`
66
+ * eviction so resume stays addressable by subagent id, and is the single source
67
+ * of truth for control-plane status and identity.
68
+ */
69
+ export interface SubagentRecord {
70
+ subagentId: string;
71
+ ownerId?: string;
72
+ /** Current live/last AsyncJob id; null while queued with no active job. */
73
+ currentJobId: string | null;
74
+ historicalJobIds: string[];
75
+ status: SubagentLifecycle;
76
+ sessionFile: string | null;
77
+ /** False for ephemeral sessions (no persistent artifacts dir). */
78
+ resumable: boolean;
79
+ queued?: { ownerId?: string; seq: number; message?: string; createdAt: number };
80
+ }
81
+
82
+ /** Lightweight, manager-owned resume payload. The async layer treats `data` as opaque. */
83
+ export interface ResumeDescriptor {
84
+ subagentId: string;
85
+ ownerId?: string;
86
+ data: unknown;
87
+ }
88
+
89
+ /** A pending resume awaiting a free concurrency slot. */
90
+ interface ResumeQueueEntry {
91
+ subagentId: string;
92
+ ownerId?: string;
93
+ seq: number;
94
+ message?: string;
95
+ createdAt: number;
96
+ }
97
+
40
98
  export interface AsyncJobManagerOptions {
41
99
  onJobComplete: (jobId: string, text: string, job?: AsyncJob) => void | Promise<void>;
42
100
  maxRunningJobs?: number;
@@ -78,6 +136,56 @@ export interface AsyncJobFilter {
78
136
  ownerId?: string;
79
137
  }
80
138
 
139
+ function sliceTextFromUtf8ByteOffset(text: string, offsetBytes: number): string {
140
+ if (offsetBytes <= 0) return text;
141
+ let consumedBytes = 0;
142
+ let codeUnitIndex = 0;
143
+ for (const char of text) {
144
+ const charBytes = Buffer.byteLength(char, "utf8");
145
+ if (consumedBytes + charBytes > offsetBytes) break;
146
+ consumedBytes += charBytes;
147
+ codeUnitIndex += char.length;
148
+ }
149
+ return text.slice(codeUnitIndex);
150
+ }
151
+
152
+ /**
153
+ * A slice of process-stream output for a background job, as recorded by
154
+ * `appendOutput` / read by `readOutputSince`.
155
+ *
156
+ * The cursor model is monotonic UTF-8 byte offsets. `nextOffset` is the offset
157
+ * to pass to the next read to receive only fresh bytes; `startOffset` is the
158
+ * first byte the manager still retains for this job. When the requested offset
159
+ * is older than `startOffset`, the manager returns the retained tail and sets
160
+ * `truncated: true`.
161
+ */
162
+ export interface AsyncJobOutputSlice {
163
+ jobId: string;
164
+ status: AsyncJob["status"];
165
+ text: string;
166
+ startOffset: number;
167
+ nextOffset: number;
168
+ truncated: boolean;
169
+ }
170
+
171
+ /** Internal: a single chunk of captured stdout/stderr keyed by its byte range. */
172
+ interface AsyncJobOutputChunk {
173
+ startByte: number;
174
+ endByte: number;
175
+ text: string;
176
+ }
177
+
178
+ interface AsyncJobOutputState {
179
+ chunks: AsyncJobOutputChunk[];
180
+ startOffset: number;
181
+ nextOffset: number;
182
+ retainedBytes: number;
183
+ }
184
+
185
+ /** Default retention cap for per-job captured output. ~512 KiB matches the
186
+ * bash tail-buffer order of magnitude without dominating session memory. */
187
+ export const DEFAULT_JOB_OUTPUT_RETENTION_BYTES = 512 * 1024;
188
+
81
189
  export class AsyncJobManager {
82
190
  static #instance: AsyncJobManager | undefined;
83
191
 
@@ -102,11 +210,20 @@ export class AsyncJobManager {
102
210
  readonly #suppressedDeliveries = new Set<string>();
103
211
  readonly #watchedJobs = new Set<string>();
104
212
  readonly #evictionTimers = new Map<string, NodeJS.Timeout>();
213
+ readonly #outputState = new Map<string, AsyncJobOutputState>();
214
+ readonly #ownerCleanups = new Map<string, Set<() => void>>();
215
+ readonly #outputRetentionBytes = DEFAULT_JOB_OUTPUT_RETENTION_BYTES;
105
216
  readonly #onJobComplete: AsyncJobManagerOptions["onJobComplete"];
106
217
  readonly #maxRunningJobs: number;
107
218
  readonly #retentionMs: number;
108
219
  #deliveryLoop: Promise<void> | undefined;
109
220
  #disposed = false;
221
+ readonly #subagentRecords = new Map<string, SubagentRecord>();
222
+ readonly #liveHandles = new Map<string, SubagentLiveHandle>();
223
+ readonly #resumeQueue: ResumeQueueEntry[] = [];
224
+ #resumeSeq = 0;
225
+ #resumeRunner?: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined;
226
+ readonly #resumeDescriptors = new Map<string, ResumeDescriptor>();
110
227
 
111
228
  #filterJobs(jobs: Iterable<AsyncJob>, filter?: AsyncJobFilter): AsyncJob[] {
112
229
  const ownerId = filter?.ownerId;
@@ -131,7 +248,7 @@ export class AsyncJobManager {
131
248
  jobId: string;
132
249
  signal: AbortSignal;
133
250
  reportProgress: (text: string, details?: Record<string, unknown>) => Promise<void>;
134
- }) => Promise<string>,
251
+ }) => Promise<string | SubagentRunOutcome>,
135
252
  options?: AsyncJobRegisterOptions,
136
253
  ): string {
137
254
  if (this.#disposed) {
@@ -174,20 +291,38 @@ export class AsyncJobManager {
174
291
  };
175
292
  job.promise = (async () => {
176
293
  try {
177
- const text = await run({ jobId: id, signal: abortController.signal, reportProgress });
294
+ const result = await run({ jobId: id, signal: abortController.signal, reportProgress });
295
+ const outcome: SubagentRunOutcome =
296
+ typeof result === "string" ? { kind: "completed", text: result } : result;
178
297
  if (job.status === "cancelled") {
179
- job.resultText = text;
298
+ job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
180
299
  this.#scheduleEviction(id);
300
+ this.#markRecordTerminal(id, "cancelled");
301
+ this.#drainResumeQueue();
302
+ return;
303
+ }
304
+ if (outcome.kind === "paused") {
305
+ // Sole canonical writer of the running -> paused transition. No
306
+ // delivery and no eviction scheduling: a paused subagent stays
307
+ // listed and resumable from its sessionFile.
308
+ job.status = "paused";
309
+ if (outcome.note) job.resultText = outcome.note;
310
+ this.#markRecordPaused(id);
311
+ this.#drainResumeQueue();
181
312
  return;
182
313
  }
183
314
  job.status = "completed";
184
- job.resultText = text;
185
- this.#enqueueDelivery(id, text);
315
+ job.resultText = outcome.text;
316
+ this.#enqueueDelivery(id, outcome.text);
186
317
  this.#scheduleEviction(id);
318
+ this.#markRecordTerminal(id, "completed");
319
+ this.#drainResumeQueue();
187
320
  } catch (error) {
188
321
  if (job.status === "cancelled") {
189
322
  job.errorText = error instanceof Error ? error.message : String(error);
190
323
  this.#scheduleEviction(id);
324
+ this.#markRecordTerminal(id, "cancelled");
325
+ this.#drainResumeQueue();
191
326
  return;
192
327
  }
193
328
  const errorText = error instanceof Error ? error.message : String(error);
@@ -195,6 +330,8 @@ export class AsyncJobManager {
195
330
  job.errorText = errorText;
196
331
  this.#enqueueDelivery(id, errorText);
197
332
  this.#scheduleEviction(id);
333
+ this.#markRecordTerminal(id, "failed");
334
+ this.#drainResumeQueue();
198
335
  }
199
336
  })();
200
337
 
@@ -211,6 +348,15 @@ export class AsyncJobManager {
211
348
  const job = this.#jobs.get(id);
212
349
  if (!job) return false;
213
350
  if (filter?.ownerId && job.ownerId !== filter.ownerId) return false;
351
+ if (job.status === "paused") {
352
+ // Paused jobs have no running promise to abort; transition directly.
353
+ // The session file is kept, so the record stays resumable by id.
354
+ job.status = "cancelled";
355
+ this.#markRecordTerminal(id, "cancelled");
356
+ this.#scheduleEviction(id);
357
+ this.#drainResumeQueue();
358
+ return true;
359
+ }
214
360
  if (job.status !== "running") return false;
215
361
  job.status = "cancelled";
216
362
  job.abortController.abort();
@@ -218,6 +364,200 @@ export class AsyncJobManager {
218
364
  return true;
219
365
  }
220
366
 
367
+ // ── Subagent control plane (pause / resume / steer support) ──────────
368
+
369
+ /** Register or replace the canonical record for a subagent. */
370
+ registerSubagentRecord(record: SubagentRecord): void {
371
+ this.#subagentRecords.set(record.subagentId, record);
372
+ }
373
+
374
+ getSubagentRecord(subagentId: string, filter?: AsyncJobFilter): SubagentRecord | undefined {
375
+ const rec = this.#subagentRecords.get(subagentId.trim());
376
+ if (!rec) return undefined;
377
+ if (filter?.ownerId && rec.ownerId !== filter.ownerId) return undefined;
378
+ return rec;
379
+ }
380
+
381
+ getSubagentRecords(filter?: AsyncJobFilter): SubagentRecord[] {
382
+ const ownerId = filter?.ownerId;
383
+ const out: SubagentRecord[] = [];
384
+ for (const rec of this.#subagentRecords.values()) {
385
+ if (ownerId && rec.ownerId !== ownerId) continue;
386
+ out.push(rec);
387
+ }
388
+ return out;
389
+ }
390
+
391
+ registerLiveHandle(subagentId: string, handle: SubagentLiveHandle): void {
392
+ this.#liveHandles.set(subagentId, handle);
393
+ }
394
+
395
+ getLiveHandle(subagentId: string): SubagentLiveHandle | undefined {
396
+ return this.#liveHandles.get(subagentId);
397
+ }
398
+
399
+ removeLiveHandle(subagentId: string): void {
400
+ this.#liveHandles.delete(subagentId);
401
+ }
402
+
403
+ /** Install the TaskTool-owned resume runner. Returns the new job id, or undefined on failure. */
404
+ setResumeRunner(
405
+ runner: (subagentId: string, message?: string, descriptor?: ResumeDescriptor) => string | undefined,
406
+ ): void {
407
+ this.#resumeRunner = runner;
408
+ }
409
+
410
+ registerResumeDescriptor(descriptor: ResumeDescriptor): void {
411
+ this.#resumeDescriptors.set(descriptor.subagentId, descriptor);
412
+ }
413
+
414
+ getResumeDescriptor(subagentId: string, filter?: AsyncJobFilter): ResumeDescriptor | undefined {
415
+ const descriptor = this.#resumeDescriptors.get(subagentId.trim());
416
+ if (!descriptor) return undefined;
417
+ if (filter?.ownerId && descriptor.ownerId !== filter.ownerId) return undefined;
418
+ return descriptor;
419
+ }
420
+
421
+ #recordByJobId(jobId: string): SubagentRecord | undefined {
422
+ for (const rec of this.#subagentRecords.values()) {
423
+ if (rec.currentJobId === jobId) return rec;
424
+ }
425
+ return undefined;
426
+ }
427
+
428
+ #markRecordPaused(jobId: string): void {
429
+ const rec = this.#recordByJobId(jobId);
430
+ if (rec) {
431
+ rec.status = "paused";
432
+ this.#liveHandles.delete(rec.subagentId);
433
+ }
434
+ }
435
+
436
+ #markRecordTerminal(jobId: string, status: "completed" | "failed" | "cancelled"): void {
437
+ const rec = this.#recordByJobId(jobId);
438
+ if (!rec) return;
439
+ rec.status = status;
440
+ this.#liveHandles.delete(rec.subagentId);
441
+ }
442
+
443
+ /** Request a graceful safe-boundary pause of a running subagent. */
444
+ pauseSubagent(
445
+ subagentId: string,
446
+ filter?: AsyncJobFilter,
447
+ ): { ok: boolean; status?: SubagentLifecycle; reason?: string } {
448
+ const rec = this.getSubagentRecord(subagentId, filter);
449
+ if (!rec) return { ok: false, reason: "not_found" };
450
+ if (rec.status !== "running") return { ok: false, status: rec.status, reason: "not_running" };
451
+ const handle = this.#liveHandles.get(rec.subagentId);
452
+ if (!handle) return { ok: false, status: rec.status, reason: "no_live_handle" };
453
+ handle.requestPause();
454
+ return { ok: true, status: rec.status };
455
+ }
456
+
457
+ /** Resume a non-running subagent from its sessionFile, optionally injecting a message first. */
458
+ resumeSubagent(
459
+ subagentId: string,
460
+ filter?: AsyncJobFilter,
461
+ message?: string,
462
+ ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; queued?: boolean; reason?: string } {
463
+ const rec = this.getSubagentRecord(subagentId, filter);
464
+ if (!rec) return { ok: false, reason: "not_found" };
465
+ if (rec.status === "running") return { ok: false, status: "running", reason: "already_running" };
466
+ if (rec.status === "queued") {
467
+ if (message !== undefined && rec.queued) {
468
+ rec.queued.message = message;
469
+ const queued = this.#resumeQueue.find(entry => entry.subagentId === rec.subagentId);
470
+ if (queued) queued.message = message;
471
+ return { ok: true, queued: true, status: "queued" };
472
+ }
473
+ return { ok: false, status: "queued", reason: "already_queued" };
474
+ }
475
+ if (!rec.resumable || !rec.sessionFile) return { ok: false, reason: "context_unavailable" };
476
+ if (!this.#resumeRunner) return { ok: false, reason: "no_runner" };
477
+ if (this.getRunningJobs().length >= this.#maxRunningJobs) {
478
+ const seq = ++this.#resumeSeq;
479
+ rec.status = "queued";
480
+ rec.queued = { ownerId: rec.ownerId, seq, message, createdAt: Date.now() };
481
+ this.#resumeQueue.push({
482
+ subagentId: rec.subagentId,
483
+ ownerId: rec.ownerId,
484
+ seq,
485
+ message,
486
+ createdAt: rec.queued.createdAt,
487
+ });
488
+ return { ok: true, queued: true, status: "queued" };
489
+ }
490
+ return this.#startResume(rec, message);
491
+ }
492
+
493
+ #startResume(
494
+ rec: SubagentRecord,
495
+ message?: string,
496
+ ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; reason?: string } {
497
+ const prevJobId = rec.currentJobId;
498
+ const newJobId = this.#resumeRunner?.(rec.subagentId, message, this.#resumeDescriptors.get(rec.subagentId));
499
+ if (!newJobId) return { ok: false, reason: "resume_failed" };
500
+ if (prevJobId && prevJobId !== newJobId) rec.historicalJobIds.push(prevJobId);
501
+ rec.currentJobId = newJobId;
502
+ rec.status = this.#jobs.get(newJobId)?.status ?? "running";
503
+ rec.queued = undefined;
504
+ return { ok: true, status: rec.status, jobId: newJobId };
505
+ }
506
+
507
+ /** Drain queued resumes (FIFO by seq) while concurrency slots are available. */
508
+ #drainResumeQueue(): void {
509
+ if (this.#resumeQueue.length === 0) return;
510
+ this.#resumeQueue.sort((a, b) => a.seq - b.seq);
511
+ while (this.#resumeQueue.length > 0 && this.getRunningJobs().length < this.#maxRunningJobs) {
512
+ const entry = this.#resumeQueue.shift();
513
+ if (!entry) return;
514
+ const rec = this.#subagentRecords.get(entry.subagentId);
515
+ if (rec?.status !== "queued") continue;
516
+ this.#startResume(rec, entry.message);
517
+ }
518
+ }
519
+
520
+ /** Cancel a subagent by stable id across running/paused/queued states (keeps the session file). */
521
+ cancelSubagent(subagentId: string, filter?: AsyncJobFilter): boolean {
522
+ const rec = this.getSubagentRecord(subagentId, filter);
523
+ if (!rec) return false;
524
+ if (rec.status === "running" && rec.currentJobId) return this.cancel(rec.currentJobId, filter);
525
+ if (rec.status === "paused") {
526
+ if (rec.currentJobId) {
527
+ const job = this.#jobs.get(rec.currentJobId);
528
+ if (job && job.status === "paused") {
529
+ job.status = "cancelled";
530
+ this.#scheduleEviction(rec.currentJobId);
531
+ }
532
+ }
533
+ rec.status = "cancelled";
534
+ this.#liveHandles.delete(rec.subagentId);
535
+ this.#drainResumeQueue();
536
+ return true;
537
+ }
538
+ if (rec.status === "queued") {
539
+ const idx = this.#resumeQueue.findIndex(e => e.subagentId === rec.subagentId);
540
+ if (idx !== -1) this.#resumeQueue.splice(idx, 1);
541
+ rec.status = "cancelled";
542
+ rec.queued = undefined;
543
+ return true;
544
+ }
545
+ return false;
546
+ }
547
+
548
+ #purgeOwnerSubagentState(ownerId?: string): void {
549
+ for (let i = this.#resumeQueue.length - 1; i >= 0; i--) {
550
+ if (!ownerId || this.#resumeQueue[i].ownerId === ownerId) this.#resumeQueue.splice(i, 1);
551
+ }
552
+ for (const [sid, rec] of this.#subagentRecords) {
553
+ if (!ownerId || rec.ownerId === ownerId) {
554
+ this.#liveHandles.delete(sid);
555
+ this.#resumeDescriptors.delete(sid);
556
+ this.#subagentRecords.delete(sid);
557
+ }
558
+ }
559
+ }
560
+
221
561
  getJob(id: string): AsyncJob | undefined {
222
562
  return this.#jobs.get(id);
223
563
  }
@@ -237,6 +577,170 @@ export class AsyncJobManager {
237
577
  return this.#filterJobs(this.#jobs.values(), filter);
238
578
  }
239
579
 
580
+ /**
581
+ * Append a sanitized process-stream chunk for a background job. Called from
582
+ * the unthrottled bash-executor capture hook (`onRawChunk`) so monitor sees
583
+ * every chunk even when preview/progress callbacks are throttled.
584
+ *
585
+ * Offsets are in UTF-8 bytes. Storing chunk metadata avoids unsafe byte
586
+ * slicing across multibyte characters at read time. The retention window is
587
+ * a per-job rolling cap (`DEFAULT_JOB_OUTPUT_RETENTION_BYTES`); when it
588
+ * overflows, oldest whole chunks are evicted and `startOffset` advances —
589
+ * subsequent reads from a stale offset get `truncated: true`.
590
+ */
591
+ appendOutput(jobId: string, chunk: string): void {
592
+ if (this.#disposed) return;
593
+ if (!chunk) return;
594
+ if (!this.#jobs.has(jobId)) return;
595
+
596
+ const state = this.#outputState.get(jobId) ?? {
597
+ chunks: [],
598
+ startOffset: 0,
599
+ nextOffset: 0,
600
+ retainedBytes: 0,
601
+ };
602
+
603
+ const byteLength = Buffer.byteLength(chunk, "utf8");
604
+ if (byteLength === 0) return;
605
+
606
+ const startByte = state.nextOffset;
607
+ const endByte = startByte + byteLength;
608
+ state.chunks.push({ startByte, endByte, text: chunk });
609
+ state.retainedBytes += byteLength;
610
+ state.nextOffset = endByte;
611
+
612
+ while (state.retainedBytes > this.#outputRetentionBytes && state.chunks.length > 0) {
613
+ const dropped = state.chunks.shift();
614
+ if (!dropped) break;
615
+ const droppedBytes = dropped.endByte - dropped.startByte;
616
+ state.retainedBytes -= droppedBytes;
617
+ state.startOffset = dropped.endByte;
618
+ }
619
+
620
+ this.#outputState.set(jobId, state);
621
+ }
622
+
623
+ /**
624
+ * Read fresh process-stream output for a job since `offset` (in UTF-8
625
+ * bytes). Returns `undefined` when the job does not exist or when an
626
+ * `ownerId` filter is set and the job belongs to a different owner — this
627
+ * mirrors the manager-level "not found" pattern used by `cancel`.
628
+ *
629
+ * - `offset < startOffset` returns the retained tail with `truncated: true`.
630
+ * - `offset > nextOffset` clamps to `nextOffset` and returns an empty text
631
+ * slice with `truncated: false`.
632
+ * - Assembled text slices the leading retained chunk at a UTF-8 codepoint
633
+ * boundary when needed, so multibyte characters cannot be split.
634
+ */
635
+ readOutputSince(jobId: string, offset: number, filter?: AsyncJobFilter): AsyncJobOutputSlice | undefined {
636
+ const job = this.#jobs.get(jobId);
637
+ if (!job) return undefined;
638
+ if (filter?.ownerId && job.ownerId !== filter.ownerId) return undefined;
639
+
640
+ const state = this.#outputState.get(jobId);
641
+ if (!state) {
642
+ return {
643
+ jobId,
644
+ status: job.status,
645
+ text: "",
646
+ startOffset: 0,
647
+ nextOffset: 0,
648
+ truncated: false,
649
+ };
650
+ }
651
+
652
+ const requestedOffset = Math.max(0, Math.floor(offset));
653
+ if (requestedOffset >= state.nextOffset) {
654
+ return {
655
+ jobId,
656
+ status: job.status,
657
+ text: "",
658
+ startOffset: state.startOffset,
659
+ nextOffset: state.nextOffset,
660
+ truncated: false,
661
+ };
662
+ }
663
+
664
+ const truncated = requestedOffset < state.startOffset;
665
+ const effectiveOffset = truncated ? state.startOffset : requestedOffset;
666
+ const parts: string[] = [];
667
+ for (const chunk of state.chunks) {
668
+ if (chunk.endByte <= effectiveOffset) continue;
669
+ if (effectiveOffset > chunk.startByte) {
670
+ parts.push(sliceTextFromUtf8ByteOffset(chunk.text, effectiveOffset - chunk.startByte));
671
+ continue;
672
+ }
673
+ parts.push(chunk.text);
674
+ }
675
+
676
+ return {
677
+ jobId,
678
+ status: job.status,
679
+ text: parts.join(""),
680
+ startOffset: state.startOffset,
681
+ nextOffset: state.nextOffset,
682
+ truncated,
683
+ };
684
+ }
685
+
686
+ /**
687
+ * Register an owner-scoped cleanup callback. Returns an unregister function.
688
+ *
689
+ * Used by Cron* tools to clear session-scoped timers when the owning agent
690
+ * is torn down. Invoked by `runOwnerCleanups({ ownerId })` before
691
+ * `cancelAll({ ownerId })` so timers cannot register new jobs during
692
+ * teardown.
693
+ */
694
+ registerOwnerCleanup(ownerId: string, cleanup: () => void): () => void {
695
+ if (!ownerId) {
696
+ throw new Error("registerOwnerCleanup requires a non-empty ownerId");
697
+ }
698
+ let bag = this.#ownerCleanups.get(ownerId);
699
+ if (!bag) {
700
+ bag = new Set();
701
+ this.#ownerCleanups.set(ownerId, bag);
702
+ }
703
+ bag.add(cleanup);
704
+ return () => {
705
+ const current = this.#ownerCleanups.get(ownerId);
706
+ if (!current) return;
707
+ current.delete(cleanup);
708
+ if (current.size === 0) this.#ownerCleanups.delete(ownerId);
709
+ };
710
+ }
711
+
712
+ /**
713
+ * Run and clear every registered cleanup for the given filter. Idempotent
714
+ * and error-isolated: a throwing cleanup does not prevent siblings from
715
+ * running and never escalates to the caller.
716
+ */
717
+ runOwnerCleanups(filter?: AsyncJobFilter): void {
718
+ const ownerId = filter?.ownerId;
719
+ const targets: Array<[string, Set<() => void>]> = [];
720
+ if (ownerId) {
721
+ const bag = this.#ownerCleanups.get(ownerId);
722
+ if (bag) targets.push([ownerId, bag]);
723
+ } else {
724
+ for (const entry of this.#ownerCleanups.entries()) targets.push(entry);
725
+ }
726
+ for (const [id, bag] of targets) {
727
+ const callbacks = Array.from(bag);
728
+ bag.clear();
729
+ this.#ownerCleanups.delete(id);
730
+ for (const cleanup of callbacks) {
731
+ try {
732
+ cleanup();
733
+ } catch (error) {
734
+ logger.warn("Async job owner cleanup failed", {
735
+ ownerId: id,
736
+ error: error instanceof Error ? error.message : String(error),
737
+ });
738
+ }
739
+ }
740
+ }
741
+ this.#purgeOwnerSubagentState(ownerId);
742
+ }
743
+
240
744
  getDeliveryState(filter?: AsyncJobFilter): AsyncJobDeliveryState {
241
745
  const deliveries = this.#filterDeliveries(filter);
242
746
  const inFlightDeliveries = this.#filterInFlightDeliveries(filter);
@@ -357,6 +861,10 @@ export class AsyncJobManager {
357
861
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
358
862
  this.#disposed = true;
359
863
  this.#clearEvictionTimers();
864
+ // Run-and-clear any remaining owner cleanups before tearing down jobs so
865
+ // late-arriving timers cannot register fresh work against a disposed
866
+ // manager. Errors in cleanup callbacks are logged but never escalated.
867
+ this.runOwnerCleanups();
360
868
  this.cancelAll();
361
869
  await this.waitForAll();
362
870
  const drained = await this.drainDeliveries({ timeoutMs: options?.timeoutMs ?? 3_000 });
@@ -366,6 +874,12 @@ export class AsyncJobManager {
366
874
  this.#inFlightDeliveries.length = 0;
367
875
  this.#suppressedDeliveries.clear();
368
876
  this.#watchedJobs.clear();
877
+ this.#outputState.clear();
878
+ this.#ownerCleanups.clear();
879
+ this.#subagentRecords.clear();
880
+ this.#liveHandles.clear();
881
+ this.#resumeDescriptors.clear();
882
+ this.#resumeQueue.length = 0;
369
883
  return drained;
370
884
  }
371
885
 
@@ -399,6 +913,7 @@ export class AsyncJobManager {
399
913
  this.#jobs.delete(jobId);
400
914
  this.#suppressedDeliveries.delete(jobId);
401
915
  this.#watchedJobs.delete(jobId);
916
+ this.#outputState.delete(jobId);
402
917
  return;
403
918
  }
404
919
  const existing = this.#evictionTimers.get(jobId);
@@ -410,6 +925,7 @@ export class AsyncJobManager {
410
925
  this.#jobs.delete(jobId);
411
926
  this.#suppressedDeliveries.delete(jobId);
412
927
  this.#watchedJobs.delete(jobId);
928
+ this.#outputState.delete(jobId);
413
929
  }, this.#retentionMs);
414
930
  timer.unref();
415
931
  this.#evictionTimers.set(jobId, timer);
@@ -64,6 +64,9 @@ function toFrontmatter(agent: AgentDefinition): Record<string, unknown> {
64
64
  if (agent.thinkingLevel) frontmatter.thinkingLevel = agent.thinkingLevel;
65
65
  if (agent.output !== undefined) frontmatter.output = agent.output;
66
66
  if (agent.blocking) frontmatter.blocking = true;
67
+ if (agent.bashAllowedPrefixes && agent.bashAllowedPrefixes.length > 0) {
68
+ frontmatter.bashAllowedPrefixes = agent.bashAllowedPrefixes;
69
+ }
67
70
 
68
71
  return frontmatter;
69
72
  }
@@ -69,6 +69,7 @@ const CALLBACK_PORTS: Record<string, number> = {
69
69
  "google-gemini-cli": 8085,
70
70
  "google-antigravity": 51121,
71
71
  "gitlab-duo": 8080,
72
+ xai: 56121,
72
73
  };
73
74
 
74
75
  function getTokenFilePath(): string {