@gajae-code/coding-agent 0.18.6 → 0.18.8

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 (302) hide show
  1. package/CHANGELOG.md +318 -1
  2. package/dist/types/async/job-manager.d.ts +1 -1
  3. package/dist/types/capability/mcp.d.ts +2 -0
  4. package/dist/types/capability/skill.d.ts +2 -0
  5. package/dist/types/capability/types.d.ts +6 -0
  6. package/dist/types/cli/public-command-errors.d.ts +3 -1
  7. package/dist/types/cli/setup-cli.d.ts +1 -1
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  10. package/dist/types/config/file-lock.d.ts +1 -0
  11. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  12. package/dist/types/config/model-profile-activation.d.ts +11 -3
  13. package/dist/types/config/model-registry.d.ts +4 -9
  14. package/dist/types/config/settings.d.ts +4 -0
  15. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  16. package/dist/types/cursor-delete-path.d.ts +1 -0
  17. package/dist/types/cursor-mcp-name.d.ts +8 -0
  18. package/dist/types/customization/inventory.d.ts +2 -0
  19. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  20. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  21. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  22. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  23. package/dist/types/discovery/helpers.d.ts +8 -3
  24. package/dist/types/export/html/css-color.d.ts +2 -0
  25. package/dist/types/export/html/template.generated.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  27. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  28. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  29. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  30. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  31. package/dist/types/extensibility/skill-management.d.ts +3 -1
  32. package/dist/types/extensibility/skills.d.ts +2 -0
  33. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  34. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  35. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  36. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  37. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  38. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  39. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  40. package/dist/types/gjc-runtime/state-writer.d.ts +11 -1
  41. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  42. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  43. package/dist/types/lsp/workspace-path.d.ts +1 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  45. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  46. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  47. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  48. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  49. package/dist/types/modes/interactive-mode.d.ts +1 -0
  50. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  51. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  52. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  53. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  54. package/dist/types/progress/progress-contract.d.ts +154 -0
  55. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  56. package/dist/types/progress/project-progress.d.ts +141 -0
  57. package/dist/types/progress/render-progress.d.ts +28 -0
  58. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  59. package/dist/types/runtime-mcp/oauth-public-url.d.ts +1 -0
  60. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  61. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  62. package/dist/types/runtime-mcp/types.d.ts +6 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  64. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  65. package/dist/types/sdk/broker/broker.d.ts +18 -1
  66. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  67. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  68. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  69. package/dist/types/sdk/bus/index.d.ts +12 -1
  70. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  71. package/dist/types/sdk/bus/telegram-daemon.d.ts +3 -2
  72. package/dist/types/sdk/client/client.d.ts +2 -0
  73. package/dist/types/sdk/host/host.d.ts +6 -0
  74. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  75. package/dist/types/sdk/index.d.ts +1 -0
  76. package/dist/types/sdk/router/session-router.d.ts +9 -0
  77. package/dist/types/sdk/session-list.d.ts +1 -1
  78. package/dist/types/sdk/session.d.ts +8 -0
  79. package/dist/types/session/agent-session.d.ts +38 -9
  80. package/dist/types/session/artifacts.d.ts +2 -2
  81. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  82. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  83. package/dist/types/session/internal/managed-session-storage.d.ts +36 -1
  84. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  85. package/dist/types/session/internal/task-artifact-owner-access.d.ts +19 -0
  86. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +11 -0
  87. package/dist/types/session/session-manager.d.ts +31 -5
  88. package/dist/types/session/session-retirement.d.ts +71 -0
  89. package/dist/types/session/session-storage.d.ts +62 -48
  90. package/dist/types/session/session-transcript-header.d.ts +2 -0
  91. package/dist/types/session/task-artifact-owner-codec.d.ts +109 -0
  92. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  93. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  94. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  95. package/dist/types/setup/credential-import.d.ts +6 -4
  96. package/dist/types/skill-state/active-state.d.ts +38 -0
  97. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  98. package/dist/types/task/executor.d.ts +3 -0
  99. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  100. package/dist/types/tools/index.d.ts +3 -0
  101. package/dist/types/tools/python.d.ts +9 -1
  102. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  103. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  104. package/dist/types/tools/todo-write.d.ts +6 -0
  105. package/dist/types/utils/tools-checksum.d.ts +7 -0
  106. package/package.json +7 -7
  107. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  108. package/scripts/resident-memory-bench.ts +174 -20
  109. package/scripts/verify-managed-empty-stop.ts +28 -0
  110. package/src/async/job-manager.ts +228 -122
  111. package/src/capability/index.ts +18 -0
  112. package/src/capability/mcp.ts +2 -0
  113. package/src/capability/skill.ts +2 -0
  114. package/src/capability/types.ts +6 -0
  115. package/src/cli/auth-broker-cli.ts +1 -1
  116. package/src/cli/config-cli.ts +8 -18
  117. package/src/cli/customize-doctor.ts +15 -2
  118. package/src/cli/public-command-errors.ts +21 -1
  119. package/src/cli/setup-cli.ts +4 -4
  120. package/src/cli/skills-cli.ts +5 -0
  121. package/src/cli-main.ts +7 -8
  122. package/src/commands/deep-interview.ts +47 -8
  123. package/src/commands/sdk.ts +140 -29
  124. package/src/config/autorouting-tier-map.ts +117 -7
  125. package/src/config/file-lock.ts +307 -68
  126. package/src/config/mcp-schema.json +4 -0
  127. package/src/config/model-bindings-applier.ts +20 -0
  128. package/src/config/model-profile-activation.ts +240 -46
  129. package/src/config/model-registry.ts +47 -153
  130. package/src/config/settings.ts +44 -4
  131. package/src/config/skill-settings-defaults.ts +7 -0
  132. package/src/coordinator-mcp/policy.ts +3 -2
  133. package/src/cursor-delete-path.ts +10 -0
  134. package/src/cursor-mcp-name.ts +15 -0
  135. package/src/cursor.ts +13 -2
  136. package/src/customization/inventory.ts +3 -0
  137. package/src/debug/report-bundle-parent.ts +22 -0
  138. package/src/debug/report-bundle.ts +3 -0
  139. package/src/debug/system-info.ts +13 -1
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  141. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  142. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  143. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  144. package/src/defaults/gjc-defaults.test.ts +18 -1
  145. package/src/defaults/gjc-defaults.ts +3 -0
  146. package/src/defaults/gjc-skills.generated.ts +58 -36
  147. package/src/discovery/agents.ts +6 -1
  148. package/src/discovery/builtin.ts +10 -0
  149. package/src/discovery/helpers.ts +168 -23
  150. package/src/discovery/mcp-json.ts +6 -0
  151. package/src/discovery/ssh.ts +9 -4
  152. package/src/discovery/substitute-plugin-root.ts +3 -1
  153. package/src/edit/file-read-cache.ts +1 -1
  154. package/src/eval/py/executor.ts +380 -120
  155. package/src/export/html/css-color.ts +9 -0
  156. package/src/export/html/index.ts +5 -4
  157. package/src/export/html/template.generated.ts +1 -1
  158. package/src/extensibility/extensions/runner.ts +4 -0
  159. package/src/extensibility/extensions/types.ts +7 -0
  160. package/src/extensibility/gjc-plugins/runtime-adapters.ts +73 -8
  161. package/src/extensibility/plugins/manager.ts +3 -2
  162. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  163. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  164. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  165. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  166. package/src/extensibility/skill-management.ts +6 -2
  167. package/src/extensibility/skills.ts +6 -1
  168. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  169. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  170. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  171. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  172. package/src/gjc-runtime/gc-runtime.ts +206 -8
  173. package/src/gjc-runtime/launch-worktree.ts +14 -4
  174. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  175. package/src/gjc-runtime/state-argv.ts +2 -0
  176. package/src/gjc-runtime/state-migrations.ts +91 -29
  177. package/src/gjc-runtime/state-runtime.ts +3716 -135
  178. package/src/gjc-runtime/state-writer.ts +83 -8
  179. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  180. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  181. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  182. package/src/harness-control-plane/operate.ts +12 -4
  183. package/src/harness-control-plane/owner.ts +5 -1
  184. package/src/hashline/parser.ts +48 -9
  185. package/src/hashline/recovery.ts +13 -4
  186. package/src/hindsight/config.ts +9 -3
  187. package/src/internal-urls/docs-index.generated.ts +19 -18
  188. package/src/internal-urls/skill-path.ts +19 -0
  189. package/src/internal-urls/skill-protocol.ts +2 -0
  190. package/src/lsp/edits.ts +6 -0
  191. package/src/lsp/workspace-path.ts +51 -0
  192. package/src/main.ts +195 -31
  193. package/src/modes/acp/acp-agent.ts +163 -7
  194. package/src/modes/components/custom-editor.ts +14 -0
  195. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  196. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  197. package/src/modes/components/extensions/state-manager.ts +15 -5
  198. package/src/modes/components/model-selector.ts +1 -4
  199. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  200. package/src/modes/components/status-line/segments.ts +4 -0
  201. package/src/modes/controllers/event-controller.ts +11 -4
  202. package/src/modes/controllers/input-controller.ts +74 -29
  203. package/src/modes/controllers/runtime-mcp-command-controller.ts +8 -3
  204. package/src/modes/controllers/selector-controller.ts +28 -15
  205. package/src/modes/interactive-mode.ts +4 -0
  206. package/src/modes/runtime-init.ts +4 -0
  207. package/src/modes/utils/abort-message.ts +3 -1
  208. package/src/modes/utils/interrupt-ownership.ts +12 -0
  209. package/src/modes/utils/keybinding-matchers.ts +4 -0
  210. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  211. package/src/progress/collect-project-progress.ts +153 -0
  212. package/src/progress/progress-contract.ts +172 -0
  213. package/src/progress/progress-snapshot.ts +140 -0
  214. package/src/progress/project-progress.ts +446 -0
  215. package/src/progress/render-progress.ts +209 -0
  216. package/src/prompts/system/master-mode.md +1 -0
  217. package/src/prompts/tools/skill-discovery.md +1 -1
  218. package/src/runtime-mcp/client.ts +13 -4
  219. package/src/runtime-mcp/config-writer.ts +10 -6
  220. package/src/runtime-mcp/config.ts +1 -0
  221. package/src/runtime-mcp/elicitation.ts +142 -0
  222. package/src/runtime-mcp/oauth-flow.ts +5 -0
  223. package/src/runtime-mcp/oauth-public-url.ts +8 -0
  224. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  225. package/src/runtime-mcp/smithery-registry.ts +22 -3
  226. package/src/runtime-mcp/transports/http.ts +2 -2
  227. package/src/runtime-mcp/types.ts +6 -0
  228. package/src/sdk/acp/adapter.ts +93 -8
  229. package/src/sdk/broker/broker-exit.ts +52 -23
  230. package/src/sdk/broker/broker.ts +83 -7
  231. package/src/sdk/broker/ensure.ts +2 -0
  232. package/src/sdk/broker/lifecycle.ts +820 -73
  233. package/src/sdk/broker/session-index.ts +191 -81
  234. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  235. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  236. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  238. package/src/sdk/bus/index.ts +241 -46
  239. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  240. package/src/sdk/bus/telegram-daemon.ts +4 -2
  241. package/src/sdk/cli/session-cli.ts +17 -3
  242. package/src/sdk/client/client.ts +28 -8
  243. package/src/sdk/host/host.ts +6 -0
  244. package/src/sdk/host/query/handlers.ts +9 -1
  245. package/src/sdk/host/session-runtime.test.ts +2805 -411
  246. package/src/sdk/host/session-runtime.ts +428 -111
  247. package/src/sdk/host/surface-policy.ts +1 -0
  248. package/src/sdk/index.ts +8 -0
  249. package/src/sdk/lifecycle-session.ts +3 -1
  250. package/src/sdk/mcp/server.ts +15 -3
  251. package/src/sdk/prompt-deadline-manager.ts +15 -0
  252. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  253. package/src/sdk/protocol/operation-registry.ts +9 -2
  254. package/src/sdk/router/session-router.ts +94 -14
  255. package/src/sdk/session-list.ts +1 -0
  256. package/src/sdk/session.ts +81 -38
  257. package/src/session/agent-session.ts +674 -224
  258. package/src/session/artifacts.ts +174 -70
  259. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  260. package/src/session/internal/managed-session-scope.ts +3457 -462
  261. package/src/session/internal/managed-session-storage.ts +482 -61
  262. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  263. package/src/session/internal/task-artifact-owner-access.ts +245 -0
  264. package/src/session/internal/task-artifact-owner-transcript.ts +614 -0
  265. package/src/session/session-manager.ts +939 -133
  266. package/src/session/session-retirement.ts +714 -0
  267. package/src/session/session-storage.ts +418 -174
  268. package/src/session/session-transcript-header.ts +17 -0
  269. package/src/session/task-artifact-owner-codec.ts +641 -0
  270. package/src/session/task-artifact-owner-retirement.ts +477 -0
  271. package/src/session/task-artifact-owner.ts +289 -0
  272. package/src/setup/credential-auto-import.ts +12 -3
  273. package/src/setup/credential-import.ts +176 -5
  274. package/src/setup/paseo/announce.ts +6 -1
  275. package/src/skill-state/active-state.ts +132 -2
  276. package/src/slash-commands/builtin-registry.ts +39 -9
  277. package/src/slash-commands/helpers/progress-report.ts +23 -0
  278. package/src/stt/setup.ts +3 -5
  279. package/src/task/commands.ts +3 -1
  280. package/src/task/executor.ts +4 -0
  281. package/src/task/index.ts +6 -0
  282. package/src/tools/ask.ts +205 -6
  283. package/src/tools/ast-edit.ts +4 -0
  284. package/src/tools/bash-allowed-prefixes.ts +15 -0
  285. package/src/tools/bash-skill-urls.ts +56 -3
  286. package/src/tools/bash.ts +48 -79
  287. package/src/tools/descriptors.ts +5 -1
  288. package/src/tools/eval.ts +56 -36
  289. package/src/tools/image-gen.ts +1 -1
  290. package/src/tools/index.ts +3 -0
  291. package/src/tools/python.ts +187 -66
  292. package/src/tools/recipe/index.ts +9 -0
  293. package/src/tools/recipe/recipe-guard.ts +19 -0
  294. package/src/tools/restricted-cwd.ts +10 -0
  295. package/src/tools/skill-discovery.ts +5 -0
  296. package/src/tools/skill.ts +28 -1
  297. package/src/tools/todo-write.ts +10 -1
  298. package/src/tools/tool-catalog.generated.ts +1 -1
  299. package/src/tools/write.ts +1 -0
  300. package/src/utils/tools-checksum.ts +34 -0
  301. package/src/utils/tools-manager.ts +24 -6
  302. package/src/web/search/providers/codex.ts +3 -2
@@ -22,12 +22,20 @@ export function pythonKernelOwnerId(sessionId: string): string {
22
22
  export interface SessionPythonToolInput {
23
23
  /** Working directory for kernel execution (session cwd). */
24
24
  cwd: string;
25
+ /** Resolve the current working directory for each admitted invocation. */
26
+ getCwd?: () => string;
25
27
  /** Session settings used for Python runtime policy. */
26
28
  settings?: SettingsType;
29
+ /** Resolve the session file associated with each admitted invocation. */
30
+ getSessionFile?: () => string | null;
27
31
  /** Resolve the GJC session id used for the kernel owner and transcript paths. */
28
32
  getSessionId: () => string | null;
29
33
  /** Register cleanup with the current logical session lifecycle. */
30
- registerSessionCleanup: (cleanup: () => Promise<void> | void) => void;
34
+ registerSessionCleanup: (cleanup: () => Promise<void> | void) => (() => void) | void;
35
+ /** Reject execution after the owning session has begun disposal. */
36
+ assertEvalExecutionAllowed?: () => void;
37
+ /** Track this whole invocation through its transcript append. */
38
+ trackEvalExecution?: <T>(execution: Promise<T>, abortController: AbortController) => Promise<T>;
31
39
  }
32
40
 
33
41
  const paramsSchema = z.object({
@@ -52,6 +60,25 @@ interface TranscriptExecutionResult {
52
60
  truncated: boolean;
53
61
  }
54
62
 
63
+ interface PythonGeneration {
64
+ readonly sessionId: string;
65
+ readonly ownerId: string;
66
+ readonly abortControllers: Set<AbortController>;
67
+ readonly completions: Set<Promise<void>>;
68
+ readonly transcripts: Map<string, PythonKernelTranscript>;
69
+ unregisterCleanup?: () => void;
70
+ cleanupPromise?: Promise<void>;
71
+ }
72
+
73
+ interface PythonInvocationContext {
74
+ readonly cwd: string;
75
+ readonly sessionFile: string | null;
76
+ readonly sessionId: string;
77
+ readonly ownerId: string;
78
+ readonly settings: SettingsType;
79
+ readonly generation: PythonGeneration;
80
+ }
81
+
55
82
  function errorMessage(error: unknown): string {
56
83
  return error instanceof Error ? error.message : String(error);
57
84
  }
@@ -71,33 +98,87 @@ function appendFailureTrailer(output: string, appendFailure: string | undefined)
71
98
  }
72
99
 
73
100
  export function createSessionPythonTool(input: SessionPythonToolInput): AgentTool {
74
- let armedForSession: string | null = null;
75
- const seenOwnerIds = new Set<string>();
76
- let currentTranscript: PythonKernelTranscript | null = null;
77
-
78
- const armCleanupForSession = (sessionId: string): void => {
79
- if (armedForSession === sessionId) return;
80
- input.registerSessionCleanup(async () => {
81
- await Promise.all([...seenOwnerIds].map(ownerId => disposeKernelSessionsByOwner(ownerId)));
82
- seenOwnerIds.clear();
83
- currentTranscript = null;
84
- armedForSession = null;
101
+ const activeGenerations = new Map<string, PythonGeneration>();
102
+
103
+ const transcriptFor = (
104
+ generation: PythonGeneration,
105
+ context: Pick<PythonInvocationContext, "cwd" | "sessionId">,
106
+ kernelInstanceId: string,
107
+ ): PythonKernelTranscript => {
108
+ const key = JSON.stringify([context.cwd, context.sessionId, kernelInstanceId]);
109
+ let transcript = generation.transcripts.get(key);
110
+ if (!transcript) {
111
+ transcript = openPythonKernelTranscript({
112
+ cwd: context.cwd,
113
+ sessionId: context.sessionId,
114
+ kernelInstanceId,
115
+ });
116
+ generation.transcripts.set(key, transcript);
117
+ }
118
+ return transcript;
119
+ };
120
+
121
+ const retireGeneration = (generation: PythonGeneration): Promise<void> => {
122
+ if (generation.cleanupPromise) return generation.cleanupPromise;
123
+ const cleanup = Promise.withResolvers<void>();
124
+ generation.cleanupPromise = cleanup.promise;
125
+ const completions = [...generation.completions];
126
+ const controllers = [...generation.abortControllers];
127
+ if (activeGenerations.get(generation.sessionId) === generation) {
128
+ activeGenerations.delete(generation.sessionId);
129
+ }
130
+
131
+ let ownerCleanup: Promise<void>;
132
+ try {
133
+ // Core registers pending operations by this existing owner label before
134
+ // availability work begins, so this synchronous call also captures work
135
+ // that has not acquired a kernel yet.
136
+ ownerCleanup = disposeKernelSessionsByOwner(generation.ownerId);
137
+ } catch (error) {
138
+ ownerCleanup = Promise.reject(error);
139
+ }
140
+ for (const controller of controllers) controller.abort();
141
+ void (async () => {
142
+ const results = await Promise.allSettled([ownerCleanup, ...completions]);
143
+ const coreResult = results[0]!;
144
+ if (coreResult.status === "rejected") throw coreResult.reason;
145
+ generation.unregisterCleanup?.();
146
+ generation.unregisterCleanup = undefined;
147
+ generation.transcripts.clear();
148
+ })().then(cleanup.resolve, error => {
149
+ if (generation.cleanupPromise === cleanup.promise) generation.cleanupPromise = undefined;
150
+ cleanup.reject(error);
85
151
  });
86
- armedForSession = sessionId;
152
+ return cleanup.promise;
153
+ };
154
+
155
+ const generationFor = (sessionId: string): PythonGeneration => {
156
+ const current = activeGenerations.get(sessionId);
157
+ if (current) return current;
158
+ const generation: PythonGeneration = {
159
+ sessionId,
160
+ ownerId: pythonKernelOwnerId(sessionId),
161
+ abortControllers: new Set(),
162
+ completions: new Set(),
163
+ transcripts: new Map(),
164
+ };
165
+ activeGenerations.set(sessionId, generation);
166
+ try {
167
+ const unregister = input.registerSessionCleanup(() => retireGeneration(generation));
168
+ if (typeof unregister === "function") generation.unregisterCleanup = unregister;
169
+ } catch (error) {
170
+ activeGenerations.delete(sessionId);
171
+ throw error;
172
+ }
173
+ return generation;
87
174
  };
88
175
 
89
176
  const appendTranscript = async (
90
- sessionId: string,
177
+ context: PythonInvocationContext,
178
+ transcript: PythonKernelTranscript | undefined,
91
179
  code: string,
92
180
  result: TranscriptExecutionResult,
93
181
  ): Promise<string | undefined> => {
94
- if (currentTranscript === null) {
95
- currentTranscript = openPythonKernelTranscript({
96
- cwd: input.cwd,
97
- sessionId,
98
- kernelInstanceId: crypto.randomUUID(),
99
- });
100
- }
101
182
  const record: PythonTranscriptRecord = {
102
183
  timestamp: new Date().toISOString(),
103
184
  code,
@@ -107,11 +188,12 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
107
188
  truncated: result.truncated,
108
189
  };
109
190
  try {
110
- await currentTranscript.append(record);
191
+ const target = transcript ?? transcriptFor(context.generation, context, crypto.randomUUID());
192
+ await target.append(record);
111
193
  return undefined;
112
194
  } catch (error) {
113
195
  const message = errorMessage(error);
114
- logger.warn("Python transcript append failed", { sessionId, error: message });
196
+ logger.warn("Python transcript append failed", { sessionId: context.sessionId, error: message });
115
197
  return message;
116
198
  }
117
199
  };
@@ -135,11 +217,9 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
135
217
  isError: true,
136
218
  };
137
219
  }
138
- armCleanupForSession(sessionId);
139
- const ownerId = pythonKernelOwnerId(sessionId);
140
220
  if (params.action === "clear") {
141
- await disposeKernelSessionsByOwner(ownerId);
142
- currentTranscript = null;
221
+ const generation = generationFor(sessionId);
222
+ await retireGeneration(generation);
143
223
  return {
144
224
  content: [{ type: "text", text: "Python kernel cleared; the next execute starts a fresh kernel." }],
145
225
  };
@@ -152,48 +232,89 @@ export function createSessionPythonTool(input: SessionPythonToolInput): AgentToo
152
232
  };
153
233
  }
154
234
 
155
- seenOwnerIds.add(ownerId);
156
- try {
157
- const activeSettings = input.settings ?? Settings.instance;
158
- const result = await executePython(code, {
159
- cwd: input.cwd,
160
- settings: activeSettings,
161
- kernelMode: "session",
162
- sessionId: ownerId,
163
- kernelOwnerId: ownerId,
164
- artifactsDir: sessionIpykernelsArtifactsDir(input.cwd, sessionId),
165
- signal,
166
- onKernelStart: kernelInstanceId => {
167
- if (currentTranscript?.kernelInstanceId !== kernelInstanceId) {
168
- currentTranscript = openPythonKernelTranscript({
169
- cwd: input.cwd,
170
- sessionId,
171
- kernelInstanceId,
172
- });
173
- }
174
- },
175
- });
176
- const appendFailure = await appendTranscript(sessionId, code, {
177
- output: result.output,
178
- exitCode: result.exitCode ?? null,
179
- cancelled: result.cancelled,
180
- truncated: result.truncated,
235
+ const cwd = input.getCwd?.() ?? input.cwd;
236
+ const sessionFile = input.getSessionFile?.() ?? null;
237
+ const settings = input.settings ?? Settings.instance;
238
+ input.assertEvalExecutionAllowed?.();
239
+ const contextGeneration = generationFor(sessionId);
240
+ const context: PythonInvocationContext = {
241
+ cwd,
242
+ sessionFile,
243
+ sessionId,
244
+ ownerId: contextGeneration.ownerId,
245
+ settings,
246
+ generation: contextGeneration,
247
+ };
248
+ const abortController = new AbortController();
249
+ const abortFromCaller = (): void => abortController.abort(signal?.reason);
250
+ if (signal?.aborted) abortFromCaller();
251
+ else signal?.addEventListener("abort", abortFromCaller, { once: true });
252
+ contextGeneration.abortControllers.add(abortController);
253
+
254
+ let trackingAccepted = false;
255
+ const execution = Promise.resolve()
256
+ .then(async (): Promise<AgentToolResult> => {
257
+ if (!trackingAccepted) throw new Error("Python execution was not admitted by its owning session.");
258
+ input.assertEvalExecutionAllowed?.();
259
+ let transcript: PythonKernelTranscript | undefined;
260
+ try {
261
+ const result = await executePython(code, {
262
+ cwd: context.cwd,
263
+ settings: context.settings,
264
+ sessionFile: context.sessionFile ?? undefined,
265
+ kernelMode: "session",
266
+ sessionId: context.ownerId,
267
+ kernelOwnerId: context.ownerId,
268
+ artifactsDir: sessionIpykernelsArtifactsDir(context.cwd, context.sessionId),
269
+ signal: abortController.signal,
270
+ onKernelStart: kernelInstanceId => {
271
+ transcript = transcriptFor(context.generation, context, kernelInstanceId);
272
+ },
273
+ });
274
+ const appendFailure = await appendTranscript(context, transcript, code, {
275
+ output: result.output,
276
+ exitCode: result.exitCode ?? null,
277
+ cancelled: result.cancelled,
278
+ truncated: result.truncated,
279
+ });
280
+ const output = result.output.length > 0 ? result.output : "(no output)";
281
+ return { content: [{ type: "text", text: appendFailureTrailer(output, appendFailure) }] };
282
+ } catch (error) {
283
+ const output = errorMessage(error);
284
+ const appendFailure = await appendTranscript(context, transcript, code, {
285
+ output,
286
+ exitCode: null,
287
+ cancelled: isCancellationError(error, abortController.signal),
288
+ truncated: false,
289
+ });
290
+ return {
291
+ content: [{ type: "text", text: appendFailureTrailer(output, appendFailure) }],
292
+ isError: true,
293
+ };
294
+ }
295
+ })
296
+ .finally(() => {
297
+ signal?.removeEventListener("abort", abortFromCaller);
298
+ contextGeneration.abortControllers.delete(abortController);
181
299
  });
182
- const output = result.output.length > 0 ? result.output : "(no output)";
183
- return { content: [{ type: "text", text: appendFailureTrailer(output, appendFailure) }] };
300
+ let completion: Promise<AgentToolResult>;
301
+ try {
302
+ completion = input.trackEvalExecution ? input.trackEvalExecution(execution, abortController) : execution;
303
+ trackingAccepted = true;
184
304
  } catch (error) {
185
- const output = errorMessage(error);
186
- const appendFailure = await appendTranscript(sessionId, code, {
187
- output,
188
- exitCode: null,
189
- cancelled: isCancellationError(error, signal),
190
- truncated: false,
191
- });
192
- return {
193
- content: [{ type: "text", text: appendFailureTrailer(output, appendFailure) }],
194
- isError: true,
195
- };
305
+ abortController.abort(error);
306
+ signal?.removeEventListener("abort", abortFromCaller);
307
+ contextGeneration.abortControllers.delete(abortController);
308
+ void execution.catch(() => {});
309
+ throw error;
196
310
  }
311
+ const settled = completion.then(
312
+ () => {},
313
+ () => {},
314
+ );
315
+ contextGeneration.completions.add(settled);
316
+ void settled.then(() => contextGeneration.completions.delete(settled));
317
+ return await completion;
197
318
  },
198
319
  };
199
320
  const agentTool = {
@@ -7,6 +7,7 @@ import type { Theme } from "../../modes/theme/theme";
7
7
  import recipeDescription from "../../prompts/tools/recipe.md" with { type: "text" };
8
8
  import type { ToolSession } from "..";
9
9
  import { type BashRenderContext, BashTool, type BashToolDetails } from "../bash";
10
+ import { assertRecipeCommandAllowed } from "./recipe-guard";
10
11
  import { createRecipeToolRenderer, type RecipeRenderArgs } from "./render";
11
12
  import { buildPromptModel, type DetectedRunner, resolveCommand } from "./runner";
12
13
  import { RUNNERS } from "./runners";
@@ -48,8 +49,10 @@ export class RecipeTool implements AgentTool<typeof recipeSchema, BashToolDetail
48
49
 
49
50
  readonly #bash: BashTool;
50
51
  readonly #runners: DetectedRunner[];
52
+ readonly #session: ToolSession;
51
53
 
52
54
  constructor(session: ToolSession, runners: DetectedRunner[]) {
55
+ this.#session = session;
53
56
  this.#runners = runners;
54
57
  this.#bash = new BashTool(session);
55
58
  this.description = prompt.render(recipeDescription, buildPromptModel(runners));
@@ -75,6 +78,12 @@ export class RecipeTool implements AgentTool<typeof recipeSchema, BashToolDetail
75
78
  ctx?: AgentToolContext,
76
79
  ): Promise<AgentToolResult<BashToolDetails>> {
77
80
  const { command, cwd } = resolveCommand(op, this.#runners);
81
+ await assertRecipeCommandAllowed({
82
+ cwd: this.#session.cwd,
83
+ sessionId: this.#session.getSessionId?.() ?? undefined,
84
+ command,
85
+ commandCwd: cwd,
86
+ });
78
87
  return await this.#bash.execute(toolCallId, { command, cwd }, signal, onUpdate, ctx);
79
88
  }
80
89
  }
@@ -0,0 +1,19 @@
1
+ import type { AgentTool } from "@gajae-code/agent-core";
2
+ import { assertWorkflowMutationAllowed } from "../../skill-state/workflow-mutation-guard";
3
+
4
+ const BASH_GUARD_TOOL = { name: "bash" } as AgentTool;
5
+
6
+ /** Apply the session bash planning guard to a recipe command before it runs. */
7
+ export async function assertRecipeCommandAllowed(input: {
8
+ cwd: string;
9
+ sessionId?: string;
10
+ command: string;
11
+ commandCwd?: string;
12
+ }): Promise<void> {
13
+ await assertWorkflowMutationAllowed({
14
+ cwd: input.cwd,
15
+ sessionId: input.sessionId,
16
+ tool: BASH_GUARD_TOOL,
17
+ args: { command: input.command, cwd: input.commandCwd },
18
+ });
19
+ }
@@ -0,0 +1,10 @@
1
+ import * as fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function assertCwdInsideWorkspace(workspace: string, commandCwd: string): Promise<void> {
5
+ const realWorkspace = await fs.realpath(workspace);
6
+ const realCwd = await fs.realpath(commandCwd);
7
+ if (realCwd !== realWorkspace && !realCwd.startsWith(`${realWorkspace}${path.sep}`)) {
8
+ throw new Error("restricted bash cwd must stay inside the workspace");
9
+ }
10
+ }
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core";
2
2
  import { prompt, untilAborted } from "@gajae-code/utils";
3
3
  import * as z from "zod/v4";
4
+ import { resolveGlobalUserSkillLinkTrust } from "../config/skill-settings-defaults";
4
5
  import {
5
6
  describeDisabledSkillScopes,
6
7
  describeNoSkillMatch,
@@ -93,6 +94,10 @@ export class SkillDiscoveryTool implements AgentTool<typeof skillDiscoverySchema
93
94
  source,
94
95
  limit: input.limit,
95
96
  policy: this.#getRuntimeSkillPolicy(),
97
+ allowExternalUserSkillSymlinks: resolveGlobalUserSkillLinkTrust({
98
+ trustUserSkills: this.#session.settings.getGlobal("skills.trustUserSkills"),
99
+ enablePiUser: this.#session.settings.getGlobal("skills.enablePiUser"),
100
+ }),
96
101
  });
97
102
  const details: SkillDiscoveryToolDetails = {
98
103
  candidates: result.candidates,
@@ -24,10 +24,15 @@
24
24
  import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core";
25
25
  import { prompt, untilAborted } from "@gajae-code/utils";
26
26
  import * as z from "zod/v4";
27
+ import { resolveGlobalUserSkillLinkTrust } from "../config/skill-settings-defaults";
27
28
  import { resolveSubskillActivationForSkillInvocation } from "../extensibility/gjc-plugins";
28
29
  import { findRuntimeSkillByName } from "../extensibility/runtime-skill-discovery";
29
30
  import { buildSkillPromptMessage } from "../extensibility/skills";
30
- import { runNativeStateCommand } from "../gjc-runtime/state-runtime";
31
+ import {
32
+ executionApprovalLineage,
33
+ hasSanctionedRalplanFinalAdmissionForHandoff,
34
+ runNativeStateCommand,
35
+ } from "../gjc-runtime/state-runtime";
31
36
  import { getSkillManifest } from "../gjc-runtime/workflow-manifest";
32
37
  import skillDescription from "../prompts/tools/skill.md" with { type: "text" };
33
38
  import { SKILL_PROMPT_MESSAGE_TYPE } from "../session/messages";
@@ -187,6 +192,10 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
187
192
  this.#session.home,
188
193
  agentDir,
189
194
  this.#session.profileAuthority,
195
+ resolveGlobalUserSkillLinkTrust({
196
+ trustUserSkills: this.#session.settings.getGlobal("skills.trustUserSkills"),
197
+ enablePiUser: this.#session.settings.getGlobal("skills.enablePiUser"),
198
+ }),
190
199
  ));
191
200
  if (!skill) {
192
201
  const available = formatAvailableSkills(skills);
@@ -209,6 +218,24 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
209
218
  }
210
219
  const cwd = this.#session.cwd;
211
220
  const sessionId = activeState?.session_id?.trim();
221
+ if (activeSkill === "ralplan" && requestedName === "ultragoal") {
222
+ if (!sessionId) {
223
+ throw new ToolError("skill tool: Ralplan to Ultragoal handoff requires a session id");
224
+ }
225
+ if (!(await hasSanctionedRalplanFinalAdmissionForHandoff(this.#session.cwd, sessionId))) {
226
+ const lineage = await executionApprovalLineage(this.#session.cwd, sessionId, "ralplan");
227
+ const approvalMode = lineage === "crystal" ? "deep-interview" : "ralplan";
228
+ const approval = await runNativeStateCommand(
229
+ ["approve-execution", "--mode", approvalMode, "--session-id", sessionId, "--json"],
230
+ this.#session.cwd,
231
+ );
232
+ if (approval.status !== 0) {
233
+ throw new ToolError(
234
+ `skill tool: Ralplan execution approval failed (status=${approval.status}): ${(approval.stderr ?? "").trim() || "no detail"}`,
235
+ );
236
+ }
237
+ }
238
+ }
212
239
  const handoffArgs = ["handoff", "--mode", activeSkill, "--to", requestedName, "--json"];
213
240
  if (sessionId) {
214
241
  handoffArgs.push("--session-id", sessionId);
@@ -135,6 +135,15 @@ function normalizeInProgressTask(phases: TodoPhase[]): void {
135
135
  export const USER_TODO_EDIT_CUSTOM_TYPE = "user_todo_edit";
136
136
 
137
137
  export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] {
138
+ return findLatestTodoPhasesFromEntries(entries) ?? [];
139
+ }
140
+
141
+ /**
142
+ * Latest persisted todo list on the branch, terminal items included. `undefined`
143
+ * means no todo state was ever persisted; an empty array is an authoritative
144
+ * empty list (for example after a bare `/todo rm`).
145
+ */
146
+ export function findLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPhase[] | undefined {
138
147
  for (let i = entries.length - 1; i >= 0; i--) {
139
148
  const entry = entries[i];
140
149
  if (entry.type === "custom" && entry.customType === USER_TODO_EDIT_CUSTOM_TYPE) {
@@ -154,7 +163,7 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
154
163
  return clonePhases(details.phases as TodoPhase[]);
155
164
  }
156
165
 
157
- return [];
166
+ return undefined;
158
167
  }
159
168
 
160
169
  function resolveTaskOrError(
@@ -2783,7 +2783,7 @@ export const TOOL_CATALOG: Readonly<Record<string, ToolCatalogEntry>> = {
2783
2783
  "skill_discovery": {
2784
2784
  "name": "skill_discovery",
2785
2785
  "label": "SkillDiscovery",
2786
- "description": "Discover bundled GJC workflow, project, and user runtime skills without loading full skill content.\n\n<instruction>\n- Includes the four bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) as `source: \"bundled\"` candidates from the generated bundled catalog; they are always available, cannot be replaced by filesystem copies, and are included for `source: \"all\"` but excluded by explicit `source: \"project\"`/`\"user\"` requests.\n- Returns thin metadata only: name, description, source scope, path, and use conditions when present. Bundled paths are stable catalog identifiers, not filesystem paths.\n- In trusted Git projects, `.gjc/skills` may be a symlink to a directory inside the same repository (for example `../.agents/skills`). Link and target identities remain checked through body loading; external targets and symlinked user/profile roots are refused. Do not replace a safe project link with copied files to make discovery work.\n- Claude Code (`.claude/skills`) and Codex (`.codex/skills`) layouts are explicit import sources into `.gjc`, never invokable candidates. They are not returned as candidates; instead, each convention skill found in a trusted scope is reported in `diagnostics` with the exact copy command that enables it (copy into `.gjc/skills`), so a skill placed in a documented convention location is discoverable in a normal session without being silently loaded.\n- Discovery is on by default in a normal session. `query` matching is conjunctive substring: every whitespace-separated term must appear in a skill's name, description, source, or use conditions, unless a term equals the exact skill name — one keyword that appears nowhere in the candidate's metadata drops every candidate, so query with exact names or few terms known to appear literally, not topic keyword lists. When zero candidates are returned, the result carries a `notice` if discovery config disabled a requested scope, a non-empty query filtered out scanned skills, or diagnostics explain observed skills that were skipped or filtered. Only an empty result without a `notice` means no skills were scanned and no diagnostics were produced; never report a zero-candidate result as proof no skill exists.\n- When skills were scanned but not advertised (protected-name collision with a bundled workflow skill, include/ignore/disable policy filters, invalid frontmatter, shadowing), the result carries a bounded `diagnostics` list explaining why.\n- To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.\n</instruction>\n\nInput:\n- `query` (optional): words to match against skill name, description, source, or use conditions. Every term must appear (conjunctive substring), or one term must equal the exact skill name.\n- `source` (optional): `all`, `project`, or `user`.\n- `limit` (optional): maximum results, 1-50.",
2786
+ "description": "Discover bundled GJC workflow, project, and user runtime skills without loading full skill content.\n\n<instruction>\n- Includes the four bundled GJC workflow skills (`autoresearch`, `deep-interview`, `ralplan`, `ultragoal`) as `source: \"bundled\"` candidates from the generated bundled catalog; they are always available, cannot be replaced by filesystem copies, and are included for `source: \"all\"` but excluded by explicit `source: \"project\"`/`\"user\"` requests.\n- Returns thin metadata only: name, description, source scope, path, and use conditions when present. Bundled paths are stable catalog identifiers, not filesystem paths.\n- In trusted Git projects, `.gjc/skills` may be a symlink to a directory inside the same repository (for example `../.agents/skills`). Project link and target identities remain checked through body loading; external project targets are refused. An individual user-scope skill symlink inside `~/.gjc/agent/skills` or a configured custom directory may resolve outside its scan root when user/global `skills.trustUserSkills` is enabled; project settings cannot grant this permission. Its link and target identities remain checked through body loading. Symlinked user/profile authority roots remain refused. Do not replace a safe project link with copied files to make discovery work.\n- Claude Code (`.claude/skills`) and Codex (`.codex/skills`) layouts are explicit import sources into `.gjc`, never invokable candidates. They are not returned as candidates; instead, each convention skill found in a trusted scope is reported in `diagnostics` with the exact copy command that enables it (copy into `.gjc/skills`), so a skill placed in a documented convention location is discoverable in a normal session without being silently loaded.\n- Discovery is on by default in a normal session. `query` matching is conjunctive substring: every whitespace-separated term must appear in a skill's name, description, source, or use conditions, unless a term equals the exact skill name — one keyword that appears nowhere in the candidate's metadata drops every candidate, so query with exact names or few terms known to appear literally, not topic keyword lists. When zero candidates are returned, the result carries a `notice` if discovery config disabled a requested scope, a non-empty query filtered out scanned skills, or diagnostics explain observed skills that were skipped or filtered. Only an empty result without a `notice` means no skills were scanned and no diagnostics were produced; never report a zero-candidate result as proof no skill exists.\n- When skills were scanned but not advertised (protected-name collision with a bundled workflow skill, include/ignore/disable policy filters, invalid frontmatter, shadowing), the result carries a bounded `diagnostics` list explaining why.\n- To load a selected skill's full `SKILL.md`, invoke it through the existing `skill` tool with the exact `name` returned here.\n</instruction>\n\nInput:\n- `query` (optional): words to match against skill name, description, source, or use conditions. Every term must appear (conjunctive substring), or one term must equal the exact skill name.\n- `source` (optional): `all`, `project`, or `user`.\n- `limit` (optional): maximum results, 1-50.",
2787
2787
  "parameters": {
2788
2788
  "type": "object",
2789
2789
  "properties": {
@@ -462,6 +462,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
462
462
  context: AgentToolContext | undefined,
463
463
  ): Promise<AgentToolResult<WriteToolDetails>> {
464
464
  const absolutePath = entry.absolutePath;
465
+ enforcePlanModeWrite(this.session, absolutePath, { op: "update" });
465
466
  if (!(await fs.exists(absolutePath))) {
466
467
  throw new ToolError(`Conflict #${entry.id} target '${entry.displayPath}' no longer exists.`);
467
468
  }
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+
4
+ const SHA256_DIGEST_RE = /^sha256:([a-fA-F0-9]{64})$/;
5
+
6
+ export interface ReleaseAssetDigest {
7
+ name?: string;
8
+ digest?: string | null;
9
+ }
10
+
11
+ export function sha256FromReleaseDigest(digest: string | null | undefined): string | null {
12
+ if (typeof digest !== "string") return null;
13
+ const match = SHA256_DIGEST_RE.exec(digest.trim());
14
+ return match ? match[1].toLowerCase() : null;
15
+ }
16
+
17
+ export function requireAssetSha256(assets: readonly ReleaseAssetDigest[], assetName: string): string {
18
+ const asset = assets.find(entry => entry.name === assetName);
19
+ const sha256 = sha256FromReleaseDigest(asset?.digest);
20
+ if (!sha256) {
21
+ throw new Error(`Release asset ${assetName} has no sha256 digest; refusing to install an unverified binary`);
22
+ }
23
+ return sha256;
24
+ }
25
+
26
+ export async function assertFileMatchesSha256(filePath: string, expectedSha256: string): Promise<void> {
27
+ const actual = createHash("sha256")
28
+ .update(await fs.readFile(filePath))
29
+ .digest("hex");
30
+ if (actual !== expectedSha256.toLowerCase()) {
31
+ await fs.rm(filePath, { force: true });
32
+ throw new Error(`Checksum mismatch for ${filePath}`);
33
+ }
34
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, APP_NAME, getToolsDir, logger, ptree, TempDir } from "@gajae-code/utils";
5
+ import { assertFileMatchesSha256, requireAssetSha256 } from "./tools-checksum";
5
6
 
6
7
  const TOOLS_DIR = getToolsDir();
7
8
  const TOOL_DOWNLOAD_TIMEOUT_MS = 120_000;
@@ -112,8 +113,18 @@ export function getToolPath(tool: ToolName): string | null {
112
113
  return $which(config.binaryName);
113
114
  }
114
115
 
115
- // Fetch latest release version from GitHub
116
- async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<string> {
116
+ interface GithubReleaseAsset {
117
+ name?: string;
118
+ digest?: string | null;
119
+ }
120
+
121
+ interface GithubRelease {
122
+ version: string;
123
+ assets: GithubReleaseAsset[];
124
+ }
125
+
126
+ // Fetch latest release metadata from GitHub, including asset digests.
127
+ async function getLatestRelease(repo: string, signal?: AbortSignal): Promise<GithubRelease> {
117
128
  let response: Response;
118
129
  try {
119
130
  response = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, {
@@ -131,8 +142,11 @@ async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<str
131
142
  throw new Error(`GitHub API error: ${response.status}`);
132
143
  }
133
144
 
134
- const data = (await response.json()) as { tag_name: string };
135
- return data.tag_name.replace(/^v/, "");
145
+ const data = (await response.json()) as { tag_name?: string; assets?: GithubReleaseAsset[] };
146
+ if (!data.tag_name) {
147
+ throw new Error("GitHub API error: missing tag_name");
148
+ }
149
+ return { version: data.tag_name.replace(/^v/, ""), assets: data.assets ?? [] };
136
150
  }
137
151
 
138
152
  // Download a file from URL
@@ -164,14 +178,16 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
164
178
  const plat = os.platform();
165
179
  const architecture = os.arch();
166
180
 
167
- // Get latest version
168
- const version = await getLatestVersion(config.repo, signal);
181
+ // Get latest version and the published digest for this asset.
182
+ const release = await getLatestRelease(config.repo, signal);
183
+ const version = release.version;
169
184
 
170
185
  // Get asset name for this platform
171
186
  const assetName = config.getAssetName(version, plat, architecture);
172
187
  if (!assetName) {
173
188
  throw new Error(`Unsupported platform: ${plat}/${architecture}`);
174
189
  }
190
+ const expectedSha256 = requireAssetSha256(release.assets, assetName);
175
191
 
176
192
  // Create tools directory
177
193
  await fs.promises.mkdir(TOOLS_DIR, { recursive: true });
@@ -183,6 +199,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
183
199
  // Handle direct binary downloads (no archive extraction needed)
184
200
  if (config.isDirectBinary) {
185
201
  await downloadFile(downloadUrl, binaryPath, signal);
202
+ await assertFileMatchesSha256(binaryPath, expectedSha256);
186
203
  if (plat !== "win32") {
187
204
  await fs.promises.chmod(binaryPath, 0o755);
188
205
  }
@@ -192,6 +209,7 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
192
209
  // Download archive
193
210
  const archivePath = path.join(TOOLS_DIR, assetName);
194
211
  await downloadFile(downloadUrl, archivePath, signal);
212
+ await assertFileMatchesSha256(archivePath, expectedSha256);
195
213
 
196
214
  // Extract
197
215
  const tmp = await TempDir.create("@gjc-tools-extract-");
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import * as os from "node:os";
10
10
  import { type AuthStorage, getBundledModels } from "@gajae-code/ai/core";
11
+ import { OPENAI_HEADER_VALUES } from "@gajae-code/ai/providers/openai-codex/constants";
11
12
  import { decodeJwt } from "@gajae-code/ai/utils/oauth/openai-codex";
12
13
  import { $env, readSseJson, sanitizeHeaderComponent } from "@gajae-code/utils";
13
14
  import packageJson from "../../../../package.json" with { type: "json" };
@@ -157,8 +158,8 @@ function buildCodexHeaders(accessToken: string, accountId: string): Record<strin
157
158
  Authorization: `Bearer ${accessToken}`,
158
159
  "chatgpt-account-id": accountId,
159
160
  "OpenAI-Beta": "responses=experimental",
160
- originator: "pi",
161
- "User-Agent": `pi/${packageJson.version} (${sanitizeHeaderComponent(os.platform())} ${sanitizeHeaderComponent(os.release())}; ${sanitizeHeaderComponent(os.arch())})`,
161
+ originator: OPENAI_HEADER_VALUES.ORIGINATOR_CODEX,
162
+ "User-Agent": `${OPENAI_HEADER_VALUES.ORIGINATOR_CODEX}/${packageJson.version} (${sanitizeHeaderComponent(os.platform())} ${sanitizeHeaderComponent(os.release())}; ${sanitizeHeaderComponent(os.arch())})`,
162
163
  Accept: "text/event-stream",
163
164
  "Content-Type": "application/json",
164
165
  };