@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
package/src/tools/ask.ts CHANGED
@@ -953,6 +953,7 @@ export class AskTool implements AgentTool<AskParametersSchema, AskToolDetails> {
953
953
  this.session.cwd,
954
954
  { sessionId: this.session.getSessionId?.() ?? null },
955
955
  this.session.getSessionAgentDir?.() ?? this.session.settings.getAgentDir(),
956
+ params,
956
957
  );
957
958
  assertDeepInterviewStructuredResponseWithinLimit(params);
958
959
  // Recheck at execution time as well: direct SDK callers and a phase change
@@ -11,6 +11,7 @@ import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text
11
11
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
13
13
  import type { ToolSession } from ".";
14
+ import { wrapUntrustedContent } from "./fetch";
14
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
15
16
  import { formatGroupedFiles } from "./grouped-file-output";
16
17
  import { formatMatchLine } from "./match-line-format";
@@ -221,7 +222,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
221
222
  const parseMessage = cappedParseErrors.length
222
223
  ? `\n${formatParseErrors(cappedParseErrors, parseErrorsTotal).join("\n")}`
223
224
  : "";
224
- return toolResult(baseDetails).text(`${noMatchMessage}${parseMessage}`).done();
225
+ return toolResult(baseDetails)
226
+ .text(wrapUntrustedContent(`${noMatchMessage}${parseMessage}`))
227
+ .done();
225
228
  }
226
229
 
227
230
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
@@ -291,7 +294,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
291
294
  outputLines.push("", ...formatParseErrors(cappedParseErrors, parseErrorsTotal));
292
295
  }
293
296
 
294
- return toolResult(details).text(outputLines.join("\n")).done();
297
+ return toolResult(details)
298
+ .text(wrapUntrustedContent(outputLines.join("\n")))
299
+ .done();
295
300
  });
296
301
  }
297
302
  }
@@ -212,8 +212,14 @@ function extractLeadingHeaderCommentText(content: string, commentStyles: readonl
212
212
  * Check if a file name indicates it might be auto-generated.
213
213
  * This is a quick pre-check before reading file content.
214
214
  */
215
+ function guardFileName(filePath: string): string {
216
+ // resolvePlanPath keeps the platform separator. Anchored patterns such as
217
+ // zz_generated.* and generated.ts must see that basename on Windows too.
218
+ return path.win32.basename(filePath);
219
+ }
220
+
215
221
  function isAutoGeneratedFileName(filePath: string): boolean {
216
- const fileName = filePath.split("/").pop() ?? "";
222
+ const fileName = guardFileName(filePath);
217
223
  return AUTO_GENERATED_FILENAME_PATTERNS.some(pattern => pattern.test(fileName));
218
224
  }
219
225
 
@@ -254,11 +260,14 @@ const autoGeneratedMap = new LRUCache<string, { marker: string | undefined }>({
254
260
 
255
261
  async function getAutoGeneratedMarker(filePath: string): Promise<string | undefined> {
256
262
  if (isAutoGeneratedFileName(filePath)) {
257
- return filePath.split("/").pop() ?? "";
263
+ return guardFileName(filePath);
258
264
  }
259
265
 
260
266
  const cached = autoGeneratedMap.get(filePath);
261
- if (cached) return cached.marker;
267
+ // A cached miss only means the file was handwritten at the last read.
268
+ // An earlier patch entry can write a generated header before a later
269
+ // rename checks the same destination, so do not trust that miss.
270
+ if (cached && cached.marker !== undefined) return cached.marker;
262
271
 
263
272
  let marker: string | undefined;
264
273
  try {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Detects a running command that printed an interactive auth prompt and is now
3
+ * waiting for a human (browser sign-in URL or device code).
4
+ *
5
+ * The agent cannot see this on its own: the tool call is simply still running.
6
+ * The detector watches the streamed output tail and fires once per command when
7
+ * all of these hold:
8
+ * - a known auth-prompt signature is present in the recent output tail;
9
+ * - the command is still running (the caller stops feeding it on exit);
10
+ * - no new output arrived for `quietMs` after the match.
11
+ *
12
+ * Requiring the quiet window keeps `cat README.md` or `grep` over docs that
13
+ * merely mention "enter the code" from firing: those commands exit or keep
14
+ * printing. The signal carries only the prompt kind, never the URL or code,
15
+ * because both are short-lived credential-equivalent values.
16
+ */
17
+ import { stripVTControlCharacters } from "node:util";
18
+
19
+ export type AwaitingInputKind = "browser_auth" | "device_code";
20
+
21
+ export interface AwaitingInputSignal {
22
+ kind: AwaitingInputKind;
23
+ /** Epoch milliseconds when the quiet window elapsed after the match. */
24
+ detectedAt: number;
25
+ }
26
+
27
+ /** Cancels a scheduled callback. */
28
+ export type CancelScheduled = () => void;
29
+
30
+ export interface AwaitingInputDetectorOptions {
31
+ onDetect: (signal: AwaitingInputSignal) => void;
32
+ /** Quiet window after a match before the signal fires. */
33
+ quietMs?: number;
34
+ /** Injectable scheduler; defaults to `setTimeout`. */
35
+ schedule?: (callback: () => void, ms: number) => CancelScheduled;
36
+ /** Injectable clock; defaults to `Date.now`. */
37
+ now?: () => number;
38
+ }
39
+
40
+ export const AWAITING_INPUT_QUIET_MS = 3_000;
41
+
42
+ /** Only the most recent output can be the prompt the process is blocked on. */
43
+ const TAIL_CHARS = 2_048;
44
+
45
+ const URL_PATTERN = /\bhttps?:\/\/\S+/i;
46
+
47
+ const DEVICE_CODE_PATTERNS: readonly RegExp[] = [
48
+ /\bone-time code\b/i,
49
+ /\benter the code\b/i,
50
+ /\benter code\b/i,
51
+ /\buser_code=/i,
52
+ ];
53
+
54
+ const BROWSER_AUTH_PATTERNS: readonly RegExp[] = [
55
+ /\bto authenticate,? visit\b/i,
56
+ /\blogin\.tailscale\.com\/a\//i,
57
+ /\bgo to the following link in your browser\b/i,
58
+ /\bopen the following url\b/i,
59
+ /\bvisit this url to authori[sz]e\b/i,
60
+ /\bopen (?:this|the) (?:url|link) in (?:a|your) browser\b/i,
61
+ ];
62
+
63
+ /** Classify an output tail; `undefined` when it does not look like an auth prompt. */
64
+ export function classifyAwaitingInput(tail: string): AwaitingInputKind | undefined {
65
+ const text = stripVTControlCharacters(tail);
66
+ if (DEVICE_CODE_PATTERNS.some(pattern => pattern.test(text))) return "device_code";
67
+ if (URL_PATTERN.test(text) && BROWSER_AUTH_PATTERNS.some(pattern => pattern.test(text))) return "browser_auth";
68
+ return undefined;
69
+ }
70
+
71
+ function defaultSchedule(callback: () => void, ms: number): CancelScheduled {
72
+ const timer = setTimeout(callback, ms);
73
+ return () => clearTimeout(timer);
74
+ }
75
+
76
+ export class AwaitingInputDetector {
77
+ readonly #onDetect: (signal: AwaitingInputSignal) => void;
78
+ readonly #quietMs: number;
79
+ readonly #schedule: (callback: () => void, ms: number) => CancelScheduled;
80
+ readonly #now: () => number;
81
+ #tail = "";
82
+ #cancel: CancelScheduled | undefined;
83
+ #done = false;
84
+
85
+ constructor(options: AwaitingInputDetectorOptions) {
86
+ this.#onDetect = options.onDetect;
87
+ this.#quietMs = options.quietMs ?? AWAITING_INPUT_QUIET_MS;
88
+ this.#schedule = options.schedule ?? defaultSchedule;
89
+ this.#now = options.now ?? Date.now;
90
+ }
91
+
92
+ /** Feed one streamed output chunk. Any new output restarts the quiet window. */
93
+ push(chunk: string): void {
94
+ if (this.#done || chunk.length === 0) return;
95
+ this.#cancel?.();
96
+ this.#cancel = undefined;
97
+ this.#tail = (this.#tail + chunk).slice(-TAIL_CHARS);
98
+ const kind = classifyAwaitingInput(this.#tail);
99
+ if (!kind) return;
100
+ this.#cancel = this.#schedule(() => {
101
+ this.#cancel = undefined;
102
+ if (this.#done) return;
103
+ this.#done = true;
104
+ this.#onDetect({ kind, detectedAt: this.#now() });
105
+ }, this.#quietMs);
106
+ }
107
+
108
+ /** Stop watching; the command exited or was folded. Never fires afterwards. */
109
+ dispose(): void {
110
+ this.#done = true;
111
+ this.#cancel?.();
112
+ this.#cancel = undefined;
113
+ }
114
+ }
@@ -280,7 +280,9 @@ function shellQuote(value: string): string {
280
280
  }
281
281
 
282
282
  function resolvedExternalCommand(command: string): string | undefined {
283
- return Bun.which(command) ?? undefined;
283
+ // Bun.which caches a command name until PATH is passed explicitly, so a test
284
+ // or a later session that changes PATH must be visible here.
285
+ return Bun.which(command, { PATH: process.env.PATH ?? "" }) ?? undefined;
284
286
  }
285
287
 
286
288
  function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesCheck {
@@ -322,16 +324,50 @@ function validateReadOnlyCommand(words: readonly string[]): BashAllowedPrefixesC
322
324
  return { allowed: true };
323
325
  }
324
326
 
327
+ const GIT_DIFF_FAMILY_SUBCOMMANDS = new Set(["blame", "diff", "log", "show"]);
328
+
329
+ function gitOptionName(word: string): string {
330
+ const equalsAt = word.indexOf("=");
331
+ return equalsAt === -1 ? word : word.slice(0, equalsAt);
332
+ }
333
+
334
+ /** Enabling forms only. `--no-ext-diff` and `--no-textconv` stay allowed. */
335
+ function gitEnablesExternalHelper(word: string): boolean {
336
+ if (!word.startsWith("--")) return false;
337
+ const name = gitOptionName(word);
338
+ return name === "--ext-diff" || name === "--textconv";
339
+ }
340
+
325
341
  function rejectGitHostAccess(words: readonly string[]): BashAllowedPrefixesCheck | undefined {
326
342
  if (words[0] !== "git") return undefined;
327
343
  for (const option of optionWords(words)) {
328
344
  if (isLongOption(option, "--output") || isLongOption(option, "--no-index")) {
329
345
  return { allowed: false, reason: "restricted bash does not allow git --output or --no-index" };
330
346
  }
347
+ if (gitEnablesExternalHelper(option)) {
348
+ return {
349
+ allowed: false,
350
+ reason: "restricted bash does not allow git external diff or textconv helpers",
351
+ };
352
+ }
331
353
  }
332
354
  return undefined;
333
355
  }
334
356
 
357
+ /**
358
+ * Diff-family git commands run `diff.external`, `GIT_EXTERNAL_DIFF`, and textconv
359
+ * filters unless these flags are present. Place them immediately after the
360
+ * subcommand so a later value-taking option cannot consume them and they stay
361
+ * before a `--` pathspec. The gate rejects a later `--ext-diff` or `--textconv`,
362
+ * which would otherwise win.
363
+ */
364
+ function disableGitExternalHelpers(commandName: string, argv: readonly string[]): string[] {
365
+ if (commandName !== "git" || !GIT_DIFF_FAMILY_SUBCOMMANDS.has(argv[1] ?? "")) return [...argv];
366
+ const next = [...argv];
367
+ next.splice(2, 0, "--no-ext-diff", "--no-textconv");
368
+ return next;
369
+ }
370
+
335
371
  const GJC_SESSION_BIND_REASON = "restricted role-agent bash binds gjc to the current session";
336
372
  const GJC_WORKTREE_BIND_REASON = "restricted role-agent bash binds gjc --worktree-root to the current repository";
337
373
 
@@ -472,6 +508,30 @@ function commandAllowedPrefixesReason(normalizedPrefixes: readonly string[], opt
472
508
  : `restricted role-agent bash only allows commands starting with: ${prefixList}`;
473
509
  }
474
510
 
511
+ /**
512
+ * Workflow restricted bash keeps the lexical prefix check, then runs the resolved
513
+ * executable. Callers also skip the shell snapshot and shell prefix, because a
514
+ * snapshot function can be named for that absolute path.
515
+ */
516
+ export function normalizeWorkflowBashCommand(
517
+ command: string,
518
+ allowedPrefixes: readonly string[] | undefined,
519
+ options: BashRestrictionOptions = {},
520
+ ): string | undefined {
521
+ if (options.profile === "read-only") return undefined;
522
+ const gate = checkBashAllowedPrefixes(command, allowedPrefixes, { ...options, profile: "workflow" });
523
+ if (!gate.allowed) return undefined;
524
+ const parsed = parseShellWords(command.trim());
525
+ if (parsed.reason || parsed.words.length === 0) return undefined;
526
+ const [head, ...rest] = parsed.words;
527
+ if (!head) return undefined;
528
+ const resolvedHead = resolvedExternalCommand(head);
529
+ if (!resolvedHead) return undefined;
530
+ return disableGitExternalHelpers(head, [resolvedHead, ...rest])
531
+ .map(shellQuote)
532
+ .join(" ");
533
+ }
534
+
475
535
  export function normalizeReadOnlyBashCommand(command: string): string | undefined {
476
536
  const parsed = parseShellWords(command.trim());
477
537
  if (parsed.reason || parsed.words.length === 0) return undefined;
@@ -1,3 +1,4 @@
1
+ import type * as fsTypes from "node:fs";
1
2
  import * as fs from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { isEnoent } from "@gajae-code/utils";
@@ -40,7 +41,7 @@ export interface InternalUrlExpansionOptions {
40
41
  * Resolve a single skill:// URL to its absolute filesystem path.
41
42
  * Does NOT read file content or verify existence.
42
43
  */
43
- export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): string {
44
+ export function resolveSkillUrl(url: string, skills: readonly Skill[]): { skill: Skill; lexicalPath: string } {
44
45
  const parsed = /^skill:\/\/([^/?#]+)(\/[^?#]*)?(?:[?#].*)?$/.exec(url);
45
46
  if (!parsed) {
46
47
  throw new ToolError(`Invalid skill:// URL: ${url}`);
@@ -72,7 +73,7 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
72
73
  const hasRelativePath = rawPath !== "" && rawPath !== "/";
73
74
 
74
75
  if (!hasRelativePath) {
75
- return path.resolve(skill.filePath);
76
+ return { skill, lexicalPath: path.resolve(skill.filePath) };
76
77
  }
77
78
 
78
79
  let relativePath: string;
@@ -95,7 +96,11 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
95
96
  throw new ToolError("Path traversal is not allowed in skill:// URLs");
96
97
  }
97
98
 
98
- return resolvedPath;
99
+ return { skill, lexicalPath: resolvedPath };
100
+ }
101
+
102
+ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): string {
103
+ return resolveSkillUrl(url, skills).lexicalPath;
99
104
  }
100
105
 
101
106
  /**
@@ -150,7 +155,7 @@ async function canonicalLocalPath(localRoot: string, lexicalPath: string): Promi
150
155
  const missing: string[] = [];
151
156
  let cursor = lexicalPath;
152
157
  for (;;) {
153
- let stat: Awaited<ReturnType<typeof fs.lstat>> | undefined;
158
+ let stat: fsTypes.Stats | undefined;
154
159
  try {
155
160
  stat = await fs.lstat(cursor);
156
161
  } catch (error) {
@@ -177,6 +182,53 @@ async function canonicalLocalPath(localRoot: string, lexicalPath: string): Promi
177
182
  }
178
183
  }
179
184
 
185
+ const SKILL_ESCAPE = "Path traversal is not allowed in skill:// URLs";
186
+
187
+ /** Real path of `target`, or the real ancestor plus the missing suffix. */
188
+ async function canonicalPathOrAncestor(target: string, escapeMessage: string): Promise<string> {
189
+ try {
190
+ return await fs.realpath(target);
191
+ } catch (error) {
192
+ if (!isEnoent(error)) throw error;
193
+ }
194
+
195
+ const missing: string[] = [];
196
+ let cursor = target;
197
+ for (;;) {
198
+ let stat: fsTypes.Stats | undefined;
199
+ try {
200
+ stat = await fs.lstat(cursor);
201
+ } catch (error) {
202
+ if (!isEnoent(error)) throw error;
203
+ }
204
+ if (stat) {
205
+ try {
206
+ return path.join(await fs.realpath(cursor), ...missing);
207
+ } catch (error) {
208
+ if (isEnoent(error)) throw new ToolError(escapeMessage);
209
+ throw error;
210
+ }
211
+ }
212
+ const parent = path.dirname(cursor);
213
+ if (parent === cursor) throw new ToolError(escapeMessage);
214
+ missing.unshift(path.basename(cursor));
215
+ cursor = parent;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Keep the lexical skill path, then bind the shell to the real path inside the skill directory.
221
+ * A missing file under a real in-skill ancestor still expands.
222
+ */
223
+ async function canonicalSkillExpansionPath(baseDir: string, lexicalPath: string): Promise<string> {
224
+ const realBase = await canonicalPathOrAncestor(baseDir, SKILL_ESCAPE);
225
+ const realTarget = await canonicalPathOrAncestor(lexicalPath, SKILL_ESCAPE);
226
+ if (localPathEscapes(realBase, realTarget)) {
227
+ throw new ToolError(SKILL_ESCAPE);
228
+ }
229
+ return realTarget;
230
+ }
231
+
180
232
  function extractScheme(url: string): SupportedInternalScheme | undefined {
181
233
  const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url);
182
234
  if (!match) return undefined;
@@ -211,7 +263,8 @@ async function resolveInternalUrlToPath(
211
263
  }
212
264
 
213
265
  if (scheme === "skill") {
214
- return resolveSkillUrlToPath(url, skills);
266
+ const { skill, lexicalPath } = resolveSkillUrl(url, skills);
267
+ return canonicalSkillExpansionPath(skill.baseDir, lexicalPath);
215
268
  }
216
269
 
217
270
  // Managed artifact and agent resources carry session authority. Expanding
package/src/tools/bash.ts CHANGED
@@ -64,7 +64,12 @@ import { CachedOutputBlock, getOutputBlockContentWidth } from "../tui/output-blo
64
64
  import { truncateToWidth } from "../tui/utils";
65
65
  import { getSixelLineMask } from "../utils/sixel";
66
66
  import type { ToolSession } from ".";
67
- import { checkBashAllowedPrefixes, normalizeReadOnlyBashCommand } from "./bash-allowed-prefixes";
67
+ import { AwaitingInputDetector, type AwaitingInputSignal } from "./awaiting-input";
68
+ import {
69
+ checkBashAllowedPrefixes,
70
+ normalizeReadOnlyBashCommand,
71
+ normalizeWorkflowBashCommand,
72
+ } from "./bash-allowed-prefixes";
68
73
  import { applyBashFixups } from "./bash-command-fixup";
69
74
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
70
75
  import { checkBashInterception } from "./bash-interceptor";
@@ -578,6 +583,12 @@ export interface BashToolDetails {
578
583
  foldReason?: FoldReason;
579
584
  /** The accepted model-declared activity declaration, when the call carried one. */
580
585
  activity?: BashActivityDeclaration;
586
+ /**
587
+ * Set on a running (partial) update when the foreground command printed an
588
+ * interactive auth prompt and then went quiet. Carries only the prompt kind,
589
+ * never the URL or code.
590
+ */
591
+ awaitingInput?: AwaitingInputSignal;
581
592
  async?: {
582
593
  state: "running" | "completed" | "failed";
583
594
  jobId: string;
@@ -1125,6 +1136,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1125
1136
  const spillThreshold = resolveBashOutputSinkTailBytes(this.session.settings);
1126
1137
  const headBytes = resolveBashOutputSinkHeadBytes(this.session.settings);
1127
1138
  const tailBuffer = new TailBuffer(spillThreshold);
1139
+ // Only a foreground wait blocks the turn; once folded or started in the
1140
+ // background the agent is free and this tool call has already settled.
1141
+ const awaitingInput = new AwaitingInputDetector({
1142
+ onDetect: signal => {
1143
+ if (backgrounded) return;
1144
+ void options.onUpdate?.({
1145
+ content: [{ type: "text", text: latestText }],
1146
+ details: { ...activityDetails, awaitingInput: signal },
1147
+ });
1148
+ },
1149
+ });
1128
1150
 
1129
1151
  let executionResult: BashResult | BashInteractiveResult | undefined;
1130
1152
  try {
@@ -1148,18 +1170,24 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1148
1170
  spillThreshold,
1149
1171
  headBytes,
1150
1172
  oneShot: true,
1151
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only",
1152
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only",
1173
+ ignoreShellPrefix: this.#isolateRestrictedShell(),
1174
+ disableShellSnapshot: this.#isolateRestrictedShell(),
1153
1175
  onChunk: chunk => {
1154
1176
  tailBuffer.append(chunk);
1155
1177
  latestText = tailBuffer.text();
1156
1178
  void reportProgress(latestText, runningDetails(jobId));
1157
1179
  },
1158
- onRawChunk: chunk => manager.appendOutput(jobId, chunk),
1180
+ // onChunk is throttled and drops chunks inside the window; the
1181
+ // detector must see every chunk, so it reads the raw stream.
1182
+ onRawChunk: chunk => {
1183
+ manager.appendOutput(jobId, chunk);
1184
+ awaitingInput.push(chunk);
1185
+ },
1159
1186
  onMinimizedSave: async originalText =>
1160
1187
  saveBashOriginalArtifact(options.artifactPublication, originalText),
1161
1188
  });
1162
1189
  executionResult = result;
1190
+ awaitingInput.dispose();
1163
1191
  const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
1164
1192
  requestedTimeoutSec: options.requestedTimeoutSec,
1165
1193
  notices: options.notices,
@@ -1173,6 +1201,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1173
1201
  await reportProgress(finalText, completedDetails(jobId));
1174
1202
  return finalText;
1175
1203
  } catch (error) {
1204
+ awaitingInput.dispose();
1176
1205
  const message = error instanceof Error ? error.message : String(error);
1177
1206
  latestText = message;
1178
1207
  options.onCompletion?.();
@@ -1323,6 +1352,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1323
1352
  * helper after `AgentSession` has applied the public-tool permission gate, so
1324
1353
  * Monitor inherits Bash's cwd / env / artifact / interceptor pipeline 1:1.
1325
1354
  */
1355
+ /** Read-only and workflow allowlists must not source a snapshot or a shell prefix. */
1356
+ #isolateRestrictedShell(): boolean {
1357
+ return this.session.bashRestrictionProfile === "read-only" || (this.session.bashAllowedPrefixes?.length ?? 0) > 0;
1358
+ }
1359
+
1326
1360
  async #prepareBashExecution(
1327
1361
  input: { command: string; env?: Record<string, string>; timeout?: number; cwd?: string },
1328
1362
  ctx?: AgentToolContext,
@@ -1447,19 +1481,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1447
1481
  ...internalUrlOptions,
1448
1482
  ensureLocalParentDirs: this.session.bashRestrictionProfile !== "read-only",
1449
1483
  });
1450
- // Issue #5039: refuse tmux write verbs whose target resolves to this
1451
- // agent's own tmux pane — such bytes forge a human `user` turn in the
1452
- // transcript. This is after command expansion but before any spawn, so
1453
- // blocked attempts inject no bytes and create no transcript turn.
1454
- if (this.session.bashRestrictionProfile !== "read-only") {
1455
- const injection = await checkTmuxSelfInjection(command, {
1456
- env: process.env,
1457
- cwd: this.session.cwd,
1458
- });
1459
- if (injection.block) {
1460
- throw new ToolError(injection.reason ?? "Blocked: tmux self-pane injection.");
1461
- }
1462
- }
1463
1484
  const expandedEnv = env
1464
1485
  ? Object.fromEntries(
1465
1486
  await Promise.all(
@@ -1541,6 +1562,45 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1541
1562
  if (this.session.bashRestrictionProfile === "read-only" || (allowedPrefixes?.length ?? 0) > 0) {
1542
1563
  await assertCwdInsideWorkspace(this.session.cwd, commandCwd);
1543
1564
  }
1565
+ // Issue #5039: refuse tmux write verbs whose target resolves to this
1566
+ // agent's own tmux pane — such bytes forge a human `user` turn in the
1567
+ // transcript. This is after command/env/cwd preparation but before any
1568
+ // spawn, so guard discovery matches execution without trusting caller env
1569
+ // for the agent's tmux identity.
1570
+ if (this.session.bashRestrictionProfile !== "read-only") {
1571
+ const shellConfigEnv = this.session.settings.getShellConfig().env;
1572
+ const injection = await checkTmuxSelfInjection(command, {
1573
+ env: process.env,
1574
+ cwd: commandCwd,
1575
+ executionEnv: { ...process.env, ...shellConfigEnv, ...resolvedEnv },
1576
+ });
1577
+ if (injection.block) {
1578
+ throw new ToolError(injection.reason ?? "Blocked: tmux self-pane injection.");
1579
+ }
1580
+ }
1581
+ // Direct master spawn parses the original command itself and does not source
1582
+ // a shell snapshot. Every other restricted workflow command is rebound here,
1583
+ // after the allowlist and interceptor have seen the caller's text.
1584
+ if (
1585
+ !directMasterSpawn &&
1586
+ this.session.bashRestrictionProfile !== "read-only" &&
1587
+ allowedPrefixes &&
1588
+ allowedPrefixes.length > 0
1589
+ ) {
1590
+ const normalizedWorkflowCommand = normalizeWorkflowBashCommand(command, allowedPrefixes, {
1591
+ profile: "workflow",
1592
+ gjcSessionBinding: {
1593
+ sessionId: this.session.getSessionId?.() ?? null,
1594
+ credentialSessionId: this.session.getCredentialSessionId?.() ?? null,
1595
+ worktreeRoot: this.session.cwd,
1596
+ invocationCwd: commandCwd,
1597
+ },
1598
+ });
1599
+ if (!normalizedWorkflowCommand) {
1600
+ throw new ToolError("Restricted role-agent bash command could not be normalized safely.");
1601
+ }
1602
+ command = normalizedWorkflowCommand;
1603
+ }
1544
1604
 
1545
1605
  const requestedTimeoutSec = input.timeout ?? 300;
1546
1606
  const timeoutSec = clampTimeout("bash", requestedTimeoutSec);
@@ -1724,9 +1784,8 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
1724
1784
  spillThreshold,
1725
1785
  headBytes,
1726
1786
  oneShot: true,
1727
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1728
- disableShellSnapshot:
1729
- this.session.bashRestrictionProfile === "read-only" || prepared.directMasterSpawn,
1787
+ ignoreShellPrefix: this.#isolateRestrictedShell() || prepared.directMasterSpawn,
1788
+ disableShellSnapshot: this.#isolateRestrictedShell() || prepared.directMasterSpawn,
1730
1789
  onChunk: chunk => {
1731
1790
  tailBuffer.append(chunk);
1732
1791
  void reportProgress(tailBuffer.text(), {
@@ -2651,6 +2710,14 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2651
2710
 
2652
2711
  // Track output for streaming updates (tail only)
2653
2712
  const tailBuffer = new TailBuffer(spillThreshold);
2713
+ const awaitingInput = new AwaitingInputDetector({
2714
+ onDetect: signal => {
2715
+ void onUpdate?.({
2716
+ content: [{ type: "text", text: tailBuffer.text() }],
2717
+ details: { ...(activity === undefined ? {} : { activity }), awaitingInput: signal },
2718
+ });
2719
+ },
2720
+ });
2654
2721
 
2655
2722
  const artifactPublisher = createBashArtifactPublisher(artifactPublication);
2656
2723
 
@@ -2833,11 +2900,15 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2833
2900
  onOutput: chunk => {
2834
2901
  ptyOutputSeen = true;
2835
2902
  if (ptyJobId) ownedManager?.appendOutput(ptyJobId, chunk);
2903
+ awaitingInput.push(chunk);
2836
2904
  },
2837
- }).catch(error => {
2838
- if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
2839
- throw error;
2840
2905
  })
2906
+ .catch(error => {
2907
+ if (ptyAdmission) ownedManager?.releaseCapacity(ptyAdmission);
2908
+ throw error;
2909
+ })
2910
+ // Completion, error, and a fold all settle this foreground wait.
2911
+ .finally(() => awaitingInput.dispose())
2841
2912
  : await executeBash(command, {
2842
2913
  cwd: commandCwd,
2843
2914
  settings: this.session.settings,
@@ -2851,10 +2922,12 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
2851
2922
  spillThreshold,
2852
2923
  headBytes,
2853
2924
  onChunk: streamTailUpdates(tailBuffer, onUpdate),
2925
+ // onChunk is throttled and drops chunks; the detector needs all of them.
2926
+ onRawChunk: chunk => awaitingInput.push(chunk),
2854
2927
  onMinimizedSave: async originalText => saveBashOriginalArtifact(artifactPublication, originalText),
2855
- ignoreShellPrefix: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2856
- disableShellSnapshot: this.session.bashRestrictionProfile === "read-only" || directMasterSpawn,
2857
- });
2928
+ ignoreShellPrefix: this.#isolateRestrictedShell() || directMasterSpawn,
2929
+ disableShellSnapshot: this.#isolateRestrictedShell() || directMasterSpawn,
2930
+ }).finally(() => awaitingInput.dispose());
2858
2931
  ptyFoldUnregister?.();
2859
2932
  // Folded: the foreground was settled by the fold, so return the background
2860
2933
  // -start result verbatim instead of presenting a half-finished command.
@@ -3,7 +3,6 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
5
  import { untilAborted } from "@gajae-code/utils/abortable";
6
- import { Snowflake } from "@gajae-code/utils/snowflake";
7
6
  import type { HTMLElement } from "linkedom";
8
7
  import type {
9
8
  Browser,
@@ -414,6 +413,31 @@ interface ActiveRun {
414
413
  pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
415
414
  }
416
415
 
416
+ async function writePrivateScreenshot(bytes: Uint8Array): Promise<string> {
417
+ // Random directory plus an exclusive create: a preexisting symlink under the
418
+ // shared temp directory is not opened, and the screenshot is not left world-readable.
419
+ // Create the temp root first: base created it with a recursive mkdir, and
420
+ // mkdtemp fails when that root does not exist yet. Do not change the root's mode.
421
+ const root = os.tmpdir();
422
+ await fs.promises.mkdir(root, { recursive: true });
423
+ const tempDir = await fs.promises.mkdtemp(path.join(root, "gjc-sshots-"));
424
+ try {
425
+ if (process.platform !== "win32") await fs.promises.chmod(tempDir, 0o700);
426
+ const dest = path.join(tempDir, "screenshot.png");
427
+ const handle = await fs.promises.open(dest, "wx", 0o600);
428
+ try {
429
+ await handle.writeFile(bytes);
430
+ } finally {
431
+ await handle.close();
432
+ }
433
+ if (process.platform !== "win32") await fs.promises.chmod(dest, 0o600);
434
+ return dest;
435
+ } catch (error) {
436
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
437
+ throw error;
438
+ }
439
+ }
440
+
417
441
  export class WorkerCore {
418
442
  #transport: Transport;
419
443
  #browser?: Browser;
@@ -852,20 +876,22 @@ export class WorkerCore {
852
876
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
853
877
  );
854
878
  const explicitPath = opts.save ? resolveTabWorkerPath(opts.save, session.cwd) : undefined;
855
-
856
- const dest =
857
- explicitPath ??
858
- (session.browserScreenshotDir
859
- ? path.join(
860
- session.browserScreenshotDir,
861
- `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`,
862
- )
863
- : path.join(os.tmpdir(), `gjc-sshots-${Snowflake.next()}.png`));
864
- await fs.promises.mkdir(path.dirname(dest), { recursive: true });
865
- const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
879
+ const configuredDir = session.browserScreenshotDir;
880
+ const saveFullRes = !!(explicitPath || configuredDir);
866
881
  const savedBuffer = saveFullRes ? buffer : resized.buffer;
867
882
  const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
868
- await Bun.write(dest, savedBuffer);
883
+ // Caller-chosen destinations stay as they were. Only the shared temp
884
+ // fallback is an exclusive private file: a preexisting symlink at the old
885
+ // predictable name is not opened, and the bytes are not left world-readable.
886
+ const dest = explicitPath
887
+ ? explicitPath
888
+ : configuredDir
889
+ ? path.join(configuredDir, `screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.png`)
890
+ : await writePrivateScreenshot(savedBuffer);
891
+ if (explicitPath || configuredDir) {
892
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
893
+ await Bun.write(dest, savedBuffer);
894
+ }
869
895
  const info: ScreenshotResult = {
870
896
  dest,
871
897
  mimeType: savedMimeType,