@gajae-code/coding-agent 0.18.12 → 0.19.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 (305) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/dist/types/capability/rule.d.ts +7 -2
  3. package/dist/types/cli/append-system-prompt.d.ts +6 -0
  4. package/dist/types/cli/args.d.ts +7 -2
  5. package/dist/types/cli/customize-doctor-precedence.d.ts +16 -0
  6. package/dist/types/cli/customize-doctor.d.ts +4 -3
  7. package/dist/types/cli/public-command-errors.d.ts +1 -0
  8. package/dist/types/cli/root-flags.d.ts +0 -12
  9. package/dist/types/cli/update-cli.d.ts +7 -2
  10. package/dist/types/cli-main.d.ts +0 -6
  11. package/dist/types/commands/launch.d.ts +0 -6
  12. package/dist/types/commands/migrate.d.ts +1 -1
  13. package/dist/types/commands/sdk.d.ts +8 -0
  14. package/dist/types/commit/map-reduce/index.d.ts +4 -4
  15. package/dist/types/config/autorouting-tier-map.d.ts +23 -1
  16. package/dist/types/config/config-hot-reload.d.ts +5 -0
  17. package/dist/types/config/file-lock.d.ts +44 -2
  18. package/dist/types/config/model-profile-activation.d.ts +19 -5
  19. package/dist/types/config/model-registry.d.ts +16 -3
  20. package/dist/types/config/model-resolver.d.ts +5 -4
  21. package/dist/types/config/settings-schema.d.ts +11 -1
  22. package/dist/types/coordinator-mcp/server.d.ts +4 -0
  23. package/dist/types/customization/import.d.ts +2 -0
  24. package/dist/types/customization/types.d.ts +1 -1
  25. package/dist/types/dap/session.d.ts +11 -2
  26. package/dist/types/discovery/helpers.d.ts +7 -2
  27. package/dist/types/edit/index.d.ts +5 -0
  28. package/dist/types/edit/modes/patch.d.ts +9 -0
  29. package/dist/types/eval/py/runtime.d.ts +9 -0
  30. package/dist/types/export/ttsr.d.ts +19 -0
  31. package/dist/types/extensibility/extensions/types.d.ts +5 -1
  32. package/dist/types/extensibility/slash-commands.d.ts +1 -0
  33. package/dist/types/gjc-runtime/ralplan-current-final.d.ts +54 -0
  34. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +5 -0
  35. package/dist/types/gjc-runtime/tmux-sessions.d.ts +24 -0
  36. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +6 -0
  37. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  38. package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
  39. package/dist/types/hindsight/content.d.ts +5 -6
  40. package/dist/types/internal-urls/types.d.ts +2 -0
  41. package/dist/types/lsp/clients/swiftlint-client.d.ts +0 -4
  42. package/dist/types/main.d.ts +23 -18
  43. package/dist/types/migrate/action-planner.d.ts +10 -1
  44. package/dist/types/migrate/adapters/codex.d.ts +1 -1
  45. package/dist/types/migrate/adapters/index.d.ts +35 -3
  46. package/dist/types/migrate/hook-module.d.ts +23 -0
  47. package/dist/types/migrate/report.d.ts +2 -1
  48. package/dist/types/migrate/types.d.ts +69 -2
  49. package/dist/types/modes/components/agent-dashboard.d.ts +1 -1
  50. package/dist/types/modes/components/model-selector.d.ts +43 -0
  51. package/dist/types/modes/prompt-suggestion-controller.d.ts +1 -1
  52. package/dist/types/modes/utils/host-status.d.ts +15 -11
  53. package/dist/types/runtime-mcp/project-host-values.d.ts +11 -5
  54. package/dist/types/sdk/broker/broker-exit.d.ts +1 -1
  55. package/dist/types/sdk/broker/broker.d.ts +22 -1
  56. package/dist/types/sdk/broker/ensure.d.ts +5 -2
  57. package/dist/types/sdk/broker/hop.d.ts +9 -0
  58. package/dist/types/sdk/broker/lifecycle.d.ts +22 -0
  59. package/dist/types/sdk/broker/session-index.d.ts +2 -0
  60. package/dist/types/sdk/broker/transport.d.ts +2 -0
  61. package/dist/types/sdk/bus/index.d.ts +21 -0
  62. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  63. package/dist/types/sdk/bus/telegram-daemon.d.ts +2 -0
  64. package/dist/types/sdk/bus/threaded-render.d.ts +2 -1
  65. package/dist/types/sdk/host/control/operations.d.ts +5 -1
  66. package/dist/types/sdk/host/events.d.ts +4 -0
  67. package/dist/types/sdk/host/host.d.ts +2 -1
  68. package/dist/types/sdk/host/prompt-image-upload.d.ts +43 -0
  69. package/dist/types/sdk/host/session-runtime.d.ts +8 -8
  70. package/dist/types/sdk/host/surface-policy.d.ts +2 -1
  71. package/dist/types/sdk/lifecycle/service.d.ts +12 -0
  72. package/dist/types/sdk/prompt-status.d.ts +11 -4
  73. package/dist/types/session/agent-session.d.ts +50 -8
  74. package/dist/types/session/session-manager.d.ts +16 -0
  75. package/dist/types/skill-state/workflow-mutation-guard.d.ts +13 -1
  76. package/dist/types/slash-commands/helpers/mcp.d.ts +5 -0
  77. package/dist/types/ssh/ssh-executor.d.ts +8 -1
  78. package/dist/types/system-prompt.d.ts +3 -2
  79. package/dist/types/task/role-body.d.ts +11 -0
  80. package/dist/types/tools/awaiting-input.d.ts +28 -0
  81. package/dist/types/tools/bash-allowed-prefixes.d.ts +6 -0
  82. package/dist/types/tools/bash-skill-urls.d.ts +4 -0
  83. package/dist/types/tools/bash.d.ts +7 -0
  84. package/dist/types/tools/descriptors.d.ts +13 -2
  85. package/dist/types/tools/fetch.d.ts +6 -0
  86. package/dist/types/tools/irc.d.ts +5 -3
  87. package/dist/types/tools/tmux-self-injection-guard.d.ts +2 -0
  88. package/dist/types/tools/ultragoal-ask-guard.d.ts +1 -1
  89. package/dist/types/tools/write.d.ts +2 -0
  90. package/dist/types/utils/file-mentions.d.ts +5 -0
  91. package/dist/types/utils/image-limits.d.ts +7 -0
  92. package/dist/types/utils/image-loading.d.ts +0 -1
  93. package/dist/types/utils/open.d.ts +0 -1
  94. package/dist/types/utils/pasted-image-loading.d.ts +1 -5
  95. package/dist/types/utils/prompt-suggestion.d.ts +2 -2
  96. package/dist/types/utils/shell-snapshot.d.ts +2 -0
  97. package/dist/types/utils/title-generator.d.ts +13 -3
  98. package/dist/types/web/search/providers/codex.d.ts +6 -7
  99. package/package.json +9 -8
  100. package/scripts/acp-conformance-agent.ts +12 -2
  101. package/scripts/generate-sdk-operation-inventory.ts +16 -0
  102. package/src/capability/fs.ts +31 -3
  103. package/src/capability/rule.ts +11 -2
  104. package/src/cli/append-system-prompt.ts +12 -0
  105. package/src/cli/args.ts +11 -7
  106. package/src/cli/customize-doctor-precedence.ts +31 -0
  107. package/src/cli/customize-doctor.ts +49 -39
  108. package/src/cli/fast-help.ts +0 -2
  109. package/src/cli/migrate-cli.ts +14 -3
  110. package/src/cli/public-command-errors.ts +2 -0
  111. package/src/cli/root-flags.ts +0 -2
  112. package/src/cli/update-cli.ts +247 -6
  113. package/src/commands/harness.ts +25 -15
  114. package/src/commands/migrate.ts +3 -3
  115. package/src/commands/sdk.ts +81 -11
  116. package/src/commit/agentic/agent.ts +6 -6
  117. package/src/commit/analysis/conventional.ts +14 -5
  118. package/src/commit/map-reduce/index.ts +7 -7
  119. package/src/commit/pipeline.ts +12 -12
  120. package/src/config/autorouting-tier-map.ts +20 -1
  121. package/src/config/config-file.ts +106 -3
  122. package/src/config/config-hot-reload.ts +8 -0
  123. package/src/config/file-lock.ts +1431 -304
  124. package/src/config/keybindings.ts +154 -7
  125. package/src/config/model-profile-activation.ts +177 -39
  126. package/src/config/model-registry.ts +380 -55
  127. package/src/config/model-resolver.ts +49 -21
  128. package/src/config/settings-schema.ts +13 -1
  129. package/src/config/settings.ts +2 -0
  130. package/src/coordinator-mcp/server.ts +37 -2
  131. package/src/crash/sanitize.ts +3 -1
  132. package/src/customization/import.ts +276 -7
  133. package/src/customization/inventory.ts +24 -8
  134. package/src/customization/types.ts +1 -0
  135. package/src/dap/client.ts +3 -2
  136. package/src/dap/session.ts +161 -36
  137. package/src/debug/crash-diagnostics.ts +87 -5
  138. package/src/debug/report-bundle.ts +71 -2
  139. package/src/discovery/builtin.ts +4 -3
  140. package/src/discovery/helpers.ts +24 -14
  141. package/src/edit/index.ts +191 -5
  142. package/src/edit/modes/patch.ts +27 -0
  143. package/src/eval/py/runtime.ts +44 -4
  144. package/src/exec/non-interactive-env.ts +0 -1
  145. package/src/export/html/index.ts +60 -20
  146. package/src/export/ttsr.ts +176 -1
  147. package/src/extensibility/extensions/loader.ts +2 -0
  148. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +63 -1
  149. package/src/extensibility/extensions/types.ts +5 -1
  150. package/src/extensibility/hooks/loader.ts +10 -2
  151. package/src/extensibility/plugins/marketplace/manager.ts +36 -10
  152. package/src/extensibility/slash-commands.ts +5 -0
  153. package/src/gjc-runtime/gc-runtime.ts +4 -1
  154. package/src/gjc-runtime/launch-tmux.ts +23 -13
  155. package/src/gjc-runtime/ralplan-current-final.ts +294 -0
  156. package/src/gjc-runtime/ralplan-runtime.ts +95 -1
  157. package/src/gjc-runtime/state-writer.ts +109 -5
  158. package/src/gjc-runtime/tmux-sessions.ts +93 -13
  159. package/src/gjc-runtime/ultragoal-change-set.ts +90 -11
  160. package/src/gjc-runtime/ultragoal-guard.ts +9 -1
  161. package/src/gjc-runtime/ultragoal-runtime.ts +15 -5
  162. package/src/harness-control-plane/finalize.ts +11 -0
  163. package/src/harness-control-plane/receipt-spool.ts +105 -28
  164. package/src/harness-control-plane/storage.ts +124 -6
  165. package/src/hindsight/content.ts +116 -16
  166. package/src/hindsight/mental-models.ts +22 -10
  167. package/src/hindsight/state.ts +7 -4
  168. package/src/hooks/native-skill-hook.ts +3 -1
  169. package/src/internal-urls/artifact-protocol.ts +3 -1
  170. package/src/internal-urls/docs-index.generated.ts +14 -14
  171. package/src/internal-urls/local-protocol.ts +28 -3
  172. package/src/internal-urls/types.ts +2 -0
  173. package/src/lsp/client.ts +2 -1
  174. package/src/lsp/clients/biome-client.ts +2 -0
  175. package/src/lsp/clients/swiftlint-client.ts +2 -0
  176. package/src/main.ts +330 -92
  177. package/src/memories/index.ts +25 -2
  178. package/src/migrate/action-planner.ts +517 -2
  179. package/src/migrate/adapters/claude-code.ts +141 -3
  180. package/src/migrate/adapters/codex.ts +57 -5
  181. package/src/migrate/adapters/index.ts +240 -5
  182. package/src/migrate/adapters/opencode.ts +4 -0
  183. package/src/migrate/executor.ts +141 -3
  184. package/src/migrate/hook-module.ts +193 -0
  185. package/src/migrate/report.ts +45 -1
  186. package/src/migrate/types.ts +73 -3
  187. package/src/modes/acp/acp-agent.ts +10 -1
  188. package/src/modes/components/agent-dashboard.ts +20 -4
  189. package/src/modes/components/customization/customization-dashboard.ts +7 -0
  190. package/src/modes/components/customization/import-wizard.ts +29 -3
  191. package/src/modes/components/model-selector.ts +157 -103
  192. package/src/modes/components/welcome.ts +3 -1
  193. package/src/modes/controllers/command-controller.ts +271 -16
  194. package/src/modes/controllers/event-controller.ts +1 -1
  195. package/src/modes/controllers/extension-ui-controller.ts +31 -1
  196. package/src/modes/controllers/goal-mode-controller.ts +79 -10
  197. package/src/modes/controllers/input-controller.ts +2 -0
  198. package/src/modes/controllers/plan-mode-controller.ts +49 -4
  199. package/src/modes/controllers/runtime-mcp-command-controller.ts +7 -6
  200. package/src/modes/controllers/selector-controller.ts +14 -1
  201. package/src/modes/interactive-mode.ts +55 -4
  202. package/src/modes/prompt-suggestion-controller.ts +1 -1
  203. package/src/modes/runtime-init.ts +5 -1
  204. package/src/modes/utils/host-status.ts +21 -10
  205. package/src/prompts/agent-fragments/browser-aside.md +2 -2
  206. package/src/prompts/system/irc-incoming.md +3 -1
  207. package/src/prompts/tools/irc.md +1 -1
  208. package/src/runtime-mcp/manager.ts +8 -3
  209. package/src/runtime-mcp/project-host-values.ts +12 -8
  210. package/src/runtime-mcp/tool-bridge.ts +68 -4
  211. package/src/runtime-mcp/transports/stdio.ts +2 -1
  212. package/src/sdk/broker/broker-exit.ts +2 -0
  213. package/src/sdk/broker/broker.ts +143 -4
  214. package/src/sdk/broker/ensure.ts +74 -6
  215. package/src/sdk/broker/hop.ts +13 -1
  216. package/src/sdk/broker/lifecycle-ledger.ts +3 -0
  217. package/src/sdk/broker/lifecycle.ts +102 -6
  218. package/src/sdk/broker/session-index.ts +29 -4
  219. package/src/sdk/broker/transport.ts +17 -1
  220. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  221. package/src/sdk/bus/index.ts +408 -14
  222. package/src/sdk/bus/session-control.ts +2 -0
  223. package/src/sdk/bus/slack-daemon.ts +105 -9
  224. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  225. package/src/sdk/bus/telegram-daemon.ts +10 -2
  226. package/src/sdk/bus/threaded-render.ts +20 -1
  227. package/src/sdk/cli/session-cli.ts +2 -0
  228. package/src/sdk/host/control/dispatch.ts +13 -1
  229. package/src/sdk/host/control/operations.ts +10 -1
  230. package/src/sdk/host/events.ts +67 -4
  231. package/src/sdk/host/host.ts +8 -2
  232. package/src/sdk/host/prompt-image-upload.ts +438 -0
  233. package/src/sdk/host/sdk-surface-parity.test.ts +15 -0
  234. package/src/sdk/host/session-runtime.test.ts +1537 -24
  235. package/src/sdk/host/session-runtime.ts +332 -36
  236. package/src/sdk/host/surface-policy.ts +4 -0
  237. package/src/sdk/host/websocket-transport.lifecycle.test.ts +90 -2
  238. package/src/sdk/host/websocket-transport.ts +19 -7
  239. package/src/sdk/lifecycle/service.ts +14 -1
  240. package/src/sdk/prompt-status.ts +6 -4
  241. package/src/sdk/protocol/adapter-validation.ts +28 -1
  242. package/src/sdk/protocol/operation-inventory.generated.json +160 -0
  243. package/src/sdk/protocol/operation-registry.ts +33 -3
  244. package/src/sdk/session.ts +123 -36
  245. package/src/session/agent-session.ts +1614 -329
  246. package/src/session/history-storage.ts +182 -12
  247. package/src/session/internal/managed-session-scope.ts +47 -18
  248. package/src/session/internal/managed-session-storage.ts +71 -1
  249. package/src/session/session-manager.ts +90 -5
  250. package/src/session-import/redact.ts +53 -2
  251. package/src/skill-state/workflow-mutation-guard.ts +1498 -18
  252. package/src/slash-commands/builtin-registry.ts +6 -3
  253. package/src/slash-commands/helpers/mcp.ts +11 -3
  254. package/src/ssh/ssh-executor.ts +9 -2
  255. package/src/stt/recorder.ts +26 -9
  256. package/src/system-prompt.ts +61 -16
  257. package/src/task/executor.ts +12 -8
  258. package/src/task/role-body.ts +84 -0
  259. package/src/task/token-log-mode.test.ts +162 -0
  260. package/src/task/token-log.ts +91 -2
  261. package/src/tools/archive-reader.ts +650 -37
  262. package/src/tools/ask.ts +1 -0
  263. package/src/tools/ast-grep.ts +7 -2
  264. package/src/tools/auto-generated-guard.ts +12 -3
  265. package/src/tools/awaiting-input.ts +114 -0
  266. package/src/tools/bash-allowed-prefixes.ts +61 -1
  267. package/src/tools/bash-skill-urls.ts +58 -5
  268. package/src/tools/bash.ts +99 -26
  269. package/src/tools/browser/tab-worker.ts +39 -13
  270. package/src/tools/browser.ts +17 -7
  271. package/src/tools/cron.ts +10 -1
  272. package/src/tools/debug.ts +9 -2
  273. package/src/tools/descriptors.test.ts +4 -4
  274. package/src/tools/descriptors.ts +16 -4
  275. package/src/tools/eval.ts +15 -0
  276. package/src/tools/fetch.ts +90 -2
  277. package/src/tools/gh.ts +27 -12
  278. package/src/tools/image-gen.ts +20 -6
  279. package/src/tools/irc.ts +5 -3
  280. package/src/tools/monitor.ts +9 -1
  281. package/src/tools/output-meta.ts +51 -3
  282. package/src/tools/read.ts +9 -2
  283. package/src/tools/recipe/runner.ts +23 -12
  284. package/src/tools/render-utils.ts +2 -2
  285. package/src/tools/search.ts +3 -2
  286. package/src/tools/ssh.ts +274 -8
  287. package/src/tools/subagent.ts +10 -4
  288. package/src/tools/tmux-self-injection-guard.ts +3301 -122
  289. package/src/tools/tool-catalog.generated.ts +1 -1
  290. package/src/tools/ultragoal-ask-guard.ts +13 -1
  291. package/src/tools/write.ts +7 -0
  292. package/src/tui/tree-list.ts +15 -1
  293. package/src/utils/commit-message-generator.ts +3 -2
  294. package/src/utils/external-editor.ts +21 -7
  295. package/src/utils/file-mentions.ts +46 -6
  296. package/src/utils/git.ts +38 -7
  297. package/src/utils/image-limits.ts +8 -0
  298. package/src/utils/image-loading.ts +1 -1
  299. package/src/utils/open.ts +73 -4
  300. package/src/utils/pasted-image-loading.ts +9 -8
  301. package/src/utils/prompt-suggestion.ts +5 -4
  302. package/src/utils/shell-snapshot.ts +9 -1
  303. package/src/utils/title-generator.ts +65 -20
  304. package/src/web/search/index.ts +2 -1
  305. package/src/web/search/providers/codex.ts +29 -32
@@ -57,7 +57,7 @@ import {
57
57
  invalidateSessionTitleGeneration,
58
58
  isSessionTitleGenerationCurrent,
59
59
  } from "../utils/session-title-generation";
60
- import { buildConversationTitleInput, generateSessionTitle } from "../utils/title-generator";
60
+ import { buildConversationTitleInput, generateSessionTitle, titleInputObfuscator } from "../utils/title-generator";
61
61
  import { handleAsideAcp } from "./helpers/aside";
62
62
  import { buildAutoroutingStatusReport } from "./helpers/autorouting-status";
63
63
  import { buildContextReportText } from "./helpers/context-report";
@@ -158,7 +158,9 @@ function toSlashCommandRuntime(runtime: TuiSlashCommandRuntime): SlashCommandRun
158
158
  async function regenerateSessionTitle(runtime: SlashCommandRuntime): Promise<SlashCommandResult> {
159
159
  try {
160
160
  const generation = beginSessionTitleGeneration(runtime.sessionManager);
161
- const input = buildConversationTitleInput(runtime.session.messages);
161
+ const activeCwd = runtime.sessionManager.getCwd();
162
+ const obfuscateText = await titleInputObfuscator(runtime.settings, activeCwd);
163
+ const input = buildConversationTitleInput(runtime.session.messages, obfuscateText);
162
164
  if (!input) {
163
165
  return usage("Nothing to summarize yet — pass a title: /rename <title>", runtime);
164
166
  }
@@ -170,10 +172,11 @@ async function regenerateSessionTitle(runtime: SlashCommandRuntime): Promise<Sla
170
172
  runtime.session.credentialSessionId,
171
173
  runtime.session.model,
172
174
  provider => runtime.session.agent.metadataForProvider(provider),
175
+ activeCwd,
173
176
  );
174
177
  if (
175
178
  !isSessionTitleGenerationCurrent(runtime.sessionManager, generation) ||
176
- buildConversationTitleInput(runtime.session.messages) !== input
179
+ buildConversationTitleInput(runtime.session.messages, obfuscateText) !== input
177
180
  )
178
181
  return commandConsumed();
179
182
  if (!generated) {
@@ -1,4 +1,4 @@
1
- import { getMCPConfigPath } from "@gajae-code/utils";
1
+ import { getMCPConfigPath, sanitizeDisplayLine } from "@gajae-code/utils";
2
2
  import { listPrompts, listResources, listTools } from "../../runtime-mcp/client";
3
3
  import {
4
4
  addMCPServer,
@@ -19,6 +19,14 @@ import { commandConsumed, errorMessage, parseNamedScopeArgs, parseSubcommand, us
19
19
 
20
20
  type AcpMcpScope = "user" | "project";
21
21
 
22
+ /**
23
+ * One terminal row for an untrusted MCP server or tool name.
24
+ * Ordinary names are unchanged. Controls, OSC, and line breaks are not.
25
+ */
26
+ export function mcpTerminalLabel(value: string): string {
27
+ return sanitizeDisplayLine(value);
28
+ }
29
+
22
30
  interface ParsedMcpAddArgs {
23
31
  name?: string;
24
32
  scope: AcpMcpScope;
@@ -272,7 +280,7 @@ async function handleTestCommand(rest: string, runtime: SlashCommandRuntime): Pr
272
280
  return await withPreparedMcpConnection(runtime, name, server.config, server.scope, async connection => {
273
281
  const tools = await listTools(connection);
274
282
  const lines = [`Server "${name}" connected (${tools.length} tools).`];
275
- for (const tool of tools) lines.push(` - ${tool.name}`);
283
+ for (const tool of tools) lines.push(` - ${mcpTerminalLabel(tool.name)}`);
276
284
  await runtime.output(lines.join("\n"));
277
285
  return commandConsumed();
278
286
  });
@@ -398,7 +406,7 @@ async function handleListCommand(runtime: SlashCommandRuntime): Promise<SlashCom
398
406
  } else {
399
407
  location = (config as { command: string }).command;
400
408
  }
401
- return `${name} | ${type} | ${status} | ${location ?? "(unknown)"} [${scope}]`;
409
+ return `${mcpTerminalLabel(name)} | ${type} | ${status} | ${location ?? "(unknown)"} [${scope}]`;
402
410
  })
403
411
  .join("\n"),
404
412
  );
@@ -1,6 +1,6 @@
1
1
  import { logger, ptree } from "@gajae-code/utils";
2
2
  import { Settings } from "../config/settings";
3
- import { OutputSink } from "../session/streaming-output";
3
+ import { OutputSink, type TerminalArtifactPublisher } from "../session/streaming-output";
4
4
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
5
5
  import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
6
6
  import { hasSshfs, mountRemote } from "./sshfs-mount";
@@ -21,6 +21,8 @@ export interface SSHExecutorOptions {
21
21
  /** Artifact path/id for full output storage */
22
22
  artifactPath?: string;
23
23
  artifactId?: string;
24
+ /** Session-owned publisher for managed sessions without a writable artifact path. */
25
+ artifactPublisher?: TerminalArtifactPublisher;
24
26
  }
25
27
 
26
28
  export interface SSHResult {
@@ -40,8 +42,12 @@ export interface SSHResult {
40
42
  outputLines: number;
41
43
  /** Number of bytes included in the output text */
42
44
  outputBytes: number;
43
- /** Artifact ID if full output was saved to artifact storage */
45
+ /** Artifact ID if output was saved to artifact storage. */
44
46
  artifactId?: string;
47
+ /** Bytes omitted from artifact storage after its hard cap was reached. */
48
+ artifactTruncatedBytes?: number;
49
+ /** Bounded diagnostic when artifact storage failed. */
50
+ artifactFailureDiagnostic?: string;
45
51
  }
46
52
 
47
53
  function quoteForCompatShell(command: string): string {
@@ -92,6 +98,7 @@ export async function executeSSH(
92
98
  onChunk: options?.onChunk,
93
99
  artifactPath: options?.artifactPath,
94
100
  artifactId: options?.artifactId,
101
+ artifactPublisher: options?.artifactPublisher,
95
102
  headBytes: resolveOutputSinkHeadBytes(settings),
96
103
  maxColumns: resolveOutputMaxColumns(settings),
97
104
  });
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { $which, logger, Snowflake } from "@gajae-code/utils";
4
+ import { $which, logger } from "@gajae-code/utils";
5
5
  import { $ } from "bun";
6
6
 
7
7
  export interface RecordingHandle {
@@ -212,10 +212,29 @@ if (Test-Path $outPath) {
212
212
  }
213
213
  `;
214
214
 
215
+ async function writePowerShellRecordingScript(): Promise<{ tempDir: string; scriptPath: string }> {
216
+ // Random directory plus an exclusive create: a preexisting symlink under the
217
+ // shared temp directory is not opened, and the script is not left world-readable.
218
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-stt-record-"));
219
+ try {
220
+ if (process.platform !== "win32") await fs.chmod(tempDir, 0o700);
221
+ const scriptPath = path.join(tempDir, "record.ps1");
222
+ const handle = await fs.open(scriptPath, "wx", 0o600);
223
+ try {
224
+ await handle.writeFile(PS_RECORD_SCRIPT);
225
+ } finally {
226
+ await handle.close();
227
+ }
228
+ if (process.platform !== "win32") await fs.chmod(scriptPath, 0o600);
229
+ return { tempDir, scriptPath };
230
+ } catch (error) {
231
+ await fs.rm(tempDir, { recursive: true, force: true });
232
+ throw error;
233
+ }
234
+ }
235
+
215
236
  async function startPowerShellRecording(outputPath: string): Promise<RecordingHandle> {
216
- // Write script to temp file — avoids quoting/escaping issues with -Command
217
- const scriptPath = path.join(os.tmpdir(), `gjc-stt-record-${Snowflake.next()}.ps1`);
218
- await Bun.write(scriptPath, PS_RECORD_SCRIPT);
237
+ const { tempDir, scriptPath } = await writePowerShellRecordingScript();
219
238
 
220
239
  const proc = Bun.spawn(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath, outputPath], {
221
240
  stdin: "pipe",
@@ -224,7 +243,7 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
224
243
  });
225
244
 
226
245
  proc.exited.then(() => {
227
- fs.unlink(scriptPath).catch(() => {});
246
+ fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
228
247
  });
229
248
 
230
249
  // Wait for "RECORDING" on stdout to confirm it started
@@ -250,8 +269,7 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
250
269
  if (proc.stderr && typeof proc.stderr !== "number") {
251
270
  stderrText = await new Response(proc.stderr as ReadableStream).text();
252
271
  }
253
- // Clean up temp script
254
- fs.unlink(scriptPath).catch(() => {});
272
+ fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
255
273
  throw new Error(
256
274
  `PowerShell audio recording failed to start: ${stderrText.trim() || output.trim() || "(no output)"}`,
257
275
  );
@@ -269,8 +287,7 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
269
287
  const killTimer = setTimeout(() => proc.kill(), 8000);
270
288
  await proc.exited;
271
289
  clearTimeout(killTimer);
272
- // Clean up temp script
273
- fs.unlink(scriptPath).catch(() => {});
290
+ fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
274
291
  },
275
292
  };
276
293
  }
@@ -75,6 +75,31 @@ function sanitizeEmbeddedPromptContent(content: string): string {
75
75
  return escapePromptMetadata(content, { preserveNewlines: true });
76
76
  }
77
77
 
78
+ const UNTRUSTED_CONTENT_OPEN_LINE = "<untrusted-content>\n";
79
+ const UNTRUSTED_CONTENT_CLOSE_LINE = "\n</untrusted-content>";
80
+
81
+ /**
82
+ * Encode an inventory description. MCP descriptions already include a host-owned
83
+ * untrusted-content fence; keep those delimiters and encode only the body.
84
+ * A body that still contains a closing fence is not that wrapper, so encode the whole value.
85
+ */
86
+ function escapeInventoryDescription(description: string): string {
87
+ const openLength = UNTRUSTED_CONTENT_OPEN_LINE.length;
88
+ const closeLength = UNTRUSTED_CONTENT_CLOSE_LINE.length;
89
+ if (
90
+ description.startsWith(UNTRUSTED_CONTENT_OPEN_LINE) &&
91
+ description.endsWith(UNTRUSTED_CONTENT_CLOSE_LINE) &&
92
+ description.length >= openLength + closeLength
93
+ ) {
94
+ const body = description.slice(openLength, -closeLength);
95
+ if (!/<\/untrusted-content>/i.test(body)) {
96
+ const encodedBody = escapePromptMetadata(body, { preserveNewlines: true });
97
+ return `${UNTRUSTED_CONTENT_OPEN_LINE}${encodedBody}${UNTRUSTED_CONTENT_CLOSE_LINE}`;
98
+ }
99
+ }
100
+ return escapePromptMetadata(description, { preserveNewlines: true });
101
+ }
102
+
78
103
  function firstNonEmpty(...values: (string | undefined | null)[]): string | null {
79
104
  for (const value of values) {
80
105
  const trimmed = value?.trim();
@@ -345,8 +370,9 @@ export async function loadProjectContextFiles(
345
370
  }
346
371
 
347
372
  /**
348
- * Load the effective system prompt customization from SYSTEM.md.
349
- * Project-level SYSTEM.md overrides user-level SYSTEM.md.
373
+ * Load the user system prompt customization from SYSTEM.md.
374
+ * A repository SYSTEM.md is untrusted project text. It must not replace the
375
+ * user file or become the customization block.
350
376
  */
351
377
  export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {}): Promise<string | null> {
352
378
  const resolvedCwd = options.cwd ?? getProjectDir();
@@ -359,11 +385,6 @@ export async function loadSystemPromptFiles(options: LoadContextFilesOptions = {
359
385
 
360
386
  if (result.items.length === 0) return null;
361
387
 
362
- const projectLevel = result.items.find(item => item.level === "project");
363
- if (projectLevel) {
364
- return projectLevel.content;
365
- }
366
-
367
388
  const userLevel = result.items.find(item => item.level === "user");
368
389
  return userLevel?.content ?? null;
369
390
  }
@@ -745,18 +766,31 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
745
766
  const toolPromptNames = new Map<string, string>(toolNames.map(name => [name, tools?.get(name)?.wireName ?? name]));
746
767
  const toolRefs = Object.fromEntries(toolPromptNames.entries());
747
768
  const hasHiddenToolDiscoveryTool = Object.hasOwn(toolRefs, "search_tool_bm25");
748
- const toolInfo = toolNames.map(name => ({
749
- name: toolPromptNames.get(name) ?? name,
750
- internalName: name,
751
- label: tools?.get(name)?.label ?? "",
752
- description: tools?.get(name)?.description ?? "",
753
- }));
769
+ // The inventory template is compiled with noEscape. Encode tool metadata here so a
770
+ // tools/list label or description cannot close <inventory> or insert a system block.
771
+ // Repeated MCP descriptions keep the host <untrusted-content> fence; only the body is encoded.
772
+ // The `tools` list stays raw so {{#has}} still matches the call name.
773
+ const toolInfo = toolNames.map(name => {
774
+ const meta = tools?.get(name);
775
+ return {
776
+ name: escapePromptMetadata(toolPromptNames.get(name) ?? name),
777
+ internalName: escapePromptMetadata(name),
778
+ label: escapePromptMetadata(meta?.label ?? ""),
779
+ description: escapeInventoryDescription(meta?.description ?? ""),
780
+ };
781
+ });
754
782
 
755
- const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
783
+ const rawSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
756
784
  resolvedCustomPrompt,
757
785
  resolvedAppendPrompt,
758
786
  ]);
759
- const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
787
+ // Encode after dedupe so rule matching still sees the file text. The customization
788
+ // template is noEscape; the same encoder used for context files keeps a file from
789
+ // closing <system-prompt-customization> or forging a sibling marker.
790
+ const effectiveSystemPromptCustomization = rawSystemPromptCustomization
791
+ ? sanitizeEmbeddedPromptContent(rawSystemPromptCustomization)
792
+ : "";
793
+ const promptSources = [rawSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
760
794
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
761
795
 
762
796
  const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
@@ -771,6 +805,17 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
771
805
  path: escapePromptMetadata(rule.path),
772
806
  content: sanitizeEmbeddedPromptContent(rule.content),
773
807
  }));
808
+ // Both rulebook templates compile with noEscape. Encode the fields they interpolate
809
+ // so repository frontmatter cannot close <rules> or the name attribute.
810
+ const sanitizedRulebookRules = (rules ?? []).map(rule => ({
811
+ ...rule,
812
+ name: escapePromptMetadata(rule.name),
813
+ description:
814
+ rule.description === undefined
815
+ ? undefined
816
+ : escapePromptMetadata(rule.description, { preserveNewlines: true }),
817
+ globs: rule.globs?.map(glob => escapePromptMetadata(glob)),
818
+ }));
774
819
  const data = {
775
820
  systemPromptCustomization: effectiveSystemPromptCustomization,
776
821
  customPrompt: resolvedCustomPrompt,
@@ -784,7 +829,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
784
829
  contextFiles: sanitizedContextFiles,
785
830
  agentsMdSearch: { files: agentsMdFiles.map(file => escapePromptMetadata(file)) },
786
831
  workspaceTree,
787
- rules: rules ?? [],
832
+ rules: sanitizedRulebookRules,
788
833
  alwaysApplyRules: sanitizedAlwaysApplyRules,
789
834
  cwd: promptCwd,
790
835
  intentTracing: !!intentField,
@@ -76,6 +76,7 @@ import { buildNamedToolChoiceResult } from "../utils/tool-choice";
76
76
  import type { WorkspaceTree } from "../workspace-tree";
77
77
  import { validateAllocatedTaskId } from "./id";
78
78
  import { classifyProviderRetryFromTransport, providerNameFromModel } from "./provider-retry-status";
79
+ import { containSubagentRoleBody } from "./role-body";
79
80
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
80
81
  import { persistTaskTokenLog, taskTokenLogFromUsage } from "./token-log";
81
82
  import {
@@ -2086,14 +2087,15 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise<Singl
2086
2087
  promptTemplates: options.promptTemplates,
2087
2088
  workspaceTree: options.workspaceTree,
2088
2089
  systemPrompt: defaultPrompt => {
2089
- const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
2090
- agent: prompt.render(agent.systemPrompt, {
2091
- // Typed executionMode wins; assignment text is compatibility-only (#2698 / #2456).
2092
- ultragoalRedTeam: resolveUltragoalRedTeamActivation({
2093
- executionMode: options.executionMode,
2094
- assignment: options.assignment ?? task,
2095
- }),
2090
+ const renderedRole = prompt.render(agent.systemPrompt, {
2091
+ // Typed executionMode wins; assignment text is compatibility-only (#2698 / #2456).
2092
+ ultragoalRedTeam: resolveUltragoalRedTeamActivation({
2093
+ executionMode: options.executionMode,
2094
+ assignment: options.assignment ?? task,
2096
2095
  }),
2096
+ });
2097
+ const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
2098
+ agent: containSubagentRoleBody(renderedRole),
2097
2099
  context: options.context?.trim() ?? "",
2098
2100
  worktree: worktree ?? "",
2099
2101
  outputSchema: normalizedOutputSchema,
@@ -2209,7 +2211,9 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise<Singl
2209
2211
  const parentOwnedToolNames = new Set(["todo_write"]);
2210
2212
  const filteredSubagentTools = subagentToolNames.filter(name => !parentOwnedToolNames.has(name));
2211
2213
  if (filteredSubagentTools.length !== subagentToolNames.length) {
2212
- await awaitAbortable(session.setActiveToolsByName(filteredSubagentTools));
2214
+ await awaitAbortable(
2215
+ session.setActiveToolsByName(filteredSubagentTools, { explicit: false, persistMCPSelection: false }),
2216
+ );
2213
2217
  }
2214
2218
 
2215
2219
  if (!options.preflightProbe && !options.preflightDurable) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Keep a subagent role body inside the host [ROLE] block.
3
+ *
4
+ * The body is the agent instruction. It is inserted between host [ROLE] and
5
+ * [/ROLE]. A literal framing tag would end that block early. Only the four
6
+ * host framing names are rewritten; other brackets stay literal.
7
+ *
8
+ * One left-to-right scan: a gap run, a slash only when it is present, then
9
+ * one more gap run, then the word. A non-matching "[" does not hide a later tag.
10
+ */
11
+
12
+ const WORDS = ["completion", "context", "role", "coop"] as const;
13
+
14
+ function isGap(code: number): boolean {
15
+ return (
16
+ code === 0x09 ||
17
+ code === 0x0a ||
18
+ code === 0x0b ||
19
+ code === 0x0c ||
20
+ code === 0x0d ||
21
+ code === 0x20 ||
22
+ code === 0xa0 ||
23
+ code === 0x200b ||
24
+ code === 0x200c ||
25
+ code === 0x200d ||
26
+ code === 0x1680 ||
27
+ (code >= 0x2000 && code <= 0x200a) ||
28
+ code === 0x2028 ||
29
+ code === 0x2029 ||
30
+ code === 0x202f ||
31
+ code === 0x205f ||
32
+ code === 0x3000 ||
33
+ code === 0xfeff
34
+ );
35
+ }
36
+
37
+ function wordLengthAt(body: string, index: number): number {
38
+ const slice = body.slice(index, index + 10).toLowerCase();
39
+ for (const word of WORDS) {
40
+ if (!slice.startsWith(word)) continue;
41
+ const after = body.charCodeAt(index + word.length);
42
+ if (Number.isNaN(after) || isGap(after) || after === 0x5d) return word.length;
43
+ }
44
+ return 0;
45
+ }
46
+
47
+ /** End index of a framing tag starting at "[", or -1. */
48
+ function framingTagEnd(body: string, start: number): number {
49
+ let index = start + 1;
50
+ while (index < body.length && isGap(body.charCodeAt(index))) index++;
51
+ if (body.charCodeAt(index) === 0x2f) {
52
+ index++;
53
+ while (index < body.length && isGap(body.charCodeAt(index))) index++;
54
+ }
55
+ const wordLength = wordLengthAt(body, index);
56
+ if (wordLength === 0) return -1;
57
+ index += wordLength;
58
+ while (index < body.length && isGap(body.charCodeAt(index))) index++;
59
+ return body.charCodeAt(index) === 0x5d ? index + 1 : -1;
60
+ }
61
+
62
+ export function containSubagentRoleBody(body: string): string {
63
+ const parts: string[] = [];
64
+ let plainStart = 0;
65
+ let index = 0;
66
+ while (index < body.length) {
67
+ if (body.charCodeAt(index) !== 0x5b) {
68
+ index++;
69
+ continue;
70
+ }
71
+ const end = framingTagEnd(body, index);
72
+ if (end < 0) {
73
+ index++;
74
+ continue;
75
+ }
76
+ if (plainStart < index) parts.push(body.slice(plainStart, index));
77
+ parts.push(body.slice(index, end).replaceAll("[", "[").replaceAll("]", "]"));
78
+ index = end;
79
+ plainStart = index;
80
+ }
81
+ if (plainStart === 0) return body;
82
+ if (plainStart < body.length) parts.push(body.slice(plainStart));
83
+ return parts.join("");
84
+ }
@@ -0,0 +1,162 @@
1
+ import { afterAll, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { persistTaskTokenLog, taskTokenLogFromChat } from "./token-log";
6
+
7
+ // Mode bits and final-component symlinks are POSIX. Windows ACLs are out of scope.
8
+ const skipPosix = process.platform === "win32";
9
+ const skipOwnerReadOnly = skipPosix || process.getuid?.() === 0;
10
+ const tempRoots: string[] = [];
11
+
12
+ async function tempDir(): Promise<string> {
13
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-token-log-mode-"));
14
+ tempRoots.push(dir);
15
+ return dir;
16
+ }
17
+
18
+ afterAll(async () => {
19
+ await Promise.all(tempRoots.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })));
20
+ });
21
+
22
+ function entry(id: string) {
23
+ return taskTokenLogFromChat(
24
+ { inputTokens: 1, outputTokens: 2, cachedInputTokens: 0, cacheWriteTokens: 0 },
25
+ { subagentId: id, turn: 1, at: "2026-01-01T00:00:00.000Z" },
26
+ );
27
+ }
28
+
29
+ async function modeOf(filePath: string): Promise<number> {
30
+ return (await fs.lstat(filePath)).mode & 0o777;
31
+ }
32
+
33
+ describe("task token log mode", () => {
34
+ it.skipIf(skipPosix)("refuses a symlink token-log.jsonl", async () => {
35
+ const root = await tempDir();
36
+ const outside = path.join(root, "outside.jsonl");
37
+ await fs.writeFile(outside, "kept");
38
+ const dir = path.join(root, "logs");
39
+ await fs.mkdir(dir);
40
+ const link = path.join(dir, "token-log.jsonl");
41
+ await fs.symlink(outside, link);
42
+
43
+ await expect(persistTaskTokenLog(entry("root"), { dir })).rejects.toThrow(/not a regular file/);
44
+ expect(await fs.readFile(outside, "utf8")).toBe("kept");
45
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
46
+ });
47
+
48
+ it.skipIf(skipPosix)("refuses a symlink token-log directory", async () => {
49
+ const root = await tempDir();
50
+ const outside = path.join(root, "outside");
51
+ await fs.mkdir(outside);
52
+ const dir = path.join(root, "token-logs");
53
+ await fs.symlink(outside, dir);
54
+
55
+ await expect(persistTaskTokenLog(entry("root"), { dir })).rejects.toThrow(/not a directory/);
56
+ expect(await fs.readdir(outside)).toEqual([]);
57
+ expect((await fs.lstat(dir)).isSymbolicLink()).toBe(true);
58
+ });
59
+
60
+ it.skipIf(skipPosix)("creates a token log as mode 0600 under a permissive umask", async () => {
61
+ const previous = process.umask(0);
62
+ try {
63
+ const root = await tempDir();
64
+ const dir = path.join(root, "logs");
65
+ await persistTaskTokenLog(entry("root"), { dir });
66
+ expect(await modeOf(dir)).toBe(0o700);
67
+ expect(await modeOf(path.join(dir, "token-log.jsonl"))).toBe(0o600);
68
+ } finally {
69
+ process.umask(previous);
70
+ }
71
+ });
72
+
73
+ it.skipIf(skipPosix)("tightens an existing mode 0666 token log to 0600", async () => {
74
+ const root = await tempDir();
75
+ const dir = path.join(root, "logs");
76
+ await fs.mkdir(dir, { mode: 0o700 });
77
+ const file = path.join(dir, "token-log.jsonl");
78
+ await fs.writeFile(file, "", { mode: 0o666 });
79
+ await fs.chmod(file, 0o666);
80
+
81
+ await persistTaskTokenLog(entry("root"), { dir });
82
+
83
+ expect(await modeOf(file)).toBe(0o600);
84
+ expect(await fs.readFile(file, "utf8")).toContain('"subagentId":"root"');
85
+ });
86
+
87
+ it.skipIf(skipPosix)("tightens an existing mode 0777 token-log directory to 0700", async () => {
88
+ const root = await tempDir();
89
+ const dir = path.join(root, "logs");
90
+ await fs.mkdir(dir, { mode: 0o777 });
91
+ await fs.chmod(dir, 0o777);
92
+
93
+ await persistTaskTokenLog(entry("root"), { dir });
94
+
95
+ expect(await modeOf(dir)).toBe(0o700);
96
+ expect(await modeOf(path.join(dir, "token-log.jsonl"))).toBe(0o600);
97
+ });
98
+
99
+ it("still allows a second append to a regular token log", async () => {
100
+ const root = await tempDir();
101
+ const dir = path.join(root, "logs");
102
+ await persistTaskTokenLog(entry("one"), { dir });
103
+ await persistTaskTokenLog(entry("two"), { dir });
104
+ const text = await fs.readFile(path.join(dir, "token-log.jsonl"), "utf8");
105
+ expect(text).toContain('"subagentId":"one"');
106
+ expect(text).toContain('"subagentId":"two"');
107
+ });
108
+
109
+ it.skipIf(skipPosix)("still allows a token log through a symlinked parent", async () => {
110
+ const root = await tempDir();
111
+ const realParent = path.join(root, "real");
112
+ await fs.mkdir(realParent);
113
+ const via = path.join(root, "via");
114
+ await fs.symlink(realParent, via);
115
+ const dir = path.join(via, "token-logs");
116
+
117
+ await persistTaskTokenLog(entry("via"), { dir });
118
+
119
+ expect(await fs.readFile(path.join(realParent, "token-logs", "token-log.jsonl"), "utf8")).toContain(
120
+ '"subagentId":"via"',
121
+ );
122
+ });
123
+
124
+ it.skipIf(skipPosix)("keeps owner mode 0600 when appending a token log", async () => {
125
+ const root = await tempDir();
126
+ const dir = path.join(root, "logs");
127
+ await fs.mkdir(dir, { mode: 0o700 });
128
+ const file = path.join(dir, "token-log.jsonl");
129
+ await fs.writeFile(file, "", { mode: 0o600 });
130
+
131
+ await persistTaskTokenLog(entry("root"), { dir });
132
+
133
+ expect(await modeOf(file)).toBe(0o600);
134
+ expect(await fs.readFile(file, "utf8")).toContain('"subagentId":"root"');
135
+ });
136
+
137
+ it.skipIf(skipOwnerReadOnly)("still allows a token log in a mode 0300 directory", async () => {
138
+ const root = await tempDir();
139
+ const dir = path.join(root, "logs");
140
+ await fs.mkdir(dir, { mode: 0o700 });
141
+ await fs.chmod(dir, 0o300);
142
+ try {
143
+ await persistTaskTokenLog(entry("root"), { dir });
144
+ expect(await fs.readFile(path.join(dir, "token-log.jsonl"), "utf8")).toContain('"subagentId":"root"');
145
+ } finally {
146
+ await fs.chmod(dir, 0o700);
147
+ }
148
+ });
149
+
150
+ it.skipIf(skipOwnerReadOnly)("leaves a mode 0400 token log unchanged when the append cannot write", async () => {
151
+ const root = await tempDir();
152
+ const dir = path.join(root, "logs");
153
+ await fs.mkdir(dir, { mode: 0o700 });
154
+ const file = path.join(dir, "token-log.jsonl");
155
+ await fs.writeFile(file, "kept\n", { mode: 0o600 });
156
+ await fs.chmod(file, 0o400);
157
+
158
+ await expect(persistTaskTokenLog(entry("root"), { dir })).rejects.toThrow(/EACCES/);
159
+ expect(await fs.readFile(file, "utf8")).toBe("kept\n");
160
+ expect(await modeOf(file)).toBe(0o400);
161
+ });
162
+ });