@gajae-code/coding-agent 0.11.6 → 0.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (259) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +0 -2
  5. package/dist/types/commands/daemon.d.ts +2 -2
  6. package/dist/types/commands/sdk.d.ts +20 -2
  7. package/dist/types/config/keybindings.d.ts +34 -4
  8. package/dist/types/config/model-profile-activation.d.ts +4 -3
  9. package/dist/types/config/model-profile-contract.d.ts +38 -0
  10. package/dist/types/config/settings-schema.d.ts +70 -7
  11. package/dist/types/edit/modes/patch.d.ts +13 -0
  12. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  16. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  17. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +6 -5
  18. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  19. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +43 -5
  20. package/dist/types/main.d.ts +32 -1
  21. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  22. package/dist/types/modes/components/model-selector.d.ts +2 -0
  23. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/tool-status-header.d.ts +4 -2
  25. package/dist/types/modes/components/welcome.d.ts +2 -0
  26. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  29. package/dist/types/modes/interactive-mode.d.ts +7 -2
  30. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  31. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  32. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  33. package/dist/types/modes/types.d.ts +4 -0
  34. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  35. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/sdk/broker/broker.d.ts +2 -0
  40. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  41. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -1
  42. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  43. package/dist/types/sdk/bus/config.d.ts +2 -0
  44. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  45. package/dist/types/sdk/bus/index.d.ts +26 -1
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  49. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  50. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +18 -3
  51. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  52. package/dist/types/sdk/bus/telegram-daemon.d.ts +189 -4
  53. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  54. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  55. package/dist/types/sdk/client/discovery.d.ts +2 -0
  56. package/dist/types/sdk/client/liveness.d.ts +7 -0
  57. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  58. package/dist/types/sdk/host/host.d.ts +1 -1
  59. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  60. package/dist/types/sdk/index.d.ts +2 -0
  61. package/dist/types/sdk/prompt-status.d.ts +88 -0
  62. package/dist/types/sdk/session-directory.d.ts +1 -1
  63. package/dist/types/sdk/startup-capability.d.ts +3 -0
  64. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  65. package/dist/types/sdk/transport/index.d.ts +12 -0
  66. package/dist/types/sdk/transport/relay.d.ts +43 -0
  67. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  68. package/dist/types/sdk/transport/socket.d.ts +7 -0
  69. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  70. package/dist/types/session/agent-session.d.ts +30 -5
  71. package/dist/types/session/btw-contract.d.ts +16 -0
  72. package/dist/types/session/internal/managed-session-scope.d.ts +32 -9
  73. package/dist/types/session/internal/managed-session-storage.d.ts +27 -10
  74. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  75. package/dist/types/session/session-manager.d.ts +25 -4
  76. package/dist/types/session/session-storage.d.ts +18 -0
  77. package/dist/types/setup/credential-import.d.ts +5 -1
  78. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  79. package/dist/types/ssh/utils.d.ts +1 -0
  80. package/dist/types/task/discovery.d.ts +3 -1
  81. package/dist/types/task/index.d.ts +3 -1
  82. package/dist/types/task/receipt.d.ts +2 -0
  83. package/dist/types/task/types.d.ts +94 -0
  84. package/dist/types/tools/ask.d.ts +454 -174
  85. package/dist/types/tools/fetch.d.ts +5 -8
  86. package/dist/types/tools/image-gen.d.ts +63 -1
  87. package/dist/types/tools/index.d.ts +4 -0
  88. package/dist/types/tools/skill-discovery.d.ts +7 -0
  89. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  90. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  91. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  92. package/dist/types/web/insane/bridge.d.ts +1 -0
  93. package/dist/types/web/insane/url-guard.d.ts +17 -0
  94. package/package.json +8 -8
  95. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  96. package/scripts/dogfood-repository-binding.ts +209 -0
  97. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  98. package/scripts/run-test-manifest.ts +4 -4
  99. package/src/capability/index.ts +50 -42
  100. package/src/capability/types.ts +3 -0
  101. package/src/cli/args.ts +2 -92
  102. package/src/cli/notify-cli.ts +5 -38
  103. package/src/cli/setup-cli.ts +10 -4
  104. package/src/cli.ts +52 -9
  105. package/src/commands/daemon.ts +22 -5
  106. package/src/commands/sdk.ts +44 -11
  107. package/src/config/keybindings.ts +158 -25
  108. package/src/config/model-profile-activation.ts +7 -18
  109. package/src/config/model-profile-contract.ts +161 -0
  110. package/src/config/model-profiles.ts +18 -0
  111. package/src/config/model-registry.ts +11 -1
  112. package/src/config/settings-schema.ts +73 -7
  113. package/src/coordinator-mcp/model-preset.ts +21 -74
  114. package/src/defaults/gjc/skills/deep-interview/SKILL.md +2 -2
  115. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  116. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  117. package/src/discovery/ssh.ts +12 -1
  118. package/src/edit/index.ts +3 -6
  119. package/src/edit/modes/patch.ts +38 -1
  120. package/src/edit/modes/replace.ts +42 -0
  121. package/src/edit/path-mutation-lock.ts +112 -0
  122. package/src/eval/py/executor.ts +30 -11
  123. package/src/eval/py/kernel.ts +1 -21
  124. package/src/eval/py/prelude.py +2 -2
  125. package/src/eval/py/runner-artifact.ts +84 -0
  126. package/src/eval/py/tool-bridge.ts +34 -18
  127. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  128. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  129. package/src/gjc-runtime/launch-tmux.ts +1 -1
  130. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  131. package/src/gjc-runtime/ralplan-runtime.ts +75 -3
  132. package/src/gjc-runtime/repository-binding.ts +267 -0
  133. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  134. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  135. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  136. package/src/gjc-runtime/ultragoal-guard.ts +93 -15
  137. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  138. package/src/gjc-runtime/ultragoal-runtime.ts +456 -31
  139. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  140. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  141. package/src/internal-urls/docs-index.generated.ts +12 -10
  142. package/src/internal-urls/local-protocol.ts +64 -21
  143. package/src/internal-urls/mcp-protocol.ts +73 -11
  144. package/src/main.ts +111 -37
  145. package/src/modes/DESIGN.md +5 -0
  146. package/src/modes/acp/acp-agent.ts +50 -16
  147. package/src/modes/action-registry.ts +1 -1
  148. package/src/modes/components/btw-panel.ts +82 -16
  149. package/src/modes/components/model-selector.ts +73 -11
  150. package/src/modes/components/plan-preview-overlay.ts +1 -1
  151. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  152. package/src/modes/components/tool-status-header.ts +25 -4
  153. package/src/modes/components/welcome.ts +22 -18
  154. package/src/modes/controllers/btw-controller.ts +119 -36
  155. package/src/modes/controllers/command-controller.ts +97 -54
  156. package/src/modes/controllers/event-controller.ts +10 -0
  157. package/src/modes/controllers/input-controller.ts +38 -2
  158. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  159. package/src/modes/controllers/selector-controller.ts +149 -63
  160. package/src/modes/interactive-mode.ts +57 -34
  161. package/src/modes/prompt-action-autocomplete.ts +32 -13
  162. package/src/modes/prompt-suggestion-controller.ts +96 -0
  163. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  164. package/src/modes/types.ts +4 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  166. package/src/modes/utils/ui-helpers.ts +5 -3
  167. package/src/prompts/agents/architect.md +0 -1
  168. package/src/prompts/agents/critic.md +0 -1
  169. package/src/prompts/system/btw-user.md +3 -8
  170. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  171. package/src/prompts/system/system-prompt.md +6 -4
  172. package/src/prompts/tools/cron.md +3 -1
  173. package/src/prompts/tools/skill-discovery.md +1 -0
  174. package/src/runtime-mcp/client.ts +41 -55
  175. package/src/runtime-mcp/content-limits.ts +71 -0
  176. package/src/runtime-mcp/json-rpc.ts +23 -9
  177. package/src/runtime-mcp/manager.ts +51 -53
  178. package/src/runtime-mcp/oauth-flow.ts +0 -72
  179. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  180. package/src/runtime-mcp/transports/http.ts +63 -22
  181. package/src/sdk/broker/broker.ts +34 -20
  182. package/src/sdk/broker/lifecycle.ts +185 -36
  183. package/src/sdk/bus/chat-daemon-control.ts +19 -5
  184. package/src/sdk/bus/config-commands.ts +2 -2
  185. package/src/sdk/bus/config.ts +9 -1
  186. package/src/sdk/bus/conversation-store.ts +40 -6
  187. package/src/sdk/bus/index.ts +244 -70
  188. package/src/sdk/bus/lifecycle-commands.ts +63 -10
  189. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  190. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  191. package/src/sdk/bus/notification-orchestration.ts +12 -0
  192. package/src/sdk/bus/notification-service.ts +68 -20
  193. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  194. package/src/sdk/bus/recent-activity.ts +90 -26
  195. package/src/sdk/bus/telegram-daemon-contract.ts +18 -3
  196. package/src/sdk/bus/telegram-daemon-control.ts +147 -35
  197. package/src/sdk/bus/telegram-daemon.ts +2463 -450
  198. package/src/sdk/bus/telegram-reference.ts +20 -2
  199. package/src/sdk/bus/topic-registry.ts +451 -12
  200. package/src/sdk/cli/session-cli.ts +15 -2
  201. package/src/sdk/client/discovery.ts +19 -3
  202. package/src/sdk/client/liveness.ts +49 -0
  203. package/src/sdk/host/control/dispatch.ts +1 -1
  204. package/src/sdk/host/control/operations.ts +1 -1
  205. package/src/sdk/host/host.ts +2 -6
  206. package/src/sdk/host/query/handlers.ts +70 -5
  207. package/src/sdk/index.ts +12 -0
  208. package/src/sdk/prompt-status.ts +97 -0
  209. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  210. package/src/sdk/protocol/operation-registry.ts +18 -4
  211. package/src/sdk/session-directory.ts +5 -1
  212. package/src/sdk/session.ts +31 -5
  213. package/src/sdk/startup-capability.ts +9 -1
  214. package/src/sdk/transport/auth-preface.ts +82 -0
  215. package/src/sdk/transport/index.ts +18 -0
  216. package/src/sdk/transport/relay.ts +252 -0
  217. package/src/sdk/transport/serve-cli.ts +105 -0
  218. package/src/sdk/transport/socket.ts +197 -0
  219. package/src/sdk/transport/stdio.ts +17 -0
  220. package/src/session/agent-session.ts +292 -154
  221. package/src/session/blob-store.ts +25 -7
  222. package/src/session/btw-contract.ts +59 -0
  223. package/src/session/internal/managed-session-scope.ts +1163 -137
  224. package/src/session/internal/managed-session-storage.ts +397 -234
  225. package/src/session/internal/native-publish-outcome.ts +266 -0
  226. package/src/session/session-manager.ts +620 -403
  227. package/src/session/session-storage.ts +128 -11
  228. package/src/setup/credential-import.ts +28 -3
  229. package/src/setup/provider-onboarding.ts +30 -1
  230. package/src/setup/provider-presets.json +2 -1
  231. package/src/slash-commands/builtin-registry.ts +1 -1
  232. package/src/ssh/utils.ts +14 -0
  233. package/src/task/discovery.ts +8 -2
  234. package/src/task/gjc-command.ts +2 -2
  235. package/src/task/index.ts +121 -15
  236. package/src/task/receipt.ts +3 -0
  237. package/src/task/types.ts +34 -3
  238. package/src/tools/ask.ts +199 -89
  239. package/src/tools/ast-edit.ts +2 -2
  240. package/src/tools/bash-pty-selection.ts +2 -2
  241. package/src/tools/browser/screenshot-format.ts +6 -1
  242. package/src/tools/cron.ts +1 -1
  243. package/src/tools/fetch.ts +13 -131
  244. package/src/tools/image-gen.ts +476 -23
  245. package/src/tools/index.ts +4 -0
  246. package/src/tools/read.ts +2 -0
  247. package/src/tools/render-utils.ts +7 -1
  248. package/src/tools/skill-discovery.ts +39 -3
  249. package/src/tools/sqlite-reader.ts +63 -5
  250. package/src/utils/edit-mode.ts +2 -2
  251. package/src/utils/pasted-image-path.ts +15 -6
  252. package/src/utils/prompt-suggestion.ts +265 -0
  253. package/src/utils/shell-snapshot.ts +173 -63
  254. package/src/utils/sixel.ts +3 -3
  255. package/src/web/insane/bridge.ts +5 -1
  256. package/src/web/insane/url-guard.ts +140 -27
  257. package/src/web/scrapers/docs-rs.ts +30 -5
  258. package/src/web/scrapers/types.ts +19 -49
  259. package/src/web/scrapers/utils.ts +12 -26
@@ -105,6 +105,16 @@ import {
105
105
  type FallbackAttemptToken,
106
106
  type FallbackTriggerClass,
107
107
  } from "@gajae-code/ai/utils/fallback-transport";
108
+ import {
109
+ BTW_MAX_ANSWER_UTF8_BYTES,
110
+ BTW_MAX_QUESTION_UTF8_BYTES,
111
+ BTW_STREAM_IDLE_TIMEOUT_MS,
112
+ BTW_STREAM_TOTAL_TIMEOUT_MS,
113
+ type BtwTextExchange,
114
+ boundBtwExchanges,
115
+ truncateUtf8,
116
+ utf8ByteLength,
117
+ } from "./btw-contract";
108
118
 
109
119
  export interface ForkContextSeedMetadata {
110
120
  sourceSessionId: string;
@@ -235,6 +245,7 @@ import { loadActiveSubskillTools } from "../extensibility/gjc-plugins/tools";
235
245
  import type { HookCommandContext } from "../extensibility/hooks/types";
236
246
  import { buildSkillPromptMessage, type Skill, type SkillWarning } from "../extensibility/skills";
237
247
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
248
+ import { assertDeepInterviewIntentManifest } from "../gjc-runtime/deep-interview-state";
238
249
  import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
239
250
  import {
240
251
  assertNonEmptyGjcSessionId,
@@ -242,6 +253,7 @@ import {
242
253
  sessionStateDir,
243
254
  } from "../gjc-runtime/session-layout";
244
255
  import {
256
+ ownerTerminalContextFromEnvironment,
245
257
  persistCoordinatorRuntimeStateFromEvent,
246
258
  registerCoordinatorRuntimeStateFinalizer,
247
259
  } from "../gjc-runtime/session-state-sidecar";
@@ -250,7 +262,7 @@ import { GoalRuntime } from "../goals/runtime";
250
262
  import type { Goal, GoalModeState } from "../goals/state";
251
263
  import type { HindsightSessionState } from "../hindsight/state";
252
264
  import { buildSkillStopOutput, ensureWorkflowSkillActivationState } from "../hooks/skill-state";
253
- import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
265
+ import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
254
266
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
255
267
  import { resolveMemoryBackend } from "../memory-backend";
256
268
  import {
@@ -827,7 +839,6 @@ function isLocalModelEndpoint(model: Model | undefined): boolean {
827
839
  }
828
840
 
829
841
  const IRC_REPLY_MAX_BYTES = 4096;
830
- const BTW_ESTABLISHMENT_TIMEOUT_MS = 15_000;
831
842
 
832
843
  export type EphemeralTurnPurpose = "btw" | "background";
833
844
 
@@ -837,8 +848,34 @@ interface EphemeralTurnBaseArgs {
837
848
  signal?: AbortSignal;
838
849
  }
839
850
 
851
+ export interface BtwRoleTextMessage {
852
+ role: "user" | "assistant";
853
+ text: string;
854
+ }
855
+
856
+ export interface BtwConversationScope {
857
+ model: Model;
858
+ systemPrompt: string[];
859
+ messages: BtwRoleTextMessage[];
860
+ thinkingLevel: ThinkingLevel;
861
+ hideThinkingSummary: boolean;
862
+ serviceTier: ServiceTier | undefined;
863
+ credentialSessionId: string;
864
+ providerAffinitySessionId: string;
865
+ sideSessionId: string;
866
+ }
867
+
868
+ export interface BtwTurnCapture {
869
+ question: string;
870
+ scope: BtwConversationScope | undefined;
871
+ }
872
+
840
873
  export type EphemeralTurnArgs =
841
- | (EphemeralTurnBaseArgs & { purpose: "btw" })
874
+ | (Omit<EphemeralTurnBaseArgs, "promptText"> & {
875
+ purpose: "btw";
876
+ turn: BtwTurnCapture;
877
+ contextExchanges?: readonly BtwTextExchange[];
878
+ })
842
879
  | (EphemeralTurnBaseArgs & {
843
880
  purpose?: "background";
844
881
  /** Internal caller-supplied, non-persistent context such as the IRC roster. */
@@ -1633,6 +1670,7 @@ export class AgentSession {
1633
1670
 
1634
1671
  #skillsSettings: SkillsSettings | undefined;
1635
1672
  #activeSkillState: { skill: string; sessionId?: string } | undefined;
1673
+ #restoredWorkflowSkillState: { skill: string; sessionId: string } | undefined;
1636
1674
 
1637
1675
  // Model registry for API key resolution
1638
1676
  #modelRegistry: ModelRegistry;
@@ -2452,6 +2490,34 @@ export class AgentSession {
2452
2490
  return undefined;
2453
2491
  }
2454
2492
  }
2493
+ /** Provider-facing ask metadata must expose only the active deep-interview phase. */
2494
+ getDeepInterviewAskStage(): "topology" | "post-topology" | undefined {
2495
+ const currentSessionId = this.sessionManager.getSessionId();
2496
+ const inMemory = this.#activeSkillState;
2497
+ const active =
2498
+ inMemory && (!inMemory.sessionId || inMemory.sessionId === currentSessionId)
2499
+ ? inMemory
2500
+ : this.#restoredWorkflowSkillState?.sessionId === currentSessionId
2501
+ ? this.#restoredWorkflowSkillState
2502
+ : undefined;
2503
+ if (active?.skill !== "deep-interview") return undefined;
2504
+ try {
2505
+ assertNonEmptyGjcSessionId(currentSessionId, "AgentSession.getDeepInterviewAskStage");
2506
+ const stateDir = sessionStateDir(this.sessionManager.getCwd(), currentSessionId);
2507
+ const filePath = path.join(
2508
+ stateDir,
2509
+ path.basename(sessionModeStatePath(this.sessionManager.getCwd(), currentSessionId, active.skill)),
2510
+ );
2511
+ const raw = fs.readFileSync(filePath, "utf-8");
2512
+ const parsed = JSON.parse(raw) as { state?: { intent_contract?: unknown } };
2513
+ const intentContract = parsed.state?.intent_contract;
2514
+ if (intentContract === undefined) return "topology";
2515
+ assertDeepInterviewIntentManifest(intentContract);
2516
+ return "post-topology";
2517
+ } catch {
2518
+ return undefined;
2519
+ }
2520
+ }
2455
2521
 
2456
2522
  /** Peek the in-flight directive's invocation handler for use by the resolve tool. */
2457
2523
  peekQueueInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
@@ -4518,6 +4584,8 @@ export class AgentSession {
4518
4584
  return {
4519
4585
  getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
4520
4586
  isManagedDestination: () => this.sessionManager.isManagedDestination(),
4587
+ // Required for verified legacy local:// migration on managed sessions (#2797 / #2925).
4588
+ getManagedLegacyLocalMigrationSource: () => this.sessionManager.getManagedLegacyLocalMigrationSource(),
4521
4589
  getSessionId: () => this.sessionManager.getSessionId(),
4522
4590
  };
4523
4591
  }
@@ -5092,7 +5160,7 @@ export class AgentSession {
5092
5160
  // No-op when this session opened no tabs. Failure is logged, not thrown.
5093
5161
  this.#unregisterResourceGc?.();
5094
5162
  this.#unregisterResourceGc = undefined;
5095
- this.#unregisterRuntimeStateFinalizer?.();
5163
+ if (ownerTerminalContextFromEnvironment() === null) this.#unregisterRuntimeStateFinalizer?.();
5096
5164
  this.#unregisterRuntimeStateFinalizer = undefined;
5097
5165
  await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
5098
5166
  logger.warn("session dispose: releaseTabsForOwner failed", { error }),
@@ -6745,8 +6813,9 @@ export class AgentSession {
6745
6813
  if (!activeSkill) {
6746
6814
  try {
6747
6815
  const activeState = await readVisibleSkillActiveState(this.sessionManager.getCwd(), sessionId);
6748
- activeSkill =
6749
- activeState?.skill ?? activeState?.active_skills?.find(entry => entry.active !== false)?.skill;
6816
+ activeSkill = activeState?.active_skills?.find(
6817
+ entry => entry.active !== false && entry.session_id === sessionId,
6818
+ )?.skill;
6750
6819
  } catch (error) {
6751
6820
  logger.warn("Failed to read durable workflow skill state while restoring ask tool", {
6752
6821
  error: error instanceof Error ? error.message : String(error),
@@ -6758,7 +6827,12 @@ export class AgentSession {
6758
6827
  // a predecessor session's workflow state to the current identity.
6759
6828
  if (this.#isDisposed || this.sessionManager.getSessionId() !== sessionId) return;
6760
6829
  }
6761
- if (activeSkill && isCanonicalGjcWorkflowSkill(activeSkill.trim())) this.#attachAskTool();
6830
+ if (activeSkill && isCanonicalGjcWorkflowSkill(activeSkill.trim())) {
6831
+ if (!inMemoryActiveSkill) this.#restoredWorkflowSkillState = { skill: activeSkill.trim(), sessionId };
6832
+ this.#attachAskTool();
6833
+ } else if (this.#restoredWorkflowSkillState?.sessionId === sessionId) {
6834
+ this.#restoredWorkflowSkillState = undefined;
6835
+ }
6762
6836
  }
6763
6837
 
6764
6838
  #attachAskTool(): void {
@@ -7310,6 +7384,7 @@ export class AgentSession {
7310
7384
  }
7311
7385
  }
7312
7386
  // In-memory tracking keeps `getActiveSkillState` accurate for the chain guard.
7387
+ this.#restoredWorkflowSkillState = undefined;
7313
7388
  this.#activeSkillState = active ? { skill, sessionId } : undefined;
7314
7389
  if (active) {
7315
7390
  await this.refreshGjcSubskillTools();
@@ -13993,167 +14068,127 @@ export class AgentSession {
13993
14068
  this.#ircRosterClaim = null;
13994
14069
  }
13995
14070
 
14071
+ createBtwConversationScope(instruction: string): BtwConversationScope {
14072
+ const model = this.model;
14073
+ if (!model) throw new Error("No active model on session");
14074
+ const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14075
+ return {
14076
+ model,
14077
+ systemPrompt: [...this.systemPrompt, instruction],
14078
+ messages: this.#projectBtwVisibleText(this.buildDisplaySessionContext().messages),
14079
+ thinkingLevel: this.thinkingLevel ?? ThinkingLevel.Off,
14080
+ hideThinkingSummary: this.agent.hideThinkingSummary ?? false,
14081
+ serviceTier: this.serviceTier,
14082
+ credentialSessionId: providerAffinitySessionId,
14083
+ providerAffinitySessionId,
14084
+ sideSessionId: `${providerAffinitySessionId}:btw:${crypto.randomUUID()}`,
14085
+ };
14086
+ }
14087
+
14088
+ #projectBtwVisibleText(messages: readonly AgentMessage[]): BtwRoleTextMessage[] {
14089
+ const projected: BtwRoleTextMessage[] = [];
14090
+ for (const message of messages) {
14091
+ if (message.role !== "user" && message.role !== "assistant") continue;
14092
+ const text = (
14093
+ typeof message.content === "string"
14094
+ ? message.content
14095
+ : message.content
14096
+ .filter((block): block is TextContent => block.type === "text")
14097
+ .map(block => block.text)
14098
+ .join("")
14099
+ ).trim();
14100
+ if (!text) continue;
14101
+ projected.push({ role: message.role, text });
14102
+ }
14103
+ return projected;
14104
+ }
14105
+
14106
+ #buildBtwAssistantMessage(text: string): AssistantMessage {
14107
+ return {
14108
+ role: "assistant",
14109
+ content: [{ type: "text", text }],
14110
+ api: "btw",
14111
+ provider: "btw",
14112
+ model: "btw",
14113
+ usage: {
14114
+ input: 0,
14115
+ output: 0,
14116
+ cacheRead: 0,
14117
+ cacheWrite: 0,
14118
+ totalTokens: 0,
14119
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14120
+ },
14121
+ stopReason: "stop",
14122
+ timestamp: 0,
14123
+ };
14124
+ }
13996
14125
  /**
13997
- * Run a single ephemeral side-channel turn against this session's current
13998
- * model + system prompt + history. No tools are used and session history is
13999
- * not modified. Background turns retain the IRC/session behavior used by
14000
- * autoreplies; `/btw` turns use an isolated committed-context policy.
14126
+ * Run a single ephemeral side-channel turn without modifying session history.
14127
+ * Background turns retain IRC/session behavior. `/btw` turns require a
14128
+ * pre-frozen, visible-text-only scope and bypass extension context transforms,
14129
+ * provider observability hooks, and session persistence surfaces.
14001
14130
  */
14002
14131
  async runEphemeralTurn(args: EphemeralTurnArgs): Promise<EphemeralTurnResult> {
14003
14132
  args.signal?.throwIfAborted();
14004
- const isBtw = args.purpose === "btw";
14005
- // `/btw` captures every provider-facing parent facet synchronously so an
14006
- // in-flight foreground mutation cannot create a mixed-generation request.
14007
- const btwSnapshot = isBtw
14008
- ? this.#buildBtwEphemeralSnapshot(
14009
- cloneJsonValueForForkSeed(this.buildDisplaySessionContext().messages),
14010
- args.promptText,
14011
- )
14012
- : undefined;
14013
- const model = this.model;
14014
- const systemPrompt = isBtw ? [...this.systemPrompt] : undefined;
14015
- const thinkingLevel = this.thinkingLevel;
14016
- const hideThinkingSummary = this.agent.hideThinkingSummary;
14017
- const serviceTier = this.serviceTier;
14018
- const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14019
- const sideSessionId = isBtw ? `${providerAffinitySessionId}:btw:${crypto.randomUUID()}` : undefined;
14020
- const backgroundArgs = isBtw ? undefined : args;
14021
- const callerOwnsRosterClaim = backgroundArgs?.ircRosterClaim !== undefined;
14022
- const rosterClaim = isBtw
14023
- ? null
14024
- : callerOwnsRosterClaim
14025
- ? backgroundArgs?.ircRosterClaim
14026
- : this.#claimIrcRosterCandidate();
14133
+ if (args.purpose === "btw") return await this.#runBtwTurn(args);
14134
+
14135
+ const callerOwnsRosterClaim = args.ircRosterClaim !== undefined;
14136
+ const rosterClaim = callerOwnsRosterClaim ? args.ircRosterClaim : this.#claimIrcRosterCandidate();
14027
14137
  const rosterClaimIsCurrent = () =>
14028
14138
  !rosterClaim || this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14029
- const prependMessagesValid = () => rosterClaimIsCurrent() && backgroundArgs?.prependMessagesValid?.() !== false;
14139
+ const prependMessagesValid = () => rosterClaimIsCurrent() && args.prependMessagesValid?.() !== false;
14030
14140
  const rosterMessage = !callerOwnsRosterClaim && rosterClaimIsCurrent() ? rosterClaim?.message : undefined;
14031
14141
  try {
14142
+ const model = this.model;
14032
14143
  if (!model) throw new Error("No active model on session");
14033
- const credentialSessionId = isBtw ? providerAffinitySessionId : this.sessionId;
14034
- const apiKey = await awaitEphemeralAbort(
14035
- this.#modelRegistry.getApiKey(model, credentialSessionId),
14036
- args.signal,
14037
- );
14144
+ const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, this.sessionId), args.signal);
14038
14145
  if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
14039
-
14040
14146
  const prependMessages = prependMessagesValid()
14041
- ? [...(rosterMessage ? [rosterMessage] : []), ...(backgroundArgs?.prependMessages ?? [])]
14147
+ ? [...(rosterMessage ? [rosterMessage] : []), ...(args.prependMessages ?? [])]
14042
14148
  : undefined;
14043
- let snapshot = btwSnapshot ?? this.#buildEphemeralSnapshot(args.promptText, prependMessages);
14149
+ let snapshot = this.#buildEphemeralSnapshot(args.promptText, prependMessages);
14044
14150
  let llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
14045
- if (!isBtw && prependMessages && !prependMessagesValid()) {
14151
+ if (prependMessages && !prependMessagesValid()) {
14046
14152
  snapshot = this.#buildEphemeralSnapshot(args.promptText);
14047
14153
  llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
14048
14154
  }
14049
- const context: Context = {
14050
- systemPrompt: systemPrompt ?? this.systemPrompt,
14051
- messages: llmMessages,
14052
- tools: [],
14053
- };
14054
-
14155
+ const context: Context = { systemPrompt: this.systemPrompt, messages: llmMessages, tools: [] };
14156
+ const ephemeralSessionId = crypto.randomUUID();
14157
+ const options = this.prepareSimpleStreamOptions(
14158
+ {
14159
+ apiKey,
14160
+ sessionId: ephemeralSessionId,
14161
+ metadata: buildSessionMetadata(
14162
+ ephemeralSessionId,
14163
+ model.provider,
14164
+ this.#modelRegistry.authStorage,
14165
+ this.sessionId,
14166
+ ),
14167
+ reasoning: toReasoningEffort(this.thinkingLevel),
14168
+ hideThinkingSummary: this.agent.hideThinkingSummary,
14169
+ serviceTier: this.serviceTier,
14170
+ signal: args.signal,
14171
+ toolChoice: "none",
14172
+ },
14173
+ model.provider,
14174
+ );
14175
+ args.signal?.throwIfAborted();
14055
14176
  let replyText = "";
14056
14177
  let assistantMessage: AssistantMessage | undefined;
14057
- if (isBtw) {
14058
- const establishmentAbort = new AbortController();
14059
- const requestSignal = args.signal
14060
- ? AbortSignal.any([args.signal, establishmentAbort.signal])
14061
- : establishmentAbort.signal;
14062
- const timeoutError = new Error(
14063
- `/btw provider did not produce an event within ${BTW_ESTABLISHMENT_TIMEOUT_MS / 1000} seconds`,
14064
- );
14065
- timeoutError.name = "TimeoutError";
14066
- let establishmentTimer: NodeJS.Timeout | undefined;
14067
- try {
14068
- requestSignal.throwIfAborted();
14069
- const options: SimpleStreamOptions = {
14070
- apiKey,
14071
- sessionId: sideSessionId!,
14072
- metadata: buildSessionMetadata(
14073
- sideSessionId!,
14074
- model.provider,
14075
- this.#modelRegistry.authStorage,
14076
- providerAffinitySessionId,
14077
- ),
14078
- reasoning: toReasoningEffort(thinkingLevel),
14079
- hideThinkingSummary,
14080
- serviceTier,
14081
- onPayload: this.#onPayload,
14082
- signal: requestSignal,
14083
- toolChoice: "none",
14084
- requestMaxRetries: 0,
14085
- streamMaxRetries: 0,
14086
- streamFirstEventTimeoutMs: 0,
14087
- };
14088
- establishmentTimer = setTimeout(
14089
- () => establishmentAbort.abort(timeoutError),
14090
- BTW_ESTABLISHMENT_TIMEOUT_MS,
14091
- );
14092
- establishmentTimer.unref?.();
14093
- const stream = streamSimple(model, context, options);
14094
- let receivedProviderEvent = false;
14095
- for await (const event of stream) {
14096
- if (!receivedProviderEvent && event.type !== "start") {
14097
- receivedProviderEvent = true;
14098
- clearTimeout(establishmentTimer);
14099
- establishmentTimer = undefined;
14100
- }
14101
- if (event.type === "text_delta") {
14102
- replyText += event.delta;
14103
- args.onTextDelta?.(event.delta);
14104
- } else if (event.type === "done") {
14105
- assistantMessage = event.message;
14106
- break;
14107
- } else if (event.type === "error") {
14108
- throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14109
- }
14110
- }
14111
- requestSignal.throwIfAborted();
14112
- } catch (error) {
14113
- if (establishmentAbort.signal.aborted && !args.signal?.aborted) throw timeoutError;
14114
- throw error;
14115
- } finally {
14116
- if (establishmentTimer) clearTimeout(establishmentTimer);
14117
- }
14118
- } else {
14119
- const ephemeralSessionId = crypto.randomUUID();
14120
- const options = this.prepareSimpleStreamOptions(
14121
- {
14122
- apiKey,
14123
- sessionId: ephemeralSessionId,
14124
- metadata: buildSessionMetadata(
14125
- ephemeralSessionId,
14126
- model.provider,
14127
- this.#modelRegistry.authStorage,
14128
- this.sessionId,
14129
- ),
14130
- reasoning: toReasoningEffort(thinkingLevel),
14131
- hideThinkingSummary,
14132
- serviceTier,
14133
- signal: args.signal,
14134
- toolChoice: "none",
14135
- },
14136
- model.provider,
14137
- );
14138
- args.signal?.throwIfAborted();
14139
- const stream = streamSimple(model, context, options);
14140
- for await (const event of stream) {
14141
- if (event.type === "text_delta") {
14142
- replyText += event.delta;
14143
- args.onTextDelta?.(event.delta);
14144
- } else if (event.type === "done") {
14145
- assistantMessage = event.message;
14146
- break;
14147
- } else if (event.type === "error") {
14148
- throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14149
- }
14178
+ for await (const event of streamSimple(model, context, options)) {
14179
+ if (event.type === "text_delta") {
14180
+ replyText += event.delta;
14181
+ args.onTextDelta?.(event.delta);
14182
+ } else if (event.type === "done") {
14183
+ assistantMessage = event.message;
14184
+ break;
14185
+ } else if (event.type === "error") {
14186
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14150
14187
  }
14151
14188
  }
14152
-
14153
14189
  if (!assistantMessage) throw new Error("Ephemeral turn ended without a final message");
14154
14190
  args.signal?.throwIfAborted();
14155
14191
  if (
14156
- !isBtw &&
14157
14192
  !callerOwnsRosterClaim &&
14158
14193
  rosterClaim &&
14159
14194
  assistantMessage.stopReason !== "error" &&
@@ -14161,21 +14196,117 @@ export class AgentSession {
14161
14196
  ) {
14162
14197
  this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14163
14198
  }
14164
- args.signal?.throwIfAborted();
14165
14199
  return { replyText: replyText.trim(), assistantMessage };
14166
14200
  } finally {
14167
- if (!isBtw && !callerOwnsRosterClaim && rosterClaim) {
14168
- this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14201
+ if (!callerOwnsRosterClaim && rosterClaim) this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14202
+ }
14203
+ }
14204
+
14205
+ async #runBtwTurn(args: Extract<EphemeralTurnArgs, { purpose: "btw" }>): Promise<EphemeralTurnResult> {
14206
+ const model = args.turn.scope?.model;
14207
+ const credentialSessionId = args.turn.scope?.credentialSessionId;
14208
+ if (!model || !credentialSessionId) throw new Error("The /btw conversation scope was scrubbed.");
14209
+ if (utf8ByteLength(args.turn.question) > BTW_MAX_QUESTION_UTF8_BYTES) {
14210
+ throw new RangeError(`/btw questions are limited to ${BTW_MAX_QUESTION_UTF8_BYTES} UTF-8 bytes.`);
14211
+ }
14212
+ const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, credentialSessionId), args.signal);
14213
+ if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
14214
+ const scope = args.turn.scope;
14215
+ if (!scope) throw new Error("The /btw conversation scope was scrubbed.");
14216
+ const messages = scope.messages.map(message => ({
14217
+ role: message.role,
14218
+ content: [{ type: "text" as const, text: message.text }],
14219
+ })) as Message[];
14220
+ for (const exchange of boundBtwExchanges(args.contextExchanges ?? [])) {
14221
+ messages.push({
14222
+ role: "user",
14223
+ content: [{ type: "text", text: exchange.question }],
14224
+ } as Message);
14225
+ messages.push({
14226
+ role: "assistant",
14227
+ content: [{ type: "text", text: exchange.answer }],
14228
+ } as Message);
14229
+ }
14230
+ messages.push({
14231
+ role: "user",
14232
+ content: [{ type: "text", text: args.turn.question }],
14233
+ } as Message);
14234
+ const context: Context = { systemPrompt: scope.systemPrompt, messages, tools: [] };
14235
+ const timeoutAbort = new AbortController();
14236
+ const requestSignal = args.signal ? AbortSignal.any([args.signal, timeoutAbort.signal]) : timeoutAbort.signal;
14237
+ const options: SimpleStreamOptions = {
14238
+ apiKey,
14239
+ sessionId: scope.sideSessionId,
14240
+ reasoning: toReasoningEffort(scope.thinkingLevel),
14241
+ hideThinkingSummary: scope.hideThinkingSummary,
14242
+ serviceTier: scope.serviceTier,
14243
+ signal: requestSignal,
14244
+ toolChoice: "none",
14245
+ requestMaxRetries: 0,
14246
+ streamMaxRetries: 0,
14247
+ streamFirstEventTimeoutMs: 0,
14248
+ };
14249
+ const iterator = streamSimple(scope.model, context, options)[Symbol.asyncIterator]();
14250
+ let replyText = "";
14251
+ let completed = false;
14252
+ let active = true;
14253
+ let onTextDelta = args.onTextDelta;
14254
+ let idleTimer: NodeJS.Timeout | undefined;
14255
+ const timeout = (message: string) => {
14256
+ const error = new Error(message);
14257
+ error.name = "TimeoutError";
14258
+ timeoutAbort.abort(error);
14259
+ };
14260
+ const resetIdleTimer = () => {
14261
+ if (idleTimer) clearTimeout(idleTimer);
14262
+ idleTimer = setTimeout(
14263
+ () => timeout(`/btw provider was idle for ${BTW_STREAM_IDLE_TIMEOUT_MS / 1000} seconds`),
14264
+ BTW_STREAM_IDLE_TIMEOUT_MS,
14265
+ );
14266
+ idleTimer.unref?.();
14267
+ };
14268
+ const totalTimer = setTimeout(
14269
+ () => timeout(`/btw provider exceeded ${BTW_STREAM_TOTAL_TIMEOUT_MS / 1000} seconds`),
14270
+ BTW_STREAM_TOTAL_TIMEOUT_MS,
14271
+ );
14272
+ totalTimer.unref?.();
14273
+ resetIdleTimer();
14274
+ const consume = async () => {
14275
+ while (active) {
14276
+ const result = await iterator.next();
14277
+ if (result.done || !active) break;
14278
+ resetIdleTimer();
14279
+ const event = result.value;
14280
+ if (event.type === "text_delta") {
14281
+ const bounded = truncateUtf8(replyText + event.delta, BTW_MAX_ANSWER_UTF8_BYTES);
14282
+ const delta = bounded.slice(replyText.length);
14283
+ replyText = bounded;
14284
+ if (delta) onTextDelta?.(delta);
14285
+ } else if (event.type === "done") {
14286
+ completed = true;
14287
+ break;
14288
+ } else if (event.type === "error") {
14289
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14290
+ }
14169
14291
  }
14292
+ };
14293
+ try {
14294
+ await awaitEphemeralAbort(consume(), requestSignal);
14295
+ requestSignal.throwIfAborted();
14296
+ if (!completed) throw new Error("Ephemeral turn ended without a final message");
14297
+ const finalText = replyText.trim();
14298
+ return { replyText: finalText, assistantMessage: this.#buildBtwAssistantMessage(finalText) };
14299
+ } finally {
14300
+ active = false;
14301
+ onTextDelta = undefined;
14302
+ replyText = "";
14303
+ context.messages = [];
14304
+ if (idleTimer) clearTimeout(idleTimer);
14305
+ clearTimeout(totalTimer);
14306
+ void iterator.return?.().catch(() => undefined);
14170
14307
  }
14171
14308
  }
14172
14309
 
14173
- /** Build a `/btw` snapshot from an already-cloned committed context. */
14174
- #buildBtwEphemeralSnapshot(messages: AgentMessage[], promptText: string): AgentMessage[] {
14175
- messages.push(this.#buildEphemeralPromptMessage(promptText));
14176
- return messages;
14177
- }
14178
-
14179
14310
  /** Build a background snapshot with in-flight assistant and optional context. */
14180
14311
  #buildEphemeralSnapshot(promptText: string, prependMessages?: AgentMessage[]): AgentMessage[] {
14181
14312
  const messages = [...this.messages];
@@ -14416,6 +14547,13 @@ export class AgentSession {
14416
14547
  await this.sessionManager.ensureOnDisk();
14417
14548
 
14418
14549
  if (switchingToDifferentSession) {
14550
+ // Interactive /resume (and any other switchSession identity change) must
14551
+ // await verified legacy local:// migration for the *new* session before
14552
+ // post-commit session_switch / local path resolution. createAgentSession
14553
+ // already does this at cold start (#2797); switchSession previously omitted
14554
+ // it, so resolving local:// after /resume could fail-closed with
14555
+ // "legacy migration must complete before path resolution" (#2925).
14556
+ await initializeLocalRoot(this.#localProtocolOptions());
14419
14557
  this.#resetHindsightConversationTrackingIfHindsight();
14420
14558
  this.#resetIrcRosterDeliveryState();
14421
14559
  }
@@ -5,6 +5,18 @@ import { isEnoent, logger } from "@gajae-code/utils";
5
5
 
6
6
  const BLOB_PREFIX = "blob:sha256:";
7
7
 
8
+ /**
9
+ * Owner-only permissions for on-disk blob directories and files.
10
+ *
11
+ * Blob stores can live inside a managed session scope (e.g. the resident-cache
12
+ * `EphemeralBlobStore` created on the explicit session path). The managed-tree
13
+ * snapshot fails closed unless every descendant is owner-only, so blobs and
14
+ * their directories MUST be created 0700/0600 rather than inheriting a
15
+ * group/other-readable mode from the process umask.
16
+ */
17
+ const BLOB_DIR_MODE = 0o700;
18
+ const BLOB_FILE_MODE = 0o600;
19
+
8
20
  export interface BlobPutResult {
9
21
  hash: string;
10
22
  path: string;
@@ -112,7 +124,13 @@ export class BlobStore {
112
124
  },
113
125
  };
114
126
 
115
- await Bun.write(blobPath, data);
127
+ // Create the blob directory 0700 and the file 0600 at creation, mirroring
128
+ // putSync/putImmutableSync. The previous Bun.write let the parent dir be
129
+ // created with the process umask (e.g. 0755) and the file at 0644 before a
130
+ // follow-up chmod, violating the owner-only contract above and leaving a
131
+ // group/other-readable window a managed-tree snapshot fails closed on.
132
+ await fsp.mkdir(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
133
+ await fsp.writeFile(blobPath, data, { mode: BLOB_FILE_MODE });
116
134
  return result;
117
135
  }
118
136
 
@@ -131,8 +149,8 @@ export class BlobStore {
131
149
  return `${BLOB_PREFIX}${hash}`;
132
150
  },
133
151
  };
134
- fs.mkdirSync(this.dir, { recursive: true });
135
- fs.writeFileSync(blobPath, data);
152
+ fs.mkdirSync(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
153
+ fs.writeFileSync(blobPath, data, { mode: BLOB_FILE_MODE });
136
154
  return result;
137
155
  }
138
156
 
@@ -147,7 +165,7 @@ export class BlobStore {
147
165
  const hash = sha256Hex(data);
148
166
  const blobPath = path.join(this.dir, hash);
149
167
  const result = makeBlobPutResult(hash, blobPath, data.byteLength);
150
- fs.mkdirSync(this.dir, { recursive: true });
168
+ fs.mkdirSync(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
151
169
 
152
170
  if (fs.existsSync(blobPath)) {
153
171
  verifyBlobFileSync(hash, blobPath);
@@ -156,7 +174,7 @@ export class BlobStore {
156
174
 
157
175
  const tempPath = uniqueTempBlobPath(this.dir, hash);
158
176
  try {
159
- const fd = fs.openSync(tempPath, "wx");
177
+ const fd = fs.openSync(tempPath, "wx", BLOB_FILE_MODE);
160
178
  try {
161
179
  fs.writeFileSync(fd, data);
162
180
  fs.fsyncSync(fd);
@@ -274,7 +292,7 @@ export class EphemeralBlobStore extends BlobStore {
274
292
  constructor(dir: string) {
275
293
  super(dir);
276
294
  fs.rmSync(dir, { recursive: true, force: true });
277
- fs.mkdirSync(dir, { recursive: true });
295
+ fs.mkdirSync(dir, { recursive: true, mode: BLOB_DIR_MODE });
278
296
  }
279
297
 
280
298
  #cachePut(hash: string, data: Buffer): void {
@@ -333,7 +351,7 @@ export class EphemeralBlobStore extends BlobStore {
333
351
  this.#bufferCache.clear();
334
352
  this.#bufferCacheBytes = 0;
335
353
  fs.rmSync(this.dir, { recursive: true, force: true });
336
- fs.mkdirSync(this.dir, { recursive: true });
354
+ fs.mkdirSync(this.dir, { recursive: true, mode: BLOB_DIR_MODE });
337
355
  }
338
356
 
339
357
  dispose(): void {