apex-code 0.0.5 → 0.1.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 (257) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +7 -8
  3. package/dist/cli/agent-lifecycle.d.ts +24 -0
  4. package/dist/cli/agent-lifecycle.d.ts.map +1 -0
  5. package/dist/cli/agent-lifecycle.js +126 -0
  6. package/dist/cli/agent-lifecycle.js.map +1 -0
  7. package/dist/cli/args.d.ts +13 -12
  8. package/dist/cli/args.d.ts.map +1 -1
  9. package/dist/cli/args.js +80 -30
  10. package/dist/cli/args.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +4 -130
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +1 -1
  17. package/dist/config.js.map +1 -1
  18. package/dist/core/agent-session-services.d.ts.map +1 -1
  19. package/dist/core/agent-session-services.js +0 -8
  20. package/dist/core/agent-session-services.js.map +1 -1
  21. package/dist/core/agent-session.d.ts +160 -0
  22. package/dist/core/agent-session.d.ts.map +1 -1
  23. package/dist/core/agent-session.js +265 -0
  24. package/dist/core/agent-session.js.map +1 -1
  25. package/dist/core/delegation/agents.d.ts.map +1 -1
  26. package/dist/core/delegation/agents.js +2 -1
  27. package/dist/core/delegation/agents.js.map +1 -1
  28. package/dist/core/delegation/runtime.d.ts +674 -1
  29. package/dist/core/delegation/runtime.d.ts.map +1 -1
  30. package/dist/core/delegation/runtime.js +1338 -66
  31. package/dist/core/delegation/runtime.js.map +1 -1
  32. package/dist/core/extensions/loader.d.ts.map +1 -1
  33. package/dist/core/extensions/loader.js +3 -4
  34. package/dist/core/extensions/loader.js.map +1 -1
  35. package/dist/core/extensions/source-runtime.d.ts +4 -5
  36. package/dist/core/extensions/source-runtime.d.ts.map +1 -1
  37. package/dist/core/extensions/source-runtime.js +4 -5
  38. package/dist/core/extensions/source-runtime.js.map +1 -1
  39. package/dist/core/formatter-lifecycle.d.ts +2 -1
  40. package/dist/core/formatter-lifecycle.d.ts.map +1 -1
  41. package/dist/core/formatter-lifecycle.js +2 -1
  42. package/dist/core/formatter-lifecycle.js.map +1 -1
  43. package/dist/core/hooks/types.d.ts +2 -3
  44. package/dist/core/hooks/types.d.ts.map +1 -1
  45. package/dist/core/hooks/types.js +2 -3
  46. package/dist/core/hooks/types.js.map +1 -1
  47. package/dist/core/http-idle-timeout.d.ts +3 -5
  48. package/dist/core/http-idle-timeout.d.ts.map +1 -1
  49. package/dist/core/http-idle-timeout.js +3 -5
  50. package/dist/core/http-idle-timeout.js.map +1 -1
  51. package/dist/core/mcp/config.d.ts +18 -1
  52. package/dist/core/mcp/config.d.ts.map +1 -1
  53. package/dist/core/mcp/config.js +24 -7
  54. package/dist/core/mcp/config.js.map +1 -1
  55. package/dist/core/mcp/connector.d.ts +2 -3
  56. package/dist/core/mcp/connector.d.ts.map +1 -1
  57. package/dist/core/mcp/connector.js.map +1 -1
  58. package/dist/core/mcp/oauth/authorize.d.ts +2 -0
  59. package/dist/core/mcp/oauth/authorize.d.ts.map +1 -1
  60. package/dist/core/mcp/oauth/authorize.js +5 -2
  61. package/dist/core/mcp/oauth/authorize.js.map +1 -1
  62. package/dist/core/mcp/oauth/mcp-token.d.ts +4 -6
  63. package/dist/core/mcp/oauth/mcp-token.d.ts.map +1 -1
  64. package/dist/core/mcp/oauth/mcp-token.js +5 -7
  65. package/dist/core/mcp/oauth/mcp-token.js.map +1 -1
  66. package/dist/core/mcp/runtime.d.ts +1 -0
  67. package/dist/core/mcp/runtime.d.ts.map +1 -1
  68. package/dist/core/mcp/runtime.js +2 -1
  69. package/dist/core/mcp/runtime.js.map +1 -1
  70. package/dist/core/package-manager.d.ts.map +1 -1
  71. package/dist/core/package-manager.js +0 -15
  72. package/dist/core/package-manager.js.map +1 -1
  73. package/dist/core/permissions/store.d.ts +6 -11
  74. package/dist/core/permissions/store.d.ts.map +1 -1
  75. package/dist/core/permissions/store.js +23 -51
  76. package/dist/core/permissions/store.js.map +1 -1
  77. package/dist/core/project-resources.d.ts +62 -0
  78. package/dist/core/project-resources.d.ts.map +1 -0
  79. package/dist/core/project-resources.js +42 -0
  80. package/dist/core/project-resources.js.map +1 -0
  81. package/dist/core/sdk.d.ts +62 -12
  82. package/dist/core/sdk.d.ts.map +1 -1
  83. package/dist/core/sdk.js +313 -44
  84. package/dist/core/sdk.js.map +1 -1
  85. package/dist/core/session-share.d.ts +6 -8
  86. package/dist/core/session-share.d.ts.map +1 -1
  87. package/dist/core/session-share.js +9 -12
  88. package/dist/core/session-share.js.map +1 -1
  89. package/dist/core/settings-manager.d.ts +0 -21
  90. package/dist/core/settings-manager.d.ts.map +1 -1
  91. package/dist/core/settings-manager.js +4 -12
  92. package/dist/core/settings-manager.js.map +1 -1
  93. package/dist/core/tools/bash.d.ts.map +1 -1
  94. package/dist/core/tools/bash.js +0 -47
  95. package/dist/core/tools/bash.js.map +1 -1
  96. package/dist/core/tools/delegate.d.ts +8 -0
  97. package/dist/core/tools/delegate.d.ts.map +1 -1
  98. package/dist/core/tools/delegate.js +33 -3
  99. package/dist/core/tools/delegate.js.map +1 -1
  100. package/dist/core/tools/web-fetch.d.ts.map +1 -1
  101. package/dist/core/tools/web-fetch.js +4 -8
  102. package/dist/core/tools/web-fetch.js.map +1 -1
  103. package/dist/core/tools/web-search-exa.d.ts +3 -6
  104. package/dist/core/tools/web-search-exa.d.ts.map +1 -1
  105. package/dist/core/tools/web-search-exa.js +5 -8
  106. package/dist/core/tools/web-search-exa.js.map +1 -1
  107. package/dist/core/trust-manager.d.ts +6 -5
  108. package/dist/core/trust-manager.d.ts.map +1 -1
  109. package/dist/core/trust-manager.js +42 -19
  110. package/dist/core/trust-manager.js.map +1 -1
  111. package/dist/core/web-search-provider.d.ts +0 -10
  112. package/dist/core/web-search-provider.d.ts.map +1 -1
  113. package/dist/core/web-search-provider.js +2 -16
  114. package/dist/core/web-search-provider.js.map +1 -1
  115. package/dist/core/workspace/git-observer.d.ts +16 -0
  116. package/dist/core/workspace/git-observer.d.ts.map +1 -1
  117. package/dist/core/workspace/git-observer.js +8 -1
  118. package/dist/core/workspace/git-observer.js.map +1 -1
  119. package/dist/core/workspace/git-worktree-owner.d.ts +89 -0
  120. package/dist/core/workspace/git-worktree-owner.d.ts.map +1 -0
  121. package/dist/core/workspace/git-worktree-owner.js +240 -0
  122. package/dist/core/workspace/git-worktree-owner.js.map +1 -0
  123. package/dist/main.d.ts +0 -1
  124. package/dist/main.d.ts.map +1 -1
  125. package/dist/main.js +22 -3
  126. package/dist/main.js.map +1 -1
  127. package/dist/modes/acp/server.d.ts +43 -1
  128. package/dist/modes/acp/server.d.ts.map +1 -1
  129. package/dist/modes/acp/server.js +78 -0
  130. package/dist/modes/acp/server.js.map +1 -1
  131. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  132. package/dist/modes/interactive/components/settings-selector.js +1 -1
  133. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  134. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  135. package/dist/modes/interactive/interactive-mode.js +0 -29
  136. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  137. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  138. package/dist/modes/rpc/rpc-mode.js +38 -0
  139. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  140. package/dist/modes/rpc/rpc-types.d.ts +110 -0
  141. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  142. package/dist/modes/rpc/rpc-types.js.map +1 -1
  143. package/dist/utils/tools-manager.d.ts +0 -25
  144. package/dist/utils/tools-manager.d.ts.map +1 -1
  145. package/dist/utils/tools-manager.js +3 -52
  146. package/dist/utils/tools-manager.js.map +1 -1
  147. package/dist/utils/version-check.d.ts +0 -2
  148. package/dist/utils/version-check.d.ts.map +1 -1
  149. package/dist/utils/version-check.js +0 -2
  150. package/dist/utils/version-check.js.map +1 -1
  151. package/docs/settings.md +2 -7
  152. package/npm-shrinkwrap.json +5 -5
  153. package/package.json +2 -2
  154. package/dist/core/sandbox/bwrap-arguments.d.ts +0 -41
  155. package/dist/core/sandbox/bwrap-arguments.d.ts.map +0 -1
  156. package/dist/core/sandbox/bwrap-arguments.js +0 -136
  157. package/dist/core/sandbox/bwrap-arguments.js.map +0 -1
  158. package/dist/core/sandbox/child-entry.d.ts +0 -2
  159. package/dist/core/sandbox/child-entry.d.ts.map +0 -1
  160. package/dist/core/sandbox/child-entry.js +0 -24
  161. package/dist/core/sandbox/child-entry.js.map +0 -1
  162. package/dist/core/sandbox/cli-launch.d.ts +0 -162
  163. package/dist/core/sandbox/cli-launch.d.ts.map +0 -1
  164. package/dist/core/sandbox/cli-launch.js +0 -366
  165. package/dist/core/sandbox/cli-launch.js.map +0 -1
  166. package/dist/core/sandbox/cli-supervisor.d.ts +0 -31
  167. package/dist/core/sandbox/cli-supervisor.d.ts.map +0 -1
  168. package/dist/core/sandbox/cli-supervisor.js +0 -147
  169. package/dist/core/sandbox/cli-supervisor.js.map +0 -1
  170. package/dist/core/sandbox/default-hosts.d.ts +0 -28
  171. package/dist/core/sandbox/default-hosts.d.ts.map +0 -1
  172. package/dist/core/sandbox/default-hosts.js +0 -69
  173. package/dist/core/sandbox/default-hosts.js.map +0 -1
  174. package/dist/core/sandbox/full-access.d.ts +0 -28
  175. package/dist/core/sandbox/full-access.d.ts.map +0 -1
  176. package/dist/core/sandbox/full-access.js +0 -63
  177. package/dist/core/sandbox/full-access.js.map +0 -1
  178. package/dist/core/sandbox/git-identity.d.ts +0 -57
  179. package/dist/core/sandbox/git-identity.d.ts.map +0 -1
  180. package/dist/core/sandbox/git-identity.js +0 -78
  181. package/dist/core/sandbox/git-identity.js.map +0 -1
  182. package/dist/core/sandbox/host-approval.d.ts +0 -42
  183. package/dist/core/sandbox/host-approval.d.ts.map +0 -1
  184. package/dist/core/sandbox/host-approval.js +0 -99
  185. package/dist/core/sandbox/host-approval.js.map +0 -1
  186. package/dist/core/sandbox/linux-backend.d.ts +0 -48
  187. package/dist/core/sandbox/linux-backend.d.ts.map +0 -1
  188. package/dist/core/sandbox/linux-backend.js +0 -328
  189. package/dist/core/sandbox/linux-backend.js.map +0 -1
  190. package/dist/core/sandbox/macos-backend.d.ts +0 -30
  191. package/dist/core/sandbox/macos-backend.d.ts.map +0 -1
  192. package/dist/core/sandbox/macos-backend.js +0 -385
  193. package/dist/core/sandbox/macos-backend.js.map +0 -1
  194. package/dist/core/sandbox/network-proxy.d.ts +0 -38
  195. package/dist/core/sandbox/network-proxy.d.ts.map +0 -1
  196. package/dist/core/sandbox/network-proxy.js +0 -153
  197. package/dist/core/sandbox/network-proxy.js.map +0 -1
  198. package/dist/core/sandbox/network-refusal.d.ts +0 -24
  199. package/dist/core/sandbox/network-refusal.d.ts.map +0 -1
  200. package/dist/core/sandbox/network-refusal.js +0 -72
  201. package/dist/core/sandbox/network-refusal.js.map +0 -1
  202. package/dist/core/sandbox/policy.d.ts +0 -40
  203. package/dist/core/sandbox/policy.d.ts.map +0 -1
  204. package/dist/core/sandbox/policy.js +0 -55
  205. package/dist/core/sandbox/policy.js.map +0 -1
  206. package/dist/core/sandbox/profiles.d.ts +0 -25
  207. package/dist/core/sandbox/profiles.d.ts.map +0 -1
  208. package/dist/core/sandbox/profiles.js +0 -23
  209. package/dist/core/sandbox/profiles.js.map +0 -1
  210. package/dist/core/sandbox/rpc/command-client.d.ts +0 -28
  211. package/dist/core/sandbox/rpc/command-client.d.ts.map +0 -1
  212. package/dist/core/sandbox/rpc/command-client.js +0 -86
  213. package/dist/core/sandbox/rpc/command-client.js.map +0 -1
  214. package/dist/core/sandbox/rpc/command-proxy.d.ts +0 -48
  215. package/dist/core/sandbox/rpc/command-proxy.d.ts.map +0 -1
  216. package/dist/core/sandbox/rpc/command-proxy.js +0 -120
  217. package/dist/core/sandbox/rpc/command-proxy.js.map +0 -1
  218. package/dist/core/sandbox/rpc/credential-client.d.ts +0 -20
  219. package/dist/core/sandbox/rpc/credential-client.d.ts.map +0 -1
  220. package/dist/core/sandbox/rpc/credential-client.js +0 -170
  221. package/dist/core/sandbox/rpc/credential-client.js.map +0 -1
  222. package/dist/core/sandbox/rpc/credential-proxy.d.ts +0 -44
  223. package/dist/core/sandbox/rpc/credential-proxy.d.ts.map +0 -1
  224. package/dist/core/sandbox/rpc/credential-proxy.js +0 -287
  225. package/dist/core/sandbox/rpc/credential-proxy.js.map +0 -1
  226. package/dist/core/sandbox/rpc/framing.d.ts +0 -35
  227. package/dist/core/sandbox/rpc/framing.d.ts.map +0 -1
  228. package/dist/core/sandbox/rpc/framing.js +0 -114
  229. package/dist/core/sandbox/rpc/framing.js.map +0 -1
  230. package/dist/core/sandbox/rpc/git-credential-helper.d.ts +0 -47
  231. package/dist/core/sandbox/rpc/git-credential-helper.d.ts.map +0 -1
  232. package/dist/core/sandbox/rpc/git-credential-helper.js +0 -222
  233. package/dist/core/sandbox/rpc/git-credential-helper.js.map +0 -1
  234. package/dist/core/sandbox/rpc/git-credential-proxy.d.ts +0 -66
  235. package/dist/core/sandbox/rpc/git-credential-proxy.d.ts.map +0 -1
  236. package/dist/core/sandbox/rpc/git-credential-proxy.js +0 -209
  237. package/dist/core/sandbox/rpc/git-credential-proxy.js.map +0 -1
  238. package/dist/core/sandbox/supervisor-temp.d.ts +0 -18
  239. package/dist/core/sandbox/supervisor-temp.d.ts.map +0 -1
  240. package/dist/core/sandbox/supervisor-temp.js +0 -21
  241. package/dist/core/sandbox/supervisor-temp.js.map +0 -1
  242. package/dist/core/sandbox/supervisor.d.ts +0 -62
  243. package/dist/core/sandbox/supervisor.d.ts.map +0 -1
  244. package/dist/core/sandbox/supervisor.js +0 -32
  245. package/dist/core/sandbox/supervisor.js.map +0 -1
  246. package/dist/core/sandbox/terminal-handoff.d.ts +0 -68
  247. package/dist/core/sandbox/terminal-handoff.d.ts.map +0 -1
  248. package/dist/core/sandbox/terminal-handoff.js +0 -221
  249. package/dist/core/sandbox/terminal-handoff.js.map +0 -1
  250. package/dist/core/sandbox/terminal-size.d.ts +0 -53
  251. package/dist/core/sandbox/terminal-size.d.ts.map +0 -1
  252. package/dist/core/sandbox/terminal-size.js +0 -126
  253. package/dist/core/sandbox/terminal-size.js.map +0 -1
  254. package/dist/core/sandbox/violations.d.ts +0 -23
  255. package/dist/core/sandbox/violations.d.ts.map +0 -1
  256. package/dist/core/sandbox/violations.js +0 -30
  257. package/dist/core/sandbox/violations.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-worktree-owner.d.ts","sourceRoot":"","sources":["../../../src/core/workspace/git-worktree-owner.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAqC/F,MAAM,WAAW,gCAAgC;IAChD,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,qBAAa,yBAAyB;IACrC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,aAAa,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,gCAAgC,EAG5E;IAED,oEAAoE;IACpE,OAAO,CAAC,WAAW;IAOnB;;;;OAIG;IACG,OAAO,CACZ,OAAO,EAAE,qBAAqB,GAAG;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,GACpD,OAAO,CAAC,qBAAqB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAUnD;IAED;;;;;;;;OAQG;IACG,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAuBhG;IAED;;;;;;;;;;;;;;OAcG;IACG,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,CAmDzE;YAGa,aAAa;YAWb,iBAAiB;YAgBjB,sBAAsB;CAoBpC","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative, resolve, sep } from \"node:path\";\nimport type { ChildWorkspaceRequest, WorkspaceReleaseOutcome } from \"../delegation/runtime.ts\";\nimport { type GitRunResult, runGit } from \"./git-observer.ts\";\n\nconst DEFAULT_TIMEOUT_MS = 15_000;\n/** Fixed location beneath the workspace root, so a child's checkout is always findable and always inside the workspace. */\nconst WORKTREES_SEGMENT = join(\".apex-code\", \"worktrees\");\nconst BRANCH_PREFIX = \"apex-child-\";\n/**\n * Short branch ids keep `git branch` output readable. Eight hex characters of a\n * UUID still bound prefix collisions between concurrent children to ~2^-32 per\n * pair; the branch ref (not the checkout) is the durable artifact anyway.\n */\nconst SHORT_SESSION_ID_LENGTH = 8;\n\n/**\n * A session id names both a filesystem path and a branch here, so it must be a\n * single safe component. A caller-supplied delegation handle flows in as the\n * session id, and `../../` or a leading `-` must never become a path traversal\n * or an option injection. Same allowlist shape the checkpoint engine uses for\n * ref components.\n */\nfunction isSafeSessionId(sessionId: string): boolean {\n\treturn /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId) && !sessionId.includes(\"..\") && !sessionId.endsWith(\".lock\");\n}\n\nfunction shortSessionId(sessionId: string): string {\n\treturn sessionId.slice(0, SHORT_SESSION_ID_LENGTH);\n}\n\n/** One-line human explanation of a failed git run, for refusal and failure errors. */\nfunction describeFailure(result: GitRunResult): string {\n\tif (result.spawnError) return \"git could not be executed\";\n\tif (result.timedOut) return \"git timed out\";\n\tconst detail = result.stderr.trim().split(\"\\n\")[0] ?? \"\";\n\treturn detail ? `git failed: ${detail}` : `git failed with exit code ${result.code ?? \"unknown\"}`;\n}\n\nexport interface GitWorktreeWorkspaceOwnerOptions {\n\t/** Per-command timeout in milliseconds (default 15 000). */\n\ttimeoutMs?: number;\n}\n\n/**\n * The production workspace owner for worktree isolation (spec\n * 2026-09-09-run-and-child-session-architecture.md, \"Parallel work and\n * ownership\"). The workspace subsystem owns git; delegation stays git-free:\n * `prepare()` creates one linked worktree per child at\n * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` on branch\n * `apex-child-<short-sessionId>` and returns the request with `root` set, which\n * is how the child session's cwd ends up inside the worktree. `release()`\n * removes it again. Refusals are throws: outside a git repository (or without\n * a git executable) `prepare()` throws, and the delegation runtime surfaces\n * that throw as the delegation's failure.\n *\n * Cleanup choices, deliberately:\n * - `git worktree remove` runs without `--force` and there is no automatic\n * fallback. When the plain remove refuses -- typically a dirty tree, since a\n * child that wrote files leaves untracked content behind -- the tree is\n * inspected and *kept*, reported as a `kept: \"dirty\"` release outcome so\n * uncommitted child work is preserved. Only an explicit\n * `release(id, { force: true })` from the caller force-removes. The child's\n * branch ref survives either way, so the parent can still integrate through\n * an explicit git workflow, and this owner never merges, commits, or pushes\n * anything.\n * - A worktree that is already gone is tolerated, so releasing twice, or\n * releasing a failed delegation whose `prepare()` died before creating\n * anything, are both no-ops.\n * - The worktrees directory is kept out of the parent checkout's `git status`\n * through the repository-local `.git/info/exclude` -- the one ignore channel\n * that cannot touch tracked project files such as `.gitignore` (which the\n * spec forbids the coordinator from editing). Left unignored, the linked\n * worktrees would report as untracked churn and spuriously retire\n * verification results. Best effort only: a failure here never fails the\n * worktree itself.\n */\nexport class GitWorktreeWorkspaceOwner {\n\tprivate readonly workspaceRoot: string;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor(workspaceRoot: string, options?: GitWorktreeWorkspaceOwnerOptions) {\n\t\tthis.workspaceRoot = workspaceRoot;\n\t\tthis.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\t}\n\n\t/** The worktree directory this owner uses for one child session. */\n\tprivate worktreeDir(sessionId: string): string {\n\t\tif (!isSafeSessionId(sessionId)) {\n\t\t\tthrow new Error(`Worktree isolation refused: session id \"${sessionId}\" is not a safe path component.`);\n\t\t}\n\t\treturn join(this.workspaceRoot, WORKTREES_SEGMENT, sessionId);\n\t}\n\n\t/**\n\t * Create the child's worktree and return the request with `root` set to its\n\t * path. Throws (refusing the delegation) when the workspace is not a git\n\t * repository, git is unavailable, or the worktree cannot be created.\n\t */\n\tasync prepare(\n\t\trequest: ChildWorkspaceRequest & { sessionId: string },\n\t): Promise<ChildWorkspaceRequest & { root: string }> {\n\t\tconst dir = this.worktreeDir(request.sessionId);\n\t\tconst toplevel = await this.requireRepository(request.sessionId);\n\t\tawait this.keepWorktreesUntracked(toplevel);\n\t\tconst branch = `${BRANCH_PREFIX}${shortSessionId(request.sessionId)}`;\n\t\tconst added = await runGit(this.workspaceRoot, [\"worktree\", \"add\", \"-b\", branch, dir], this.timeoutMs);\n\t\tif (!added.ok) {\n\t\t\tthrow new Error(`Worktree isolation failed for session \"${request.sessionId}\": ${describeFailure(added)}.`);\n\t\t}\n\t\treturn { ...request, root: dir };\n\t}\n\n\t/**\n\t * Remove the child's worktree safely. Plain `git worktree remove` first; if\n\t * it refuses, the tree's status decides the outcome: uncommitted or\n\t * untracked content means the tree is kept (\"dirty\") so child work is\n\t * never destroyed silently; a clean tree that still cannot be removed is\n\t * reported as \"failed\". `--force` runs only when the caller passes\n\t * `options.force` -- the explicit escape hatch, never an automatic\n\t * fallback. Already-removed worktrees are tolerated and never throw.\n\t */\n\tasync release(sessionId: string, options?: { force?: boolean }): Promise<WorkspaceReleaseOutcome> {\n\t\tif (!isSafeSessionId(sessionId)) return { removed: true }; // an unsafe id can never have been prepared\n\t\tconst dir = this.worktreeDir(sessionId);\n\t\tif (!existsSync(dir)) {\n\t\t\t// Never created, already removed, or removed out of band: drop any\n\t\t\t// stale administrative entry and succeed either way.\n\t\t\tawait runGit(this.workspaceRoot, [\"worktree\", \"prune\"], this.timeoutMs);\n\t\t\treturn { removed: true };\n\t\t}\n\t\tif (options?.force) {\n\t\t\tconst forced = await runGit(this.workspaceRoot, [\"worktree\", \"remove\", \"--force\", dir], this.timeoutMs);\n\t\t\tif (forced.ok) return { removed: true };\n\t\t\treturn { removed: false, kept: \"failed\", dir, error: describeFailure(forced) };\n\t\t}\n\t\tconst removed = await runGit(this.workspaceRoot, [\"worktree\", \"remove\", dir], this.timeoutMs);\n\t\tif (removed.ok) return { removed: true };\n\t\t// The plain remove refused. Inspect the tree: uncommitted/untracked\n\t\t// content means the refusal is protective and the tree stays.\n\t\tconst status = await runGit(dir, [\"status\", \"--porcelain\"], this.timeoutMs);\n\t\tif (status.ok && status.stdout.toString(\"utf-8\").trim() !== \"\") {\n\t\t\treturn { removed: false, kept: \"dirty\", dir, error: describeFailure(removed) };\n\t\t}\n\t\treturn { removed: false, kept: \"failed\", dir, error: describeFailure(removed) };\n\t}\n\n\t/**\n\t * Read-only verification for explicit workspace recovery (spec\n\t * 2026-09-09-run-and-child-session-architecture.md, \"Workspace states and\n\t * explicit recovery\"): confirm `root` is still THIS owner's worktree for\n\t * `sessionId` in the CURRENT workspace. Three checks, each refusal naming\n\t * the check: (layout) the recorded path is exactly the owner's\n\t * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` and exists;\n\t * (admin entry) `<root>/.git` is a linked-worktree pointer file whose gitdir\n\t * resolves into the current repository's `.git/worktrees`; (branch) the\n\t * checked-out branch is `apex-child-<short-sessionId>`. Never creates,\n\t * checks out, resets, or force-removes anything -- git inspection is\n\t * read-only (`rev-parse`, `status`) plus the administrative entry read.\n\t * Returns the worktree's dirty state so callers can report preserved\n\t * uncommitted work without a second inspection.\n\t */\n\tasync verify(sessionId: string, root: string): Promise<{ dirty: boolean }> {\n\t\tif (!isSafeSessionId(sessionId)) {\n\t\t\tthrow new Error(`layout check failed: session id \"${sessionId}\" is not a safe path component.`);\n\t\t}\n\t\t// (c) The directory sits at this owner's layout for the session.\n\t\tconst expected = this.worktreeDir(sessionId);\n\t\tif (resolve(root) !== expected) {\n\t\t\tthrow new Error(\n\t\t\t\t`layout check failed: recorded root \"${root}\" is not this workspace's worktree location for session \"${sessionId}\" (expected \"${expected}\").`,\n\t\t\t);\n\t\t}\n\t\tif (!existsSync(root)) {\n\t\t\tthrow new Error(`layout check failed: the recorded worktree root \"${root}\" does not exist.`);\n\t\t}\n\t\t// (a) The directory is a linked worktree of the CURRENT parent workspace:\n\t\t// git resolves its administrative entry, and the resolved gitdir must sit\n\t\t// inside this repository's worktrees dir. Git reads the pointer itself,\n\t\t// which direct file reads cannot do portably (Windows denies the open).\n\t\tconst resolved = await runGit(root, [\"rev-parse\", \"--absolute-git-dir\"], this.timeoutMs);\n\t\tif (!resolved.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: \"${root}\" is not a linked worktree (git could not resolve its administrative entry: ${describeFailure(resolved)}).`,\n\t\t\t);\n\t\t}\n\t\tconst gitdir = resolve(resolved.stdout.toString(\"utf-8\").trim());\n\t\tconst parentGitDir = await this.currentGitDir();\n\t\tconst worktreesDir = join(parentGitDir, \"worktrees\");\n\t\tconst rel = relative(worktreesDir, gitdir);\n\t\tif (rel === \"\" || isAbsolute(rel) || rel.startsWith(\"..\") || !existsSync(gitdir)) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: \"${root}\" is not a linked worktree of the current repository (its gitdir \"${gitdir}\" does not resolve into \"${worktreesDir}\").`,\n\t\t\t);\n\t\t}\n\t\t// (b) The checked-out branch is this child's own.\n\t\tconst branch = `${BRANCH_PREFIX}${shortSessionId(sessionId)}`;\n\t\tconst head = await runGit(root, [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], this.timeoutMs);\n\t\tconst checkedOut = head.ok ? head.stdout.toString(\"utf-8\").trim() : \"\";\n\t\tif (!head.ok || checkedOut !== branch) {\n\t\t\tthrow new Error(\n\t\t\t\t`branch check failed: the worktree for session \"${sessionId}\" has ${\n\t\t\t\t\thead.ok ? `\"${checkedOut}\" checked out` : describeFailure(head)\n\t\t\t\t}, expected \"${branch}\".`,\n\t\t\t);\n\t\t}\n\t\t// The dirty report is informational, not a gate: uncommitted child work is\n\t\t// exactly what recovery must preserve, never destroy.\n\t\tconst status = await runGit(root, [\"status\", \"--porcelain\"], this.timeoutMs);\n\t\tif (!status.ok) {\n\t\t\tthrow new Error(`status check failed: the worktree's status could not be read: ${describeFailure(status)}.`);\n\t\t}\n\t\treturn { dirty: status.stdout.toString(\"utf-8\").trim() !== \"\" };\n\t}\n\n\t/** The current workspace repository's absolute git dir, for admin-entry verification. */\n\tprivate async currentGitDir(): Promise<string> {\n\t\tconst probe = await runGit(this.workspaceRoot, [\"rev-parse\", \"--absolute-git-dir\"], this.timeoutMs);\n\t\tif (!probe.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: the current workspace is not a readable git repository (${describeFailure(probe)}).`,\n\t\t\t);\n\t\t}\n\t\treturn resolve(this.workspaceRoot, probe.stdout.toString(\"utf-8\").trim());\n\t}\n\n\t/** Verify git is usable and the workspace sits inside a repository; returns the repository toplevel. */\n\tprivate async requireRepository(sessionId: string): Promise<string> {\n\t\tconst probe = await runGit(this.workspaceRoot, [\"rev-parse\", \"--show-toplevel\"], this.timeoutMs);\n\t\tif (!probe.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`Worktree isolation refused for session \"${sessionId}\": ${describeFailure(probe)}; worktree isolation requires a git repository.`,\n\t\t\t);\n\t\t}\n\t\treturn probe.stdout.toString(\"utf-8\").trim();\n\t}\n\n\t/**\n\t * Keep `<...>/.apex-code/worktrees/` out of the parent checkout's status\n\t * via `.git/info/exclude` (idempotent; forward-slash pattern as git\n\t * requires, repo-relative so it also holds when the workspace root is a\n\t * subdirectory of the repository).\n\t */\n\tprivate async keepWorktreesUntracked(toplevel: string): Promise<void> {\n\t\tconst dir = join(this.workspaceRoot, WORKTREES_SEGMENT);\n\t\tconst repoRelative = relative(toplevel, dir);\n\t\tif (repoRelative === \"\" || isAbsolute(repoRelative) || repoRelative.startsWith(\"..\")) return;\n\t\tconst entry = `${repoRelative.split(sep).join(\"/\")}/`;\n\t\ttry {\n\t\t\tconst ignored = await runGit(toplevel, [\"check-ignore\", \"-q\", entry], this.timeoutMs);\n\t\t\tif (ignored.ok) return;\n\t\t\tconst excludePathResult = await runGit(toplevel, [\"rev-parse\", \"--git-path\", \"info/exclude\"], this.timeoutMs);\n\t\t\tif (!excludePathResult.ok) return;\n\t\t\tconst excludePath = resolve(toplevel, excludePathResult.stdout.toString(\"utf-8\").trim());\n\t\t\tmkdirSync(dirname(excludePath), { recursive: true });\n\t\t\tconst current = existsSync(excludePath) ? readFileSync(excludePath, \"utf-8\") : \"\";\n\t\t\tif (current.split(/\\r?\\n/).includes(entry)) return;\n\t\t\tconst needsNewLine = current.length > 0 && !current.endsWith(\"\\n\");\n\t\t\twriteFileSync(excludePath, `${needsNewLine ? \"\\n\" : \"\"}${entry}\\n`, { flag: \"a\", encoding: \"utf-8\" });\n\t\t} catch {\n\t\t\t// Hygiene only: an unwritable or unreadable exclude never fails the worktree.\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,240 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { runGit } from "./git-observer.js";
4
+ const DEFAULT_TIMEOUT_MS = 15_000;
5
+ /** Fixed location beneath the workspace root, so a child's checkout is always findable and always inside the workspace. */
6
+ const WORKTREES_SEGMENT = join(".apex-code", "worktrees");
7
+ const BRANCH_PREFIX = "apex-child-";
8
+ /**
9
+ * Short branch ids keep `git branch` output readable. Eight hex characters of a
10
+ * UUID still bound prefix collisions between concurrent children to ~2^-32 per
11
+ * pair; the branch ref (not the checkout) is the durable artifact anyway.
12
+ */
13
+ const SHORT_SESSION_ID_LENGTH = 8;
14
+ /**
15
+ * A session id names both a filesystem path and a branch here, so it must be a
16
+ * single safe component. A caller-supplied delegation handle flows in as the
17
+ * session id, and `../../` or a leading `-` must never become a path traversal
18
+ * or an option injection. Same allowlist shape the checkpoint engine uses for
19
+ * ref components.
20
+ */
21
+ function isSafeSessionId(sessionId) {
22
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId) && !sessionId.includes("..") && !sessionId.endsWith(".lock");
23
+ }
24
+ function shortSessionId(sessionId) {
25
+ return sessionId.slice(0, SHORT_SESSION_ID_LENGTH);
26
+ }
27
+ /** One-line human explanation of a failed git run, for refusal and failure errors. */
28
+ function describeFailure(result) {
29
+ if (result.spawnError)
30
+ return "git could not be executed";
31
+ if (result.timedOut)
32
+ return "git timed out";
33
+ const detail = result.stderr.trim().split("\n")[0] ?? "";
34
+ return detail ? `git failed: ${detail}` : `git failed with exit code ${result.code ?? "unknown"}`;
35
+ }
36
+ /**
37
+ * The production workspace owner for worktree isolation (spec
38
+ * 2026-09-09-run-and-child-session-architecture.md, "Parallel work and
39
+ * ownership"). The workspace subsystem owns git; delegation stays git-free:
40
+ * `prepare()` creates one linked worktree per child at
41
+ * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` on branch
42
+ * `apex-child-<short-sessionId>` and returns the request with `root` set, which
43
+ * is how the child session's cwd ends up inside the worktree. `release()`
44
+ * removes it again. Refusals are throws: outside a git repository (or without
45
+ * a git executable) `prepare()` throws, and the delegation runtime surfaces
46
+ * that throw as the delegation's failure.
47
+ *
48
+ * Cleanup choices, deliberately:
49
+ * - `git worktree remove` runs without `--force` and there is no automatic
50
+ * fallback. When the plain remove refuses -- typically a dirty tree, since a
51
+ * child that wrote files leaves untracked content behind -- the tree is
52
+ * inspected and *kept*, reported as a `kept: "dirty"` release outcome so
53
+ * uncommitted child work is preserved. Only an explicit
54
+ * `release(id, { force: true })` from the caller force-removes. The child's
55
+ * branch ref survives either way, so the parent can still integrate through
56
+ * an explicit git workflow, and this owner never merges, commits, or pushes
57
+ * anything.
58
+ * - A worktree that is already gone is tolerated, so releasing twice, or
59
+ * releasing a failed delegation whose `prepare()` died before creating
60
+ * anything, are both no-ops.
61
+ * - The worktrees directory is kept out of the parent checkout's `git status`
62
+ * through the repository-local `.git/info/exclude` -- the one ignore channel
63
+ * that cannot touch tracked project files such as `.gitignore` (which the
64
+ * spec forbids the coordinator from editing). Left unignored, the linked
65
+ * worktrees would report as untracked churn and spuriously retire
66
+ * verification results. Best effort only: a failure here never fails the
67
+ * worktree itself.
68
+ */
69
+ export class GitWorktreeWorkspaceOwner {
70
+ workspaceRoot;
71
+ timeoutMs;
72
+ constructor(workspaceRoot, options) {
73
+ this.workspaceRoot = workspaceRoot;
74
+ this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
75
+ }
76
+ /** The worktree directory this owner uses for one child session. */
77
+ worktreeDir(sessionId) {
78
+ if (!isSafeSessionId(sessionId)) {
79
+ throw new Error(`Worktree isolation refused: session id "${sessionId}" is not a safe path component.`);
80
+ }
81
+ return join(this.workspaceRoot, WORKTREES_SEGMENT, sessionId);
82
+ }
83
+ /**
84
+ * Create the child's worktree and return the request with `root` set to its
85
+ * path. Throws (refusing the delegation) when the workspace is not a git
86
+ * repository, git is unavailable, or the worktree cannot be created.
87
+ */
88
+ async prepare(request) {
89
+ const dir = this.worktreeDir(request.sessionId);
90
+ const toplevel = await this.requireRepository(request.sessionId);
91
+ await this.keepWorktreesUntracked(toplevel);
92
+ const branch = `${BRANCH_PREFIX}${shortSessionId(request.sessionId)}`;
93
+ const added = await runGit(this.workspaceRoot, ["worktree", "add", "-b", branch, dir], this.timeoutMs);
94
+ if (!added.ok) {
95
+ throw new Error(`Worktree isolation failed for session "${request.sessionId}": ${describeFailure(added)}.`);
96
+ }
97
+ return { ...request, root: dir };
98
+ }
99
+ /**
100
+ * Remove the child's worktree safely. Plain `git worktree remove` first; if
101
+ * it refuses, the tree's status decides the outcome: uncommitted or
102
+ * untracked content means the tree is kept ("dirty") so child work is
103
+ * never destroyed silently; a clean tree that still cannot be removed is
104
+ * reported as "failed". `--force` runs only when the caller passes
105
+ * `options.force` -- the explicit escape hatch, never an automatic
106
+ * fallback. Already-removed worktrees are tolerated and never throw.
107
+ */
108
+ async release(sessionId, options) {
109
+ if (!isSafeSessionId(sessionId))
110
+ return { removed: true }; // an unsafe id can never have been prepared
111
+ const dir = this.worktreeDir(sessionId);
112
+ if (!existsSync(dir)) {
113
+ // Never created, already removed, or removed out of band: drop any
114
+ // stale administrative entry and succeed either way.
115
+ await runGit(this.workspaceRoot, ["worktree", "prune"], this.timeoutMs);
116
+ return { removed: true };
117
+ }
118
+ if (options?.force) {
119
+ const forced = await runGit(this.workspaceRoot, ["worktree", "remove", "--force", dir], this.timeoutMs);
120
+ if (forced.ok)
121
+ return { removed: true };
122
+ return { removed: false, kept: "failed", dir, error: describeFailure(forced) };
123
+ }
124
+ const removed = await runGit(this.workspaceRoot, ["worktree", "remove", dir], this.timeoutMs);
125
+ if (removed.ok)
126
+ return { removed: true };
127
+ // The plain remove refused. Inspect the tree: uncommitted/untracked
128
+ // content means the refusal is protective and the tree stays.
129
+ const status = await runGit(dir, ["status", "--porcelain"], this.timeoutMs);
130
+ if (status.ok && status.stdout.toString("utf-8").trim() !== "") {
131
+ return { removed: false, kept: "dirty", dir, error: describeFailure(removed) };
132
+ }
133
+ return { removed: false, kept: "failed", dir, error: describeFailure(removed) };
134
+ }
135
+ /**
136
+ * Read-only verification for explicit workspace recovery (spec
137
+ * 2026-09-09-run-and-child-session-architecture.md, "Workspace states and
138
+ * explicit recovery"): confirm `root` is still THIS owner's worktree for
139
+ * `sessionId` in the CURRENT workspace. Three checks, each refusal naming
140
+ * the check: (layout) the recorded path is exactly the owner's
141
+ * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` and exists;
142
+ * (admin entry) `<root>/.git` is a linked-worktree pointer file whose gitdir
143
+ * resolves into the current repository's `.git/worktrees`; (branch) the
144
+ * checked-out branch is `apex-child-<short-sessionId>`. Never creates,
145
+ * checks out, resets, or force-removes anything -- git inspection is
146
+ * read-only (`rev-parse`, `status`) plus the administrative entry read.
147
+ * Returns the worktree's dirty state so callers can report preserved
148
+ * uncommitted work without a second inspection.
149
+ */
150
+ async verify(sessionId, root) {
151
+ if (!isSafeSessionId(sessionId)) {
152
+ throw new Error(`layout check failed: session id "${sessionId}" is not a safe path component.`);
153
+ }
154
+ // (c) The directory sits at this owner's layout for the session.
155
+ const expected = this.worktreeDir(sessionId);
156
+ if (resolve(root) !== expected) {
157
+ throw new Error(`layout check failed: recorded root "${root}" is not this workspace's worktree location for session "${sessionId}" (expected "${expected}").`);
158
+ }
159
+ if (!existsSync(root)) {
160
+ throw new Error(`layout check failed: the recorded worktree root "${root}" does not exist.`);
161
+ }
162
+ // (a) The directory is a linked worktree of the CURRENT parent workspace:
163
+ // git resolves its administrative entry, and the resolved gitdir must sit
164
+ // inside this repository's worktrees dir. Git reads the pointer itself,
165
+ // which direct file reads cannot do portably (Windows denies the open).
166
+ const resolved = await runGit(root, ["rev-parse", "--absolute-git-dir"], this.timeoutMs);
167
+ if (!resolved.ok) {
168
+ throw new Error(`admin entry check failed: "${root}" is not a linked worktree (git could not resolve its administrative entry: ${describeFailure(resolved)}).`);
169
+ }
170
+ const gitdir = resolve(resolved.stdout.toString("utf-8").trim());
171
+ const parentGitDir = await this.currentGitDir();
172
+ const worktreesDir = join(parentGitDir, "worktrees");
173
+ const rel = relative(worktreesDir, gitdir);
174
+ if (rel === "" || isAbsolute(rel) || rel.startsWith("..") || !existsSync(gitdir)) {
175
+ throw new Error(`admin entry check failed: "${root}" is not a linked worktree of the current repository (its gitdir "${gitdir}" does not resolve into "${worktreesDir}").`);
176
+ }
177
+ // (b) The checked-out branch is this child's own.
178
+ const branch = `${BRANCH_PREFIX}${shortSessionId(sessionId)}`;
179
+ const head = await runGit(root, ["rev-parse", "--abbrev-ref", "HEAD"], this.timeoutMs);
180
+ const checkedOut = head.ok ? head.stdout.toString("utf-8").trim() : "";
181
+ if (!head.ok || checkedOut !== branch) {
182
+ throw new Error(`branch check failed: the worktree for session "${sessionId}" has ${head.ok ? `"${checkedOut}" checked out` : describeFailure(head)}, expected "${branch}".`);
183
+ }
184
+ // The dirty report is informational, not a gate: uncommitted child work is
185
+ // exactly what recovery must preserve, never destroy.
186
+ const status = await runGit(root, ["status", "--porcelain"], this.timeoutMs);
187
+ if (!status.ok) {
188
+ throw new Error(`status check failed: the worktree's status could not be read: ${describeFailure(status)}.`);
189
+ }
190
+ return { dirty: status.stdout.toString("utf-8").trim() !== "" };
191
+ }
192
+ /** The current workspace repository's absolute git dir, for admin-entry verification. */
193
+ async currentGitDir() {
194
+ const probe = await runGit(this.workspaceRoot, ["rev-parse", "--absolute-git-dir"], this.timeoutMs);
195
+ if (!probe.ok) {
196
+ throw new Error(`admin entry check failed: the current workspace is not a readable git repository (${describeFailure(probe)}).`);
197
+ }
198
+ return resolve(this.workspaceRoot, probe.stdout.toString("utf-8").trim());
199
+ }
200
+ /** Verify git is usable and the workspace sits inside a repository; returns the repository toplevel. */
201
+ async requireRepository(sessionId) {
202
+ const probe = await runGit(this.workspaceRoot, ["rev-parse", "--show-toplevel"], this.timeoutMs);
203
+ if (!probe.ok) {
204
+ throw new Error(`Worktree isolation refused for session "${sessionId}": ${describeFailure(probe)}; worktree isolation requires a git repository.`);
205
+ }
206
+ return probe.stdout.toString("utf-8").trim();
207
+ }
208
+ /**
209
+ * Keep `<...>/.apex-code/worktrees/` out of the parent checkout's status
210
+ * via `.git/info/exclude` (idempotent; forward-slash pattern as git
211
+ * requires, repo-relative so it also holds when the workspace root is a
212
+ * subdirectory of the repository).
213
+ */
214
+ async keepWorktreesUntracked(toplevel) {
215
+ const dir = join(this.workspaceRoot, WORKTREES_SEGMENT);
216
+ const repoRelative = relative(toplevel, dir);
217
+ if (repoRelative === "" || isAbsolute(repoRelative) || repoRelative.startsWith(".."))
218
+ return;
219
+ const entry = `${repoRelative.split(sep).join("/")}/`;
220
+ try {
221
+ const ignored = await runGit(toplevel, ["check-ignore", "-q", entry], this.timeoutMs);
222
+ if (ignored.ok)
223
+ return;
224
+ const excludePathResult = await runGit(toplevel, ["rev-parse", "--git-path", "info/exclude"], this.timeoutMs);
225
+ if (!excludePathResult.ok)
226
+ return;
227
+ const excludePath = resolve(toplevel, excludePathResult.stdout.toString("utf-8").trim());
228
+ mkdirSync(dirname(excludePath), { recursive: true });
229
+ const current = existsSync(excludePath) ? readFileSync(excludePath, "utf-8") : "";
230
+ if (current.split(/\r?\n/).includes(entry))
231
+ return;
232
+ const needsNewLine = current.length > 0 && !current.endsWith("\n");
233
+ writeFileSync(excludePath, `${needsNewLine ? "\n" : ""}${entry}\n`, { flag: "a", encoding: "utf-8" });
234
+ }
235
+ catch {
236
+ // Hygiene only: an unwritable or unreadable exclude never fails the worktree.
237
+ }
238
+ }
239
+ }
240
+ //# sourceMappingURL=git-worktree-owner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git-worktree-owner.js","sourceRoot":"","sources":["../../../src/core/workspace/git-worktree-owner.ts"],"names":[],"mappings":"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","sourcesContent":["import { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { dirname, isAbsolute, join, relative, resolve, sep } from \"node:path\";\nimport type { ChildWorkspaceRequest, WorkspaceReleaseOutcome } from \"../delegation/runtime.ts\";\nimport { type GitRunResult, runGit } from \"./git-observer.ts\";\n\nconst DEFAULT_TIMEOUT_MS = 15_000;\n/** Fixed location beneath the workspace root, so a child's checkout is always findable and always inside the workspace. */\nconst WORKTREES_SEGMENT = join(\".apex-code\", \"worktrees\");\nconst BRANCH_PREFIX = \"apex-child-\";\n/**\n * Short branch ids keep `git branch` output readable. Eight hex characters of a\n * UUID still bound prefix collisions between concurrent children to ~2^-32 per\n * pair; the branch ref (not the checkout) is the durable artifact anyway.\n */\nconst SHORT_SESSION_ID_LENGTH = 8;\n\n/**\n * A session id names both a filesystem path and a branch here, so it must be a\n * single safe component. A caller-supplied delegation handle flows in as the\n * session id, and `../../` or a leading `-` must never become a path traversal\n * or an option injection. Same allowlist shape the checkpoint engine uses for\n * ref components.\n */\nfunction isSafeSessionId(sessionId: string): boolean {\n\treturn /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(sessionId) && !sessionId.includes(\"..\") && !sessionId.endsWith(\".lock\");\n}\n\nfunction shortSessionId(sessionId: string): string {\n\treturn sessionId.slice(0, SHORT_SESSION_ID_LENGTH);\n}\n\n/** One-line human explanation of a failed git run, for refusal and failure errors. */\nfunction describeFailure(result: GitRunResult): string {\n\tif (result.spawnError) return \"git could not be executed\";\n\tif (result.timedOut) return \"git timed out\";\n\tconst detail = result.stderr.trim().split(\"\\n\")[0] ?? \"\";\n\treturn detail ? `git failed: ${detail}` : `git failed with exit code ${result.code ?? \"unknown\"}`;\n}\n\nexport interface GitWorktreeWorkspaceOwnerOptions {\n\t/** Per-command timeout in milliseconds (default 15 000). */\n\ttimeoutMs?: number;\n}\n\n/**\n * The production workspace owner for worktree isolation (spec\n * 2026-09-09-run-and-child-session-architecture.md, \"Parallel work and\n * ownership\"). The workspace subsystem owns git; delegation stays git-free:\n * `prepare()` creates one linked worktree per child at\n * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` on branch\n * `apex-child-<short-sessionId>` and returns the request with `root` set, which\n * is how the child session's cwd ends up inside the worktree. `release()`\n * removes it again. Refusals are throws: outside a git repository (or without\n * a git executable) `prepare()` throws, and the delegation runtime surfaces\n * that throw as the delegation's failure.\n *\n * Cleanup choices, deliberately:\n * - `git worktree remove` runs without `--force` and there is no automatic\n * fallback. When the plain remove refuses -- typically a dirty tree, since a\n * child that wrote files leaves untracked content behind -- the tree is\n * inspected and *kept*, reported as a `kept: \"dirty\"` release outcome so\n * uncommitted child work is preserved. Only an explicit\n * `release(id, { force: true })` from the caller force-removes. The child's\n * branch ref survives either way, so the parent can still integrate through\n * an explicit git workflow, and this owner never merges, commits, or pushes\n * anything.\n * - A worktree that is already gone is tolerated, so releasing twice, or\n * releasing a failed delegation whose `prepare()` died before creating\n * anything, are both no-ops.\n * - The worktrees directory is kept out of the parent checkout's `git status`\n * through the repository-local `.git/info/exclude` -- the one ignore channel\n * that cannot touch tracked project files such as `.gitignore` (which the\n * spec forbids the coordinator from editing). Left unignored, the linked\n * worktrees would report as untracked churn and spuriously retire\n * verification results. Best effort only: a failure here never fails the\n * worktree itself.\n */\nexport class GitWorktreeWorkspaceOwner {\n\tprivate readonly workspaceRoot: string;\n\tprivate readonly timeoutMs: number;\n\n\tconstructor(workspaceRoot: string, options?: GitWorktreeWorkspaceOwnerOptions) {\n\t\tthis.workspaceRoot = workspaceRoot;\n\t\tthis.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n\t}\n\n\t/** The worktree directory this owner uses for one child session. */\n\tprivate worktreeDir(sessionId: string): string {\n\t\tif (!isSafeSessionId(sessionId)) {\n\t\t\tthrow new Error(`Worktree isolation refused: session id \"${sessionId}\" is not a safe path component.`);\n\t\t}\n\t\treturn join(this.workspaceRoot, WORKTREES_SEGMENT, sessionId);\n\t}\n\n\t/**\n\t * Create the child's worktree and return the request with `root` set to its\n\t * path. Throws (refusing the delegation) when the workspace is not a git\n\t * repository, git is unavailable, or the worktree cannot be created.\n\t */\n\tasync prepare(\n\t\trequest: ChildWorkspaceRequest & { sessionId: string },\n\t): Promise<ChildWorkspaceRequest & { root: string }> {\n\t\tconst dir = this.worktreeDir(request.sessionId);\n\t\tconst toplevel = await this.requireRepository(request.sessionId);\n\t\tawait this.keepWorktreesUntracked(toplevel);\n\t\tconst branch = `${BRANCH_PREFIX}${shortSessionId(request.sessionId)}`;\n\t\tconst added = await runGit(this.workspaceRoot, [\"worktree\", \"add\", \"-b\", branch, dir], this.timeoutMs);\n\t\tif (!added.ok) {\n\t\t\tthrow new Error(`Worktree isolation failed for session \"${request.sessionId}\": ${describeFailure(added)}.`);\n\t\t}\n\t\treturn { ...request, root: dir };\n\t}\n\n\t/**\n\t * Remove the child's worktree safely. Plain `git worktree remove` first; if\n\t * it refuses, the tree's status decides the outcome: uncommitted or\n\t * untracked content means the tree is kept (\"dirty\") so child work is\n\t * never destroyed silently; a clean tree that still cannot be removed is\n\t * reported as \"failed\". `--force` runs only when the caller passes\n\t * `options.force` -- the explicit escape hatch, never an automatic\n\t * fallback. Already-removed worktrees are tolerated and never throw.\n\t */\n\tasync release(sessionId: string, options?: { force?: boolean }): Promise<WorkspaceReleaseOutcome> {\n\t\tif (!isSafeSessionId(sessionId)) return { removed: true }; // an unsafe id can never have been prepared\n\t\tconst dir = this.worktreeDir(sessionId);\n\t\tif (!existsSync(dir)) {\n\t\t\t// Never created, already removed, or removed out of band: drop any\n\t\t\t// stale administrative entry and succeed either way.\n\t\t\tawait runGit(this.workspaceRoot, [\"worktree\", \"prune\"], this.timeoutMs);\n\t\t\treturn { removed: true };\n\t\t}\n\t\tif (options?.force) {\n\t\t\tconst forced = await runGit(this.workspaceRoot, [\"worktree\", \"remove\", \"--force\", dir], this.timeoutMs);\n\t\t\tif (forced.ok) return { removed: true };\n\t\t\treturn { removed: false, kept: \"failed\", dir, error: describeFailure(forced) };\n\t\t}\n\t\tconst removed = await runGit(this.workspaceRoot, [\"worktree\", \"remove\", dir], this.timeoutMs);\n\t\tif (removed.ok) return { removed: true };\n\t\t// The plain remove refused. Inspect the tree: uncommitted/untracked\n\t\t// content means the refusal is protective and the tree stays.\n\t\tconst status = await runGit(dir, [\"status\", \"--porcelain\"], this.timeoutMs);\n\t\tif (status.ok && status.stdout.toString(\"utf-8\").trim() !== \"\") {\n\t\t\treturn { removed: false, kept: \"dirty\", dir, error: describeFailure(removed) };\n\t\t}\n\t\treturn { removed: false, kept: \"failed\", dir, error: describeFailure(removed) };\n\t}\n\n\t/**\n\t * Read-only verification for explicit workspace recovery (spec\n\t * 2026-09-09-run-and-child-session-architecture.md, \"Workspace states and\n\t * explicit recovery\"): confirm `root` is still THIS owner's worktree for\n\t * `sessionId` in the CURRENT workspace. Three checks, each refusal naming\n\t * the check: (layout) the recorded path is exactly the owner's\n\t * `<workspaceRoot>/.apex-code/worktrees/<sessionId>` and exists;\n\t * (admin entry) `<root>/.git` is a linked-worktree pointer file whose gitdir\n\t * resolves into the current repository's `.git/worktrees`; (branch) the\n\t * checked-out branch is `apex-child-<short-sessionId>`. Never creates,\n\t * checks out, resets, or force-removes anything -- git inspection is\n\t * read-only (`rev-parse`, `status`) plus the administrative entry read.\n\t * Returns the worktree's dirty state so callers can report preserved\n\t * uncommitted work without a second inspection.\n\t */\n\tasync verify(sessionId: string, root: string): Promise<{ dirty: boolean }> {\n\t\tif (!isSafeSessionId(sessionId)) {\n\t\t\tthrow new Error(`layout check failed: session id \"${sessionId}\" is not a safe path component.`);\n\t\t}\n\t\t// (c) The directory sits at this owner's layout for the session.\n\t\tconst expected = this.worktreeDir(sessionId);\n\t\tif (resolve(root) !== expected) {\n\t\t\tthrow new Error(\n\t\t\t\t`layout check failed: recorded root \"${root}\" is not this workspace's worktree location for session \"${sessionId}\" (expected \"${expected}\").`,\n\t\t\t);\n\t\t}\n\t\tif (!existsSync(root)) {\n\t\t\tthrow new Error(`layout check failed: the recorded worktree root \"${root}\" does not exist.`);\n\t\t}\n\t\t// (a) The directory is a linked worktree of the CURRENT parent workspace:\n\t\t// git resolves its administrative entry, and the resolved gitdir must sit\n\t\t// inside this repository's worktrees dir. Git reads the pointer itself,\n\t\t// which direct file reads cannot do portably (Windows denies the open).\n\t\tconst resolved = await runGit(root, [\"rev-parse\", \"--absolute-git-dir\"], this.timeoutMs);\n\t\tif (!resolved.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: \"${root}\" is not a linked worktree (git could not resolve its administrative entry: ${describeFailure(resolved)}).`,\n\t\t\t);\n\t\t}\n\t\tconst gitdir = resolve(resolved.stdout.toString(\"utf-8\").trim());\n\t\tconst parentGitDir = await this.currentGitDir();\n\t\tconst worktreesDir = join(parentGitDir, \"worktrees\");\n\t\tconst rel = relative(worktreesDir, gitdir);\n\t\tif (rel === \"\" || isAbsolute(rel) || rel.startsWith(\"..\") || !existsSync(gitdir)) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: \"${root}\" is not a linked worktree of the current repository (its gitdir \"${gitdir}\" does not resolve into \"${worktreesDir}\").`,\n\t\t\t);\n\t\t}\n\t\t// (b) The checked-out branch is this child's own.\n\t\tconst branch = `${BRANCH_PREFIX}${shortSessionId(sessionId)}`;\n\t\tconst head = await runGit(root, [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], this.timeoutMs);\n\t\tconst checkedOut = head.ok ? head.stdout.toString(\"utf-8\").trim() : \"\";\n\t\tif (!head.ok || checkedOut !== branch) {\n\t\t\tthrow new Error(\n\t\t\t\t`branch check failed: the worktree for session \"${sessionId}\" has ${\n\t\t\t\t\thead.ok ? `\"${checkedOut}\" checked out` : describeFailure(head)\n\t\t\t\t}, expected \"${branch}\".`,\n\t\t\t);\n\t\t}\n\t\t// The dirty report is informational, not a gate: uncommitted child work is\n\t\t// exactly what recovery must preserve, never destroy.\n\t\tconst status = await runGit(root, [\"status\", \"--porcelain\"], this.timeoutMs);\n\t\tif (!status.ok) {\n\t\t\tthrow new Error(`status check failed: the worktree's status could not be read: ${describeFailure(status)}.`);\n\t\t}\n\t\treturn { dirty: status.stdout.toString(\"utf-8\").trim() !== \"\" };\n\t}\n\n\t/** The current workspace repository's absolute git dir, for admin-entry verification. */\n\tprivate async currentGitDir(): Promise<string> {\n\t\tconst probe = await runGit(this.workspaceRoot, [\"rev-parse\", \"--absolute-git-dir\"], this.timeoutMs);\n\t\tif (!probe.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`admin entry check failed: the current workspace is not a readable git repository (${describeFailure(probe)}).`,\n\t\t\t);\n\t\t}\n\t\treturn resolve(this.workspaceRoot, probe.stdout.toString(\"utf-8\").trim());\n\t}\n\n\t/** Verify git is usable and the workspace sits inside a repository; returns the repository toplevel. */\n\tprivate async requireRepository(sessionId: string): Promise<string> {\n\t\tconst probe = await runGit(this.workspaceRoot, [\"rev-parse\", \"--show-toplevel\"], this.timeoutMs);\n\t\tif (!probe.ok) {\n\t\t\tthrow new Error(\n\t\t\t\t`Worktree isolation refused for session \"${sessionId}\": ${describeFailure(probe)}; worktree isolation requires a git repository.`,\n\t\t\t);\n\t\t}\n\t\treturn probe.stdout.toString(\"utf-8\").trim();\n\t}\n\n\t/**\n\t * Keep `<...>/.apex-code/worktrees/` out of the parent checkout's status\n\t * via `.git/info/exclude` (idempotent; forward-slash pattern as git\n\t * requires, repo-relative so it also holds when the workspace root is a\n\t * subdirectory of the repository).\n\t */\n\tprivate async keepWorktreesUntracked(toplevel: string): Promise<void> {\n\t\tconst dir = join(this.workspaceRoot, WORKTREES_SEGMENT);\n\t\tconst repoRelative = relative(toplevel, dir);\n\t\tif (repoRelative === \"\" || isAbsolute(repoRelative) || repoRelative.startsWith(\"..\")) return;\n\t\tconst entry = `${repoRelative.split(sep).join(\"/\")}/`;\n\t\ttry {\n\t\t\tconst ignored = await runGit(toplevel, [\"check-ignore\", \"-q\", entry], this.timeoutMs);\n\t\t\tif (ignored.ok) return;\n\t\t\tconst excludePathResult = await runGit(toplevel, [\"rev-parse\", \"--git-path\", \"info/exclude\"], this.timeoutMs);\n\t\t\tif (!excludePathResult.ok) return;\n\t\t\tconst excludePath = resolve(toplevel, excludePathResult.stdout.toString(\"utf-8\").trim());\n\t\t\tmkdirSync(dirname(excludePath), { recursive: true });\n\t\t\tconst current = existsSync(excludePath) ? readFileSync(excludePath, \"utf-8\") : \"\";\n\t\t\tif (current.split(/\\r?\\n/).includes(entry)) return;\n\t\t\tconst needsNewLine = current.length > 0 && !current.endsWith(\"\\n\");\n\t\t\twriteFileSync(excludePath, `${needsNewLine ? \"\\n\" : \"\"}${entry}\\n`, { flag: \"a\", encoding: \"utf-8\" });\n\t\t} catch {\n\t\t\t// Hygiene only: an unwritable or unreadable exclude never fails the worktree.\n\t\t}\n\t}\n}\n"]}
package/dist/main.d.ts CHANGED
@@ -11,7 +11,6 @@ import { SettingsManager } from "./core/settings-manager.ts";
11
11
  export declare function createSessionManager(parsed: Args, cwd: string, sessionDir: string | undefined, settingsManager: SettingsManager): Promise<SessionManager>;
12
12
  export interface MainOptions {
13
13
  extensionFactories?: InlineExtension[];
14
- sessionLeaseOwner?: "main" | "supervisor";
15
14
  /** Already-parsed public CLI boundary value. */
16
15
  parsedCommand?: ParsedCliCommand;
17
16
  }
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,OAAO,EACN,KAAK,IAAI,EAGT,KAAK,gBAAgB,EAIrB,MAAM,eAAe,CAAC;AAsCvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAgBlE,OAAO,EAAkD,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAE3G,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAwS7D,wBAAsB,oBAAoB,CACzC,MAAM,EAAE,IAAI,EACZ,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,eAAe,EAAE,eAAe,GAC9B,OAAO,CAAC,cAAc,CAAC,CAsFzB;AAkHD,MAAM,WAAW,WAAW;IAC3B,kBAAkB,CAAC,EAAE,eAAe,EAAE,CAAC;IACvC,iBAAiB,CAAC,EAAE,MAAM,GAAG,YAAY,CAAC;IAC1C,gDAAgD;IAChD,aAAa,CAAC,EAAE,gBAAgB,CAAC;CACjC;AAED,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,iBAuf/D","sourcesContent":["/**\n * Main entry point for the coding agent CLI.\n *\n * This file handles CLI argument parsing and translates them into\n * createAgentSession() options. The SDK does the heavy lifting.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { type ImageContent, modelsAreEqual } from \"@earendil-works/pi-ai\";\nimport { setCapabilityOverrides } from \"@earendil-works/pi-tui\";\nimport chalk from \"chalk\";\nimport {\n\ttype Args,\n\ttype Mode,\n\tnormalizeSessionName,\n\ttype ParsedCliCommand,\n\tparseArgs,\n\tparseCliCommand,\n\tprintHelp,\n} from \"./cli/args.ts\";\nimport {\n\ttype AuthCheckResult,\n\tcheckProviderAuth,\n\tcreateAuthCheckModelRuntime,\n\tgetProviderCredential,\n} from \"./cli/auth-check.ts\";\nimport {\n\ttype AuthCommand,\n\tAuthCommandError,\n\tgetAuthCommandName,\n\tgetAuthCommandUsage,\n\tisAuthCommandHelp,\n\tparseAuthCommand,\n\tprintAuthCommandHelp,\n\tvalidateAuthCommandArgs,\n} from \"./cli/auth-command.ts\";\nimport { reportConcurrentSessionRefusal } from \"./cli/concurrent-session.ts\";\nimport { runCostCommand } from \"./cli/cost-command.ts\";\nimport { resolveCredentialForPrint } from \"./cli/credential-print.ts\";\nimport { processFileArguments } from \"./cli/file-processor.ts\";\nimport { buildInitialMessage } from \"./cli/initial-message.ts\";\nimport { listModels } from \"./cli/list-models.ts\";\nimport { runMcpCommand } from \"./cli/mcp-command.ts\";\nimport { createProjectTrustContext } from \"./cli/project-trust.ts\";\nimport { selectSession } from \"./cli/session-picker.ts\";\nimport { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from \"./cli/startup-ui.ts\";\nimport { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from \"./config.ts\";\nimport { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from \"./core/agent-session-runtime.ts\";\nimport {\n\ttype AgentSessionRuntimeDiagnostic,\n\tcreateAgentSessionFromServices,\n\tcreateAgentSessionServices,\n} from \"./core/agent-session-services.ts\";\nimport { formatNoModelsAvailableMessage } from \"./core/auth-guidance.ts\";\nimport { AuthStorage, ReadOnlyAuthStorage } from \"./core/auth-storage.ts\";\nimport { getApexEnvironment, setApexEnvironment } from \"./core/environment.ts\";\nimport { exportFromFile } from \"./core/export-html/index.ts\";\nimport type { InlineExtension } from \"./core/extensions/types.ts\";\nimport { applyHttpProxySettings, configureHttpDispatcher } from \"./core/http-dispatcher.ts\";\nimport { resolveCliModel, resolveModelScope, type ScopedModel } from \"./core/model-resolver.ts\";\nimport { ModelRuntime } from \"./core/model-runtime.ts\";\nimport { restoreStdout, takeOverStdout } from \"./core/output-guard.ts\";\nimport { resolveEffectiveMode, resolvePermissionModeForStartup } from \"./core/permissions/startup.ts\";\nimport { FilePermissionRuleStore } from \"./core/permissions/store.ts\";\nimport { type AppMode, resolveProjectTrusted } from \"./core/project-trust.ts\";\nimport type { CreateAgentSessionOptions } from \"./core/sdk.ts\";\nimport {\n\tformatMissingSessionCwdPrompt,\n\tgetMissingSessionCwdIssue,\n\tMissingSessionCwdError,\n\ttype SessionCwdIssue,\n} from \"./core/session-cwd.ts\";\nimport { acquireSessionLease, readLiveSessionLeases } from \"./core/session-lease.ts\";\nimport { assertValidSessionId, getDefaultSessionDirPath, SessionManager } from \"./core/session-manager.ts\";\nimport { collectSettingsDiagnostics, deduplicateDiagnostics } from \"./core/settings-diagnostics.ts\";\nimport { SettingsManager } from \"./core/settings-manager.ts\";\nimport { printTimings, resetTimings, time } from \"./core/timings.ts\";\nimport { unclassifiedToolNames } from \"./core/tools/contract-snapshot.ts\";\nimport { hasTrustRequiringProjectResources, ProjectTrustStore } from \"./core/trust-manager.ts\";\nimport { builtInExtensions } from \"./extensions/index.ts\";\nimport { runMigrations, showDeprecationWarnings } from \"./migrations.ts\";\nimport type { AcpServer } from \"./modes/acp/server.ts\";\nimport { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from \"./modes/index.ts\";\nimport { initTheme, stopThemeWatcher } from \"./modes/interactive/theme/theme.ts\";\nimport { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from \"./package-manager-cli.ts\";\nimport { isLocalPath, normalizePath, resolvePath } from \"./utils/paths.ts\";\nimport { cleanupWindowsSelfUpdateQuarantine } from \"./utils/windows-self-update.ts\";\n\nconst EXTENSION_LOAD_FAILURE_HINT = 'Hint: Start without extensions using \"apex-code -ne\".';\n\n/**\n * Read all content from piped stdin.\n * Returns undefined if stdin is a TTY (interactive terminal).\n */\nasync function readPipedStdin(): Promise<string | undefined> {\n\t// If stdin is a TTY, we're running interactively - don't read stdin\n\tif (process.stdin.isTTY) {\n\t\treturn undefined;\n\t}\n\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tprocess.stdin.on(\"end\", () => {\n\t\t\tresolve(data.trim() || undefined);\n\t\t});\n\t\tprocess.stdin.resume();\n\t});\n}\n\nfunction reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {\n\tfor (const diagnostic of diagnostics) {\n\t\tconst color = diagnostic.type === \"error\" ? chalk.red : diagnostic.type === \"warning\" ? chalk.yellow : chalk.dim;\n\t\tconst prefix = diagnostic.type === \"error\" ? \"Error: \" : diagnostic.type === \"warning\" ? \"Warning: \" : \"\";\n\t\tconsole.error(color(`${prefix}${diagnostic.message}`));\n\t}\n}\n\nfunction isTruthyEnvFlag(value: string | undefined): boolean {\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\nfunction resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {\n\tif (parsed.mode === \"rpc\") {\n\t\treturn \"rpc\";\n\t}\n\tif (parsed.mode === \"acp\") {\n\t\treturn \"acp\";\n\t}\n\tif (parsed.mode === \"json\") {\n\t\treturn \"json\";\n\t}\n\tif (parsed.print || !stdinIsTTY || !stdoutIsTTY) {\n\t\treturn \"print\";\n\t}\n\treturn \"interactive\";\n}\n\nfunction toPrintOutputMode(appMode: AppMode): Exclude<Mode, \"rpc\" | \"acp\"> {\n\treturn appMode === \"json\" ? \"json\" : \"text\";\n}\n\nfunction isPlainRuntimeMetadataCommand(parsed: Args): boolean {\n\treturn !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);\n}\n\nasync function runAuthCommand(args: string[]): Promise<boolean> {\n\tif (isAuthCommandHelp(args)) {\n\t\tprintAuthCommandHelp();\n\t\treturn true;\n\t}\n\n\tlet command: AuthCommand | undefined;\n\ttry {\n\t\tcommand = parseAuthCommand(args);\n\t} catch (error) {\n\t\tconst message = error instanceof AuthCommandError ? error.message : \"Failed to parse auth command\";\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n\tif (!command) return false;\n\n\tconst parsed = parseArgs(command.args);\n\tif (parsed.unknownFlags.size > 0) {\n\t\tconst option = parsed.unknownFlags.keys().next().value;\n\t\tconsole.error(chalk.red(`Unknown option --${option} for \"${getAuthCommandName(command.kind)}\".`));\n\t\tconsole.error(chalk.dim(`Use \"${APP_NAME} --help\" or \"${getAuthCommandUsage(command.kind)}\".`));\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n\ttry {\n\t\tif (parsed.diagnostics.length > 0) {\n\t\t\tthrow new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join(\"\\n\"));\n\t\t}\n\t\tif (command.kind !== \"check\") {\n\t\t\tconst signal = AbortSignal.timeout(15_000);\n\t\t\tconst modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal });\n\t\t\tconst credential = await resolveCredentialForPrint(\n\t\t\t\tparsed,\n\t\t\t\tmodelRuntime,\n\t\t\t\tcommand.kind,\n\t\t\t\tcommand.minExpiryMs,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tprocess.stdout.write(`${credential}\\n`);\n\t\t\treturn true;\n\t\t}\n\n\t\tconst requestedAuth = validateAuthCommandArgs(parsed, command.kind);\n\t\tlet result: AuthCheckResult;\n\t\tlet credential: string | undefined;\n\t\ttry {\n\t\t\tconst credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create();\n\t\t\tconst modelRuntime = await createAuthCheckModelRuntime(credentials);\n\t\t\tresult = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh });\n\t\t\tif (command.credentials && result.status === \"ready\") {\n\t\t\t\tcredential = await getProviderCredential(result.provider, modelRuntime, credentials, {\n\t\t\t\t\trefresh: !command.noRefresh,\n\t\t\t\t});\n\t\t\t\tif (!credential) {\n\t\t\t\t\tresult = { status: \"not_ready\", provider: result.provider, reason: \"credential_not_available\" };\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\tresult = {\n\t\t\t\tstatus: \"invalid\",\n\t\t\t\tprovider: requestedAuth.provider ?? requestedAuth.model!,\n\t\t\t\treason: \"invalid_state\",\n\t\t\t};\n\t\t}\n\t\tconst output = command.json\n\t\t\t? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) })\n\t\t\t: (credential ?? result.status);\n\t\tprocess.stdout.write(`${output}\\n`);\n\t\tprocess.exitCode = result.status === \"ready\" ? 0 : result.status === \"not_ready\" ? 1 : 2;\n\t} catch (error) {\n\t\tconst message = error instanceof AuthCommandError ? error.message : \"Failed to resolve credential\";\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exitCode = command.kind === \"check\" ? 2 : 1;\n\t}\n\treturn true;\n}\n\nasync function prepareInitialMessage(\n\tparsed: Args,\n\tautoResizeImages: boolean,\n\tstdinContent?: string,\n): Promise<{\n\tinitialMessage?: string;\n\tinitialImages?: ImageContent[];\n}> {\n\tif (parsed.fileArgs.length === 0) {\n\t\treturn buildInitialMessage({ parsed, stdinContent });\n\t}\n\n\tconst { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });\n\treturn buildInitialMessage({\n\t\tparsed,\n\t\tfileText: text,\n\t\tfileImages: images,\n\t\tstdinContent,\n\t});\n}\n\n/** Result from resolving a session argument */\ntype ResolvedSession =\n\t| { type: \"path\"; path: string } // Direct file path\n\t| { type: \"local\"; path: string } // Found in current project\n\t| { type: \"global\"; path: string; cwd: string } // Found in different project\n\t| { type: \"not_found\"; arg: string }; // Not found anywhere\n\n/**\n * Resolve a session argument to a file path.\n * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.\n */\nasync function findLocalSessionByExactId(\n\tsessionId: string,\n\tcwd: string,\n\tsessionDir?: string,\n): Promise<{ type: \"local\"; path: string } | undefined> {\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatch = localSessions.find((s) => s.id === sessionId);\n\treturn localMatch ? { type: \"local\", path: localMatch.path } : undefined;\n}\n\nasync function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {\n\t// If it looks like a file path, resolve it before handing it to the session manager.\n\tif (sessionArg.includes(\"/\") || sessionArg.includes(\"\\\\\") || sessionArg.endsWith(\".jsonl\")) {\n\t\treturn { type: \"path\", path: resolvePath(sessionArg, cwd) };\n\t}\n\n\t// Try to match as session ID in current project first\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatch =\n\t\tlocalSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg));\n\n\tif (localMatch) {\n\t\treturn { type: \"local\", path: localMatch.path };\n\t}\n\n\t// Try global search across all projects\n\tconst allSessions = await SessionManager.listAll(sessionDir);\n\tconst globalMatch =\n\t\tallSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg));\n\n\tif (globalMatch) {\n\t\treturn { type: \"global\", path: globalMatch.path, cwd: globalMatch.cwd };\n\t}\n\n\t// Not found anywhere\n\treturn { type: \"not_found\", arg: sessionArg };\n}\n\n/** Prompt user for yes/no confirmation */\nasync function promptConfirm(message: string): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst rl = createInterface({\n\t\t\tinput: process.stdin,\n\t\t\toutput: process.stdout,\n\t\t});\n\t\trl.question(`${message} [y/N] `, (answer) => {\n\t\t\trl.close();\n\t\t\tresolve(answer.toLowerCase() === \"y\" || answer.toLowerCase() === \"yes\");\n\t\t});\n\t});\n}\n\nfunction validateForkFlags(parsed: Args): void {\n\tif (!parsed.fork) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t\tparsed.noSession ? \"--no-session\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction validateSessionIdFlags(parsed: Args): void {\n\tif (parsed.sessionId === undefined) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --session-id cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n\n\ttry {\n\t\tassertValidSessionId(parsed.sessionId);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction openSessionOrExit(path: string, sessionDir?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.open(path, sessionDir);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, sessionId?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.forkFrom(sourcePath, cwd, sessionDir, { id: sessionId });\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nexport async function createSessionManager(\n\tparsed: Args,\n\tcwd: string,\n\tsessionDir: string | undefined,\n\tsettingsManager: SettingsManager,\n): Promise<SessionManager> {\n\tif (parsed.noSession || parsed.help || parsed.listModels !== undefined) {\n\t\treturn SessionManager.inMemory(cwd, parsed.sessionId !== undefined ? { id: parsed.sessionId } : undefined);\n\t}\n\n\tif (parsed.fork) {\n\t\tif (parsed.sessionId) {\n\t\t\tconst existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);\n\t\t\tif (existingTarget) {\n\t\t\t\tconsole.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\n\t\tconst resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\tcase \"global\":\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId);\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.session) {\n\t\tconst resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\t\treturn openSessionOrExit(resolved.path, sessionDir);\n\n\t\t\tcase \"global\": {\n\t\t\t\tconsole.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));\n\t\t\t\tconst shouldFork = await promptConfirm(\"Fork this session into current directory?\");\n\t\t\t\tif (!shouldFork) {\n\t\t\t\t\tconsole.log(chalk.dim(\"Aborted.\"));\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\t\t\t}\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.resume) {\n\t\ttry {\n\t\t\tconst selectedPath = await selectSession(\n\t\t\t\t(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),\n\t\t\t\t(onProgress) => SessionManager.listAll(sessionDir, onProgress),\n\t\t\t\tsettingsManager,\n\t\t\t);\n\t\t\tif (!selectedPath) {\n\t\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\treturn SessionManager.open(selectedPath, sessionDir);\n\t\t} finally {\n\t\t\tstopThemeWatcher();\n\t\t}\n\t}\n\n\tif (parsed.continue) {\n\t\treturn SessionManager.continueRecent(cwd, sessionDir);\n\t}\n\n\tif (parsed.sessionId) {\n\t\tconst existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);\n\t\tif (existingSession) {\n\t\t\treturn SessionManager.open(existingSession.path, sessionDir);\n\t\t}\n\t\tconsole.error(\n\t\t\tchalk.yellow(\n\t\t\t\t`Warning: No project session found with id '${parsed.sessionId}'; creating a new session with that id.`,\n\t\t\t),\n\t\t);\n\t}\n\n\treturn SessionManager.create(cwd, sessionDir, { id: parsed.sessionId });\n}\n\nfunction buildSessionOptions(\n\tparsed: Args,\n\tscopedModels: ScopedModel[],\n\thasExistingSession: boolean,\n\tmodelRuntime: ModelRuntime,\n\tsettingsManager: SettingsManager,\n): {\n\toptions: CreateAgentSessionOptions;\n\tcliThinkingFromModel: boolean;\n\tdiagnostics: AgentSessionRuntimeDiagnostic[];\n} {\n\tconst options: CreateAgentSessionOptions = {};\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\tlet cliThinkingFromModel = false;\n\n\t// Model from CLI\n\t// - supports --provider <name> --model <pattern>\n\t// - supports --model <provider>/<pattern>\n\tif (parsed.model) {\n\t\tconst resolved = resolveCliModel({\n\t\t\tcliProvider: parsed.provider,\n\t\t\tcliModel: parsed.model,\n\t\t\tcliThinking: parsed.thinking,\n\t\t\tmodelRuntime,\n\t\t});\n\t\tif (resolved.warning) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: resolved.warning });\n\t\t}\n\t\tif (resolved.error) {\n\t\t\tdiagnostics.push({ type: \"error\", message: resolved.error });\n\t\t}\n\t\tif (resolved.model) {\n\t\t\toptions.model = resolved.model;\n\t\t\t// Allow \"--model <pattern>:<thinking>\" as a shorthand.\n\t\t\t// Explicit --thinking still takes precedence (applied later).\n\t\t\tif (!parsed.thinking && resolved.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = resolved.thinkingLevel;\n\t\t\t\tcliThinkingFromModel = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!options.model && scopedModels.length > 0 && !hasExistingSession) {\n\t\t// Check if saved default is in scoped models - use it if so, otherwise first scoped model\n\t\tconst savedProvider = settingsManager.getDefaultProvider();\n\t\tconst savedModelId = settingsManager.getDefaultModel();\n\t\tconst savedModel = savedProvider && savedModelId ? modelRuntime.getModel(savedProvider, savedModelId) : undefined;\n\t\tconst savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;\n\n\t\tif (savedInScope) {\n\t\t\toptions.model = savedInScope.model;\n\t\t\t// Use thinking level from scoped model config if explicitly set\n\t\t\tif (!parsed.thinking && savedInScope.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = savedInScope.thinkingLevel;\n\t\t\t}\n\t\t} else {\n\t\t\toptions.model = scopedModels[0].model;\n\t\t\t// Use thinking level from first scoped model if explicitly set\n\t\t\tif (!parsed.thinking && scopedModels[0].thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = scopedModels[0].thinkingLevel;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Thinking level from CLI (takes precedence over scoped model thinking levels set above)\n\tif (parsed.thinking) {\n\t\toptions.thinkingLevel = parsed.thinking;\n\t}\n\n\t// Scoped models for Ctrl+P cycling\n\t// Keep thinking level undefined when not explicitly set in the model pattern.\n\t// Undefined means \"inherit current session thinking level\" during cycling.\n\tif (scopedModels.length > 0) {\n\t\toptions.scopedModels = scopedModels.map((sm) => ({\n\t\t\tmodel: sm.model,\n\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t}));\n\t}\n\n\t// API key from CLI - set as a non-persistent runtime override\n\t// (handled by caller before createAgentSession)\n\n\t// Tools\n\tif (parsed.noTools) {\n\t\toptions.noTools = \"all\";\n\t} else if (parsed.noBuiltinTools) {\n\t\toptions.noTools = \"builtin\";\n\t}\n\tif (parsed.tools) {\n\t\toptions.tools = [...parsed.tools];\n\t}\n\tif (parsed.excludeTools) {\n\t\toptions.excludeTools = [...parsed.excludeTools];\n\t}\n\n\treturn { options, cliThinkingFromModel, diagnostics };\n}\n\nfunction resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {\n\treturn paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));\n}\n\nasync function promptForMissingSessionCwd(\n\tissue: SessionCwdIssue,\n\tsettingsManager: SettingsManager,\n): Promise<string | undefined> {\n\treturn showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [\n\t\t{ label: \"Continue\", value: issue.fallbackCwd },\n\t\t{ label: \"Cancel\", value: undefined },\n\t]);\n}\n\nexport interface MainOptions {\n\textensionFactories?: InlineExtension[];\n\tsessionLeaseOwner?: \"main\" | \"supervisor\";\n\t/** Already-parsed public CLI boundary value. */\n\tparsedCommand?: ParsedCliCommand;\n}\n\nexport async function main(args: string[], options?: MainOptions) {\n\tresetTimings();\n\tconst extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];\n\tconst offlineMode = args.includes(\"--offline\") || isTruthyEnvFlag(getApexEnvironment(\"APEX_CODE_OFFLINE\"));\n\tif (offlineMode) {\n\t\tsetApexEnvironment(\"APEX_CODE_OFFLINE\", \"1\");\n\t\tsetApexEnvironment(\"APEX_CODE_SKIP_VERSION_CHECK\", \"1\");\n\t}\n\n\tif (await runAuthCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await runMcpCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (process.platform === \"win32\") {\n\t\tcleanupWindowsSelfUpdateQuarantine(getPackageDir());\n\t}\n\tcleanupManagedInstall();\n\n\tconst cwd = process.cwd();\n\tconst agentDir = getAgentDir();\n\tconst bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });\n\tapplyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);\n\tconfigureHttpDispatcher();\n\n\tif (args[0] === \"cost\") {\n\t\tawait runCostCommand(agentDir, args.slice(1));\n\t\treturn;\n\t}\n\n\tif (await handlePackageCommand(args, { extensionFactories })) {\n\t\tconst exitCode = process.exitCode ?? 0;\n\t\tif (process.platform === \"win32\" && exitCode === 0 && args[0] === \"update\") {\n\t\t\t// We normally prefer process.exit(0) for package commands so bad extensions cannot keep\n\t\t\t// one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0)\n\t\t\t// runs during teardown; let successful `pi update` drain naturally instead.\n\t\t\t// https://github.com/nodejs/node/issues/56645\n\t\t\treturn;\n\t\t}\n\t\tprocess.exit(exitCode);\n\t\treturn;\n\t}\n\n\tif (await handleConfigCommand(args, { extensionFactories })) {\n\t\treturn;\n\t}\n\n\tconst parsed = options?.parsedCommand?.args ?? parseCliCommand(args).args;\n\tif (parsed.diagnostics.length > 0) {\n\t\tfor (const d of parsed.diagnostics) {\n\t\t\tconst color = d.type === \"error\" ? chalk.red : chalk.yellow;\n\t\t\tconsole.error(color(`${d.type === \"error\" ? \"Error\" : \"Warning\"}: ${d.message}`));\n\t\t}\n\t\tif (parsed.diagnostics.some((d) => d.type === \"error\")) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"parseArgs\");\n\n\tif (parsed.version) {\n\t\tconsole.log(VERSION);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.export) {\n\t\tlet result: string;\n\t\ttry {\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tresult = await exportFromFile(parsed.export, outputPath);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Failed to export session\";\n\t\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconsole.log(`Exported to: ${result}`);\n\t\tprocess.exit(0);\n\t}\n\n\tlet appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);\n\tconst shouldTakeOverStdout = appMode !== \"interactive\" && !isPlainRuntimeMetadataCommand(parsed);\n\tif (shouldTakeOverStdout) {\n\t\ttakeOverStdout();\n\t}\n\n\t// A headless caller cannot answer an `ask`; require an explicit operator\n\t// decision before creating a session. Metadata commands never create one.\n\tconst permissionModeStartup = resolvePermissionModeForStartup({\n\t\tinteractive: appMode === \"interactive\",\n\t\trequestedMode: parsed.permissionMode,\n\t});\n\tif (!permissionModeStartup.ok && !parsed.help && parsed.listModels === undefined) {\n\t\tconsole.error(chalk.red(`Error: ${permissionModeStartup.message}`));\n\t\tprocess.exit(1);\n\t}\n\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tvalidateForkFlags(parsed);\n\tvalidateSessionIdFlags(parsed);\n\n\t// Run migrations (pass cwd for project-local migrations)\n\tconst { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);\n\ttime(\"runMigrations\");\n\n\tconst startupSettingsManager = SettingsManager.create(cwd, agentDir);\n\tconst startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager);\n\n\t// Experimental first-time setup: theme choice and analytics opt-in.\n\t// Runs before any runtime services are created so the chosen settings apply everywhere.\n\tif (appMode === \"interactive\" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) {\n\t\tawait showFirstTimeSetup(startupSettingsManager);\n\t\ttime(\"firstTimeSetup\");\n\t}\n\n\tif (appMode === \"interactive\" && parsed.useTheme !== undefined) {\n\t\tstartupSettingsManager.applyOverrides({ theme: parsed.useTheme });\n\t}\n\n\t// Decide the final runtime cwd before creating cwd-bound runtime services.\n\t// --session and --resume may select a session from another project, so project-local\n\t// settings, resources, provider registrations, and models must be resolved only after\n\t// the target session cwd is known. The startup-cwd settings manager is used only for\n\t// sessionDir lookup during session selection.\n\tconst envSessionDir = process.env[ENV_SESSION_DIR];\n\tconst sessionDir =\n\t\t(parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??\n\t\t(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??\n\t\tstartupSettingsManager.getSessionDir();\n\tconst wantsPersistentSession = !parsed.noSession && !parsed.help && parsed.listModels === undefined;\n\tconst mainOwnsSessionLease = options?.sessionLeaseOwner !== \"supervisor\";\n\tif (mainOwnsSessionLease && wantsPersistentSession && !parsed.allowConcurrent) {\n\t\tconst liveSessions = readLiveSessionLeases(sessionDir ?? getDefaultSessionDirPath(cwd), cwd);\n\t\tif (liveSessions.length > 0) {\n\t\t\treportConcurrentSessionRefusal(liveSessions, cwd);\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tlet sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);\n\tif (mainOwnsSessionLease && wantsPersistentSession) {\n\t\tconst lease = acquireSessionLease(sessionManager.getSessionDir(), cwd, sessionManager.getSessionId());\n\t\tprocess.once(\"exit\", () => lease.release());\n\t}\n\tconst missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);\n\tif (missingSessionCwdIssue) {\n\t\tif (appMode === \"interactive\") {\n\t\t\tconst selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);\n\t\t\tif (!selectedCwd) {\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\tsessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);\n\t\t} else {\n\t\t\tconsole.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\tif (parsed.name !== undefined) {\n\t\tconst name = normalizeSessionName(parsed.name);\n\t\tif (name === undefined) {\n\t\t\tconsole.error(chalk.red(\"Error: --name requires a non-empty value\"));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tsessionManager.appendSessionInfo(name);\n\t}\n\ttime(\"createSessionManager\");\n\n\tconst trustStore = new ProjectTrustStore(agentDir);\n\tconst sessionCwd = sessionManager.getCwd();\n\tconst autoTrustOnReloadCwd =\n\t\tparsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd)\n\t\t\t? sessionCwd\n\t\t\t: undefined;\n\tconst trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? \"print\" : appMode;\n\tconst projectTrustByCwd = new Map<string, boolean>();\n\n\tconst resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);\n\tconst resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);\n\tconst resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);\n\tconst resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);\n\tconst createRuntime: CreateAgentSessionRuntimeFactory = async ({\n\t\tcwd,\n\t\tagentDir,\n\t\tsessionManager,\n\t\tsessionStartEvent,\n\t\tprojectTrustContext,\n\t}) => {\n\t\tconst isInitialRuntime = sessionStartEvent === undefined;\n\t\tconst projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\t\tconst cachedProjectTrust = projectTrustByCwd.get(cwd);\n\t\tconst hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);\n\t\tconst shouldResolveProjectTrust =\n\t\t\tparsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;\n\t\tconst projectTrusted = shouldResolveProjectTrust\n\t\t\t? false\n\t\t\t: (cachedProjectTrust ??\n\t\t\t\tparsed.projectTrustOverride ??\n\t\t\t\t(!hasTrustRequiringResources || trustStore.get(cwd) === true));\n\t\tconst runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });\n\t\tconst services = await createAgentSessionServices({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tsessionId: sessionManager.getSessionId(),\n\t\t\tsettingsManager: runtimeSettingsManager,\n\t\t\tmodelRuntimeSignal: AbortSignal.timeout(15_000),\n\t\t\textensionFlagValues: parsed.unknownFlags,\n\t\t\tresourceLoaderReloadOptions: shouldResolveProjectTrust\n\t\t\t\t? {\n\t\t\t\t\t\tresolveProjectTrust: async ({ extensionsResult }) => {\n\t\t\t\t\t\t\tconst trusted = await resolveProjectTrusted({\n\t\t\t\t\t\t\t\tcwd,\n\t\t\t\t\t\t\t\ttrustStore,\n\t\t\t\t\t\t\t\ttrustOverride: parsed.projectTrustOverride,\n\t\t\t\t\t\t\t\tdefaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),\n\t\t\t\t\t\t\t\textensionsResult,\n\t\t\t\t\t\t\t\tprojectTrustContext:\n\t\t\t\t\t\t\t\t\tprojectTrustContext ??\n\t\t\t\t\t\t\t\t\tcreateProjectTrustContext({\n\t\t\t\t\t\t\t\t\t\tcwd,\n\t\t\t\t\t\t\t\t\t\tmode: isInitialRuntime ? trustPromptMode : appMode,\n\t\t\t\t\t\t\t\t\t\tsettingsManager: startupSettingsManager,\n\t\t\t\t\t\t\t\t\t\thasUI: isInitialRuntime && trustPromptMode === \"interactive\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\tonExtensionError: (message) => projectTrustDiagnostics.push({ type: \"warning\", message }),\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tprojectTrustByCwd.set(cwd, trusted);\n\t\t\t\t\t\t\treturn trusted;\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t: undefined,\n\t\t\tresourceLoaderOptions: {\n\t\t\t\tadditionalExtensionPaths: resolvedExtensionPaths,\n\t\t\t\tadditionalSkillPaths: resolvedSkillPaths,\n\t\t\t\tadditionalPromptTemplatePaths: resolvedPromptTemplatePaths,\n\t\t\t\tadditionalThemePaths: resolvedThemePaths,\n\t\t\t\tnoExtensions: parsed.noExtensions,\n\t\t\t\tnoSkills: parsed.noSkills,\n\t\t\t\tnoPromptTemplates: parsed.noPromptTemplates,\n\t\t\t\tnoThemes: parsed.noThemes,\n\t\t\t\tnoContextFiles: parsed.noContextFiles,\n\t\t\t\tsystemPrompt: parsed.systemPrompt,\n\t\t\t\tappendSystemPrompt: parsed.appendSystemPrompt,\n\t\t\t\textensionFactories,\n\t\t\t},\n\t\t});\n\t\tconst { settingsManager, modelRuntime, resourceLoader } = services;\n\t\tconst permissionStore = new FilePermissionRuleStore({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tprojectTrusted: settingsManager.isProjectTrusted(),\n\t\t\tinitialRules: (parsed.allowedTools ?? []).map((toolName) => ({\n\t\t\t\tsource: \"cliArg\" as const,\n\t\t\t\ttoolName,\n\t\t\t\tbehavior: \"allow\" as const,\n\t\t\t})),\n\t\t});\n\t\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [\n\t\t\t...projectTrustDiagnostics,\n\t\t\t...services.diagnostics,\n\t\t\t...collectSettingsDiagnostics(settingsManager),\n\t\t\t...resourceLoader.getExtensions().errors.map(({ path, error }) => ({\n\t\t\t\ttype: \"error\" as const,\n\t\t\t\tmessage: `Failed to load extension \"${path}\": ${error}`,\n\t\t\t})),\n\t\t];\n\n\t\tconst modelPatterns = parsed.models ?? settingsManager.getEnabledModels();\n\t\tconst scopedModels =\n\t\t\tmodelPatterns && modelPatterns.length > 0\n\t\t\t\t? await resolveModelScope(modelPatterns, modelRuntime, { signal: AbortSignal.timeout(15_000) })\n\t\t\t\t: [];\n\t\tconst {\n\t\t\toptions: sessionOptions,\n\t\t\tcliThinkingFromModel,\n\t\t\tdiagnostics: sessionOptionDiagnostics,\n\t\t} = buildSessionOptions(\n\t\t\tparsed,\n\t\t\tscopedModels,\n\t\t\tsessionManager.buildSessionContext().messages.length > 0,\n\t\t\tmodelRuntime,\n\t\t\tsettingsManager,\n\t\t);\n\t\tdiagnostics.push(...sessionOptionDiagnostics);\n\n\t\tif (parsed.apiKey) {\n\t\t\tif (!sessionOptions.model) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\tmessage: \"--api-key requires a model to be specified via --model, --provider/--model, or --models\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tawait modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);\n\t\t\t}\n\t\t}\n\n\t\tconst created = await createAgentSessionFromServices({\n\t\t\tservices,\n\t\t\tsessionManager,\n\t\t\tsessionStartEvent,\n\t\t\tmodel: sessionOptions.model,\n\t\t\tthinkingLevel: sessionOptions.thinkingLevel,\n\t\t\tscopedModels: sessionOptions.scopedModels,\n\t\t\ttools: sessionOptions.tools,\n\t\t\texcludeTools: sessionOptions.excludeTools,\n\t\t\tnoTools: sessionOptions.noTools,\n\t\t\tcustomTools: sessionOptions.customTools,\n\t\t\t// ACP bridge (spec 2026-08-31-acp-adapter.md): permission asks route to\n\t\t\t// the ACP client through the server created by runAcpMode; absent\n\t\t\t// server (non-ACP modes) leaves today's fail-closed behavior.\n\t\t\tpermissionResponderFactory:\n\t\t\t\tparsed.mode === \"acp\"\n\t\t\t\t\t? () => {\n\t\t\t\t\t\t\tconst server = acpServerRef.current;\n\t\t\t\t\t\t\tif (!server) return undefined;\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tasync ask(request) {\n\t\t\t\t\t\t\t\t\treturn server.askPermission(\n\t\t\t\t\t\t\t\t\t\tserver.currentSessionId ?? \"\",\n\t\t\t\t\t\t\t\t\t\trequest.toolName,\n\t\t\t\t\t\t\t\t\t\trequest.description,\n\t\t\t\t\t\t\t\t\t\trequest.sessionScope !== undefined,\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t: undefined,\n\t\t\tpermissionGate: {\n\t\t\t\tstore: permissionStore,\n\t\t\t\tflagMode: parsed.permissionMode,\n\t\t\t\tgetMode: async () =>\n\t\t\t\t\tresolveEffectiveMode(parsed.permissionMode, (await permissionStore.snapshot()).modesBySource),\n\t\t\t},\n\t\t});\n\t\t// ADR 0010: a conservative default nobody can see is indistinguishable from a bug,\n\t\t// so the projection is what reports it rather than a filter written here.\n\t\tconst undeclared = unclassifiedToolNames(created.session.getToolContractSnapshot());\n\t\tif (undeclared.length > 0) {\n\t\t\tdiagnostics.push({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Unclassified tool contracts (approval required): ${undeclared.join(\", \")}`,\n\t\t\t});\n\t\t}\n\t\tconst cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;\n\t\tif (created.session.model && cliThinkingOverride) {\n\t\t\tcreated.session.setThinkingLevel(created.session.thinkingLevel);\n\t\t}\n\n\t\treturn {\n\t\t\t...created,\n\t\t\tservices,\n\t\t\tdiagnostics,\n\t\t};\n\t};\n\t// ACP bridge (spec 2026-08-31-acp-adapter.md): runAcpMode installs the live\n\t// server here once the mode dispatch creates it.\n\tconst acpServerRef: { current?: AcpServer } = { current: undefined };\n\ttime(\"createRuntime\");\n\tconst runtime = await createAgentSessionRuntime(createRuntime, {\n\t\tcwd: sessionManager.getCwd(),\n\t\tagentDir,\n\t\tsessionManager,\n\t});\n\ttime(\"createAgentSessionRuntime\");\n\tconst { services, session, modelFallbackMessage } = runtime;\n\tconst { settingsManager, modelRuntime, resourceLoader } = services;\n\tsetCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());\n\tapplyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);\n\tconfigureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());\n\n\tif (parsed.help) {\n\t\treportDiagnostics(startupSettingsDiagnostics);\n\t\tconst extensionFlags = resourceLoader\n\t\t\t.getExtensions()\n\t\t\t.extensions.flatMap((extension) => Array.from(extension.flags.values()));\n\t\tprintHelp(extensionFlags);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.listModels !== undefined) {\n\t\treportDiagnostics(startupSettingsDiagnostics);\n\t\tconst searchPattern = typeof parsed.listModels === \"string\" ? parsed.listModels : undefined;\n\t\tawait listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000));\n\t\tprocess.exit(0);\n\t}\n\n\t// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC\n\tlet stdinContent: string | undefined;\n\tif (appMode !== \"rpc\") {\n\t\tstdinContent = await readPipedStdin();\n\t\tif (stdinContent !== undefined && appMode === \"interactive\") {\n\t\t\tappMode = \"print\";\n\t\t}\n\t}\n\ttime(\"readPipedStdin\");\n\n\tconst { initialMessage, initialImages } = await prepareInitialMessage(\n\t\tparsed,\n\t\tsettingsManager.getImageAutoResize(),\n\t\tstdinContent,\n\t);\n\ttime(\"prepareInitialMessage\");\n\tinitTheme(settingsManager.getTheme(), appMode === \"interactive\");\n\ttime(\"initTheme\");\n\n\t// Show deprecation warnings in interactive mode\n\tif (appMode === \"interactive\" && deprecationWarnings.length > 0) {\n\t\tawait showDeprecationWarnings(deprecationWarnings);\n\t}\n\n\ttime(\"resolveModelScope\");\n\tconst startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]);\n\tconst hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === \"error\");\n\tif (appMode !== \"interactive\" || hasRuntimeErrors) {\n\t\treportDiagnostics(startupDiagnostics);\n\t}\n\tif (hasRuntimeErrors) {\n\t\tif (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes(\"Failed to load extension\"))) {\n\t\t\tconsole.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT));\n\t\t}\n\t\tprocess.exit(1);\n\t}\n\ttime(\"createAgentSession\");\n\n\tif (appMode !== \"interactive\" && !session.model) {\n\t\tconsole.error(chalk.red(formatNoModelsAvailableMessage()));\n\t\tprocess.exit(1);\n\t}\n\n\tconst startupBenchmark = isTruthyEnvFlag(getApexEnvironment(\"APEX_CODE_STARTUP_BENCHMARK\"));\n\tif (startupBenchmark && appMode !== \"interactive\") {\n\t\tconsole.error(chalk.red(\"Error: APEX_CODE_STARTUP_BENCHMARK only supports interactive mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\t// RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization.\n\tif (!offlineMode && appMode === \"rpc\") {\n\t\tconst controller = new AbortController();\n\t\tconst timeout = setTimeout(() => controller.abort(), 15_000);\n\t\tvoid modelRuntime\n\t\t\t.refresh({ signal: controller.signal })\n\t\t\t.catch(() => {})\n\t\t\t.finally(() => clearTimeout(timeout));\n\t}\n\n\tif (appMode === \"rpc\") {\n\t\tprintTimings();\n\t\tawait runRpcMode(runtime);\n\t} else if (appMode === \"acp\") {\n\t\tprintTimings();\n\t\tawait runAcpMode(runtime, acpServerRef);\n\t} else if (appMode === \"interactive\") {\n\t\tconst interactiveMode = new InteractiveMode(runtime, {\n\t\t\tmigratedProviders,\n\t\t\tstartupDiagnostics,\n\t\t\tmodelFallbackMessage,\n\t\t\tautoTrustOnReloadCwd,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\tinitialMessages: parsed.messages,\n\t\t\tverbose: parsed.verbose,\n\t\t\ttuiMode: parsed.tuiMode,\n\t\t\tinitialThemeSetting: parsed.useTheme,\n\t\t});\n\t\tif (startupBenchmark) {\n\t\t\tawait interactiveMode.init();\n\t\t\ttime(\"interactiveMode.init\");\n\t\t\t// Give the TUI's stdin handler a brief chance to consume terminal query replies\n\t\t\t// (Kitty keyboard protocol, device attributes, cell size) before restoring the terminal.\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 150));\n\t\t\tinteractiveMode.stop();\n\t\t\tstopThemeWatcher();\n\t\t\tprintTimings();\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tprintTimings();\n\t\tawait interactiveMode.run();\n\t} else {\n\t\tprintTimings();\n\t\tconst exitCode = await runPrintMode(runtime, {\n\t\t\tmode: toPrintOutputMode(appMode),\n\t\t\tmessages: parsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t});\n\t\tstopThemeWatcher();\n\t\trestoreStdout();\n\t\tif (exitCode !== 0) {\n\t\t\tprocess.exitCode = exitCode;\n\t\t}\n\t\treturn;\n\t}\n}\n"]}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EACN,KAAK,IAAI,EAGT,KAAK,gBAAgB,EAIrB,MAAM,eAAe,CAAC;AAsCvB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAgBlE,OAAO,EAAkD,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAE3G,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAwS7D,wBAAsB,oBAAoB,CACzC,MAAM,EAAE,IAAI,EACZ,GAAG,EAAE,MAAM,EACX,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,eAAe,EAAE,eAAe,GAC9B,OAAO,CAAC,cAAc,CAAC,CAsFzB;AAkHD,MAAM,WAAW,WAAW;IAC3B,kBAAkB,CAAC,EAAE,eAAe,EAAE,CAAC;IACvC,gDAAgD;IAChD,aAAa,CAAC,EAAE,gBAAgB,CAAC;CACjC;AAED,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,WAAW,iBA8gB/D","sourcesContent":["/**\n * Main entry point for the coding agent CLI.\n *\n * This file handles CLI argument parsing and translates them into\n * createAgentSession() options. The SDK does the heavy lifting.\n */\n\nimport { createInterface } from \"node:readline\";\nimport { type ImageContent, modelsAreEqual } from \"@earendil-works/pi-ai\";\nimport { setCapabilityOverrides } from \"@earendil-works/pi-tui\";\nimport chalk from \"chalk\";\nimport { runAgentLifecycleCommand } from \"./cli/agent-lifecycle.ts\";\nimport {\n\ttype Args,\n\ttype Mode,\n\tnormalizeSessionName,\n\ttype ParsedCliCommand,\n\tparseArgs,\n\tparseCliCommand,\n\tprintHelp,\n} from \"./cli/args.ts\";\nimport {\n\ttype AuthCheckResult,\n\tcheckProviderAuth,\n\tcreateAuthCheckModelRuntime,\n\tgetProviderCredential,\n} from \"./cli/auth-check.ts\";\nimport {\n\ttype AuthCommand,\n\tAuthCommandError,\n\tgetAuthCommandName,\n\tgetAuthCommandUsage,\n\tisAuthCommandHelp,\n\tparseAuthCommand,\n\tprintAuthCommandHelp,\n\tvalidateAuthCommandArgs,\n} from \"./cli/auth-command.ts\";\nimport { reportConcurrentSessionRefusal } from \"./cli/concurrent-session.ts\";\nimport { runCostCommand } from \"./cli/cost-command.ts\";\nimport { resolveCredentialForPrint } from \"./cli/credential-print.ts\";\nimport { processFileArguments } from \"./cli/file-processor.ts\";\nimport { buildInitialMessage } from \"./cli/initial-message.ts\";\nimport { listModels } from \"./cli/list-models.ts\";\nimport { runMcpCommand } from \"./cli/mcp-command.ts\";\nimport { createProjectTrustContext } from \"./cli/project-trust.ts\";\nimport { selectSession } from \"./cli/session-picker.ts\";\nimport { shouldRunFirstTimeSetup, showFirstTimeSetup, showStartupSelector } from \"./cli/startup-ui.ts\";\nimport { APP_NAME, ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from \"./config.ts\";\nimport { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from \"./core/agent-session-runtime.ts\";\nimport {\n\ttype AgentSessionRuntimeDiagnostic,\n\tcreateAgentSessionFromServices,\n\tcreateAgentSessionServices,\n} from \"./core/agent-session-services.ts\";\nimport { formatNoModelsAvailableMessage } from \"./core/auth-guidance.ts\";\nimport { AuthStorage, ReadOnlyAuthStorage } from \"./core/auth-storage.ts\";\nimport { getApexEnvironment, setApexEnvironment } from \"./core/environment.ts\";\nimport { exportFromFile } from \"./core/export-html/index.ts\";\nimport type { InlineExtension } from \"./core/extensions/types.ts\";\nimport { applyHttpProxySettings, configureHttpDispatcher } from \"./core/http-dispatcher.ts\";\nimport { resolveCliModel, resolveModelScope, type ScopedModel } from \"./core/model-resolver.ts\";\nimport { ModelRuntime } from \"./core/model-runtime.ts\";\nimport { restoreStdout, takeOverStdout } from \"./core/output-guard.ts\";\nimport { resolveEffectiveMode, resolvePermissionModeForStartup } from \"./core/permissions/startup.ts\";\nimport { FilePermissionRuleStore } from \"./core/permissions/store.ts\";\nimport { type AppMode, resolveProjectTrusted } from \"./core/project-trust.ts\";\nimport type { CreateAgentSessionOptions } from \"./core/sdk.ts\";\nimport {\n\tformatMissingSessionCwdPrompt,\n\tgetMissingSessionCwdIssue,\n\tMissingSessionCwdError,\n\ttype SessionCwdIssue,\n} from \"./core/session-cwd.ts\";\nimport { acquireSessionLease, readLiveSessionLeases } from \"./core/session-lease.ts\";\nimport { assertValidSessionId, getDefaultSessionDirPath, SessionManager } from \"./core/session-manager.ts\";\nimport { collectSettingsDiagnostics, deduplicateDiagnostics } from \"./core/settings-diagnostics.ts\";\nimport { SettingsManager } from \"./core/settings-manager.ts\";\nimport { printTimings, resetTimings, time } from \"./core/timings.ts\";\nimport { unclassifiedToolNames } from \"./core/tools/contract-snapshot.ts\";\nimport { hasTrustRequiringProjectResources, ProjectTrustStore } from \"./core/trust-manager.ts\";\nimport { builtInExtensions } from \"./extensions/index.ts\";\nimport { runMigrations, showDeprecationWarnings } from \"./migrations.ts\";\nimport type { AcpServer } from \"./modes/acp/server.ts\";\nimport { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from \"./modes/index.ts\";\nimport { initTheme, stopThemeWatcher } from \"./modes/interactive/theme/theme.ts\";\nimport { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from \"./package-manager-cli.ts\";\nimport { isLocalPath, normalizePath, resolvePath } from \"./utils/paths.ts\";\nimport { cleanupWindowsSelfUpdateQuarantine } from \"./utils/windows-self-update.ts\";\n\nconst EXTENSION_LOAD_FAILURE_HINT = 'Hint: Start without extensions using \"apex-code -ne\".';\n\n/**\n * Read all content from piped stdin.\n * Returns undefined if stdin is a TTY (interactive terminal).\n */\nasync function readPipedStdin(): Promise<string | undefined> {\n\t// If stdin is a TTY, we're running interactively - don't read stdin\n\tif (process.stdin.isTTY) {\n\t\treturn undefined;\n\t}\n\n\treturn new Promise((resolve) => {\n\t\tlet data = \"\";\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.on(\"data\", (chunk) => {\n\t\t\tdata += chunk;\n\t\t});\n\t\tprocess.stdin.on(\"end\", () => {\n\t\t\tresolve(data.trim() || undefined);\n\t\t});\n\t\tprocess.stdin.resume();\n\t});\n}\n\nfunction reportDiagnostics(diagnostics: readonly AgentSessionRuntimeDiagnostic[]): void {\n\tfor (const diagnostic of diagnostics) {\n\t\tconst color = diagnostic.type === \"error\" ? chalk.red : diagnostic.type === \"warning\" ? chalk.yellow : chalk.dim;\n\t\tconst prefix = diagnostic.type === \"error\" ? \"Error: \" : diagnostic.type === \"warning\" ? \"Warning: \" : \"\";\n\t\tconsole.error(color(`${prefix}${diagnostic.message}`));\n\t}\n}\n\nfunction isTruthyEnvFlag(value: string | undefined): boolean {\n\tif (!value) return false;\n\treturn value === \"1\" || value.toLowerCase() === \"true\" || value.toLowerCase() === \"yes\";\n}\n\nfunction resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {\n\tif (parsed.mode === \"rpc\") {\n\t\treturn \"rpc\";\n\t}\n\tif (parsed.mode === \"acp\") {\n\t\treturn \"acp\";\n\t}\n\tif (parsed.mode === \"json\") {\n\t\treturn \"json\";\n\t}\n\tif (parsed.print || !stdinIsTTY || !stdoutIsTTY) {\n\t\treturn \"print\";\n\t}\n\treturn \"interactive\";\n}\n\nfunction toPrintOutputMode(appMode: AppMode): Exclude<Mode, \"rpc\" | \"acp\"> {\n\treturn appMode === \"json\" ? \"json\" : \"text\";\n}\n\nfunction isPlainRuntimeMetadataCommand(parsed: Args): boolean {\n\treturn !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined);\n}\n\nasync function runAuthCommand(args: string[]): Promise<boolean> {\n\tif (isAuthCommandHelp(args)) {\n\t\tprintAuthCommandHelp();\n\t\treturn true;\n\t}\n\n\tlet command: AuthCommand | undefined;\n\ttry {\n\t\tcommand = parseAuthCommand(args);\n\t} catch (error) {\n\t\tconst message = error instanceof AuthCommandError ? error.message : \"Failed to parse auth command\";\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n\tif (!command) return false;\n\n\tconst parsed = parseArgs(command.args);\n\tif (parsed.unknownFlags.size > 0) {\n\t\tconst option = parsed.unknownFlags.keys().next().value;\n\t\tconsole.error(chalk.red(`Unknown option --${option} for \"${getAuthCommandName(command.kind)}\".`));\n\t\tconsole.error(chalk.dim(`Use \"${APP_NAME} --help\" or \"${getAuthCommandUsage(command.kind)}\".`));\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n\ttry {\n\t\tif (parsed.diagnostics.length > 0) {\n\t\t\tthrow new AuthCommandError(parsed.diagnostics.map((diagnostic) => diagnostic.message).join(\"\\n\"));\n\t\t}\n\t\tif (command.kind !== \"check\") {\n\t\t\tconst signal = AbortSignal.timeout(15_000);\n\t\t\tconst modelRuntime = await ModelRuntime.create({ allowModelNetwork: false, signal });\n\t\t\tconst credential = await resolveCredentialForPrint(\n\t\t\t\tparsed,\n\t\t\t\tmodelRuntime,\n\t\t\t\tcommand.kind,\n\t\t\t\tcommand.minExpiryMs,\n\t\t\t\tsignal,\n\t\t\t);\n\t\t\tprocess.stdout.write(`${credential}\\n`);\n\t\t\treturn true;\n\t\t}\n\n\t\tconst requestedAuth = validateAuthCommandArgs(parsed, command.kind);\n\t\tlet result: AuthCheckResult;\n\t\tlet credential: string | undefined;\n\t\ttry {\n\t\t\tconst credentials = command.noRefresh ? new ReadOnlyAuthStorage() : AuthStorage.create();\n\t\t\tconst modelRuntime = await createAuthCheckModelRuntime(credentials);\n\t\t\tresult = await checkProviderAuth(parsed, modelRuntime, { refresh: !command.noRefresh });\n\t\t\tif (command.credentials && result.status === \"ready\") {\n\t\t\t\tcredential = await getProviderCredential(result.provider, modelRuntime, credentials, {\n\t\t\t\t\trefresh: !command.noRefresh,\n\t\t\t\t});\n\t\t\t\tif (!credential) {\n\t\t\t\t\tresult = { status: \"not_ready\", provider: result.provider, reason: \"credential_not_available\" };\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\tresult = {\n\t\t\t\tstatus: \"invalid\",\n\t\t\t\tprovider: requestedAuth.provider ?? requestedAuth.model!,\n\t\t\t\treason: \"invalid_state\",\n\t\t\t};\n\t\t}\n\t\tconst output = command.json\n\t\t\t? JSON.stringify({ ...result, ...(credential ? { credentials: credential } : {}) })\n\t\t\t: (credential ?? result.status);\n\t\tprocess.stdout.write(`${output}\\n`);\n\t\tprocess.exitCode = result.status === \"ready\" ? 0 : result.status === \"not_ready\" ? 1 : 2;\n\t} catch (error) {\n\t\tconst message = error instanceof AuthCommandError ? error.message : \"Failed to resolve credential\";\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exitCode = command.kind === \"check\" ? 2 : 1;\n\t}\n\treturn true;\n}\n\nasync function prepareInitialMessage(\n\tparsed: Args,\n\tautoResizeImages: boolean,\n\tstdinContent?: string,\n): Promise<{\n\tinitialMessage?: string;\n\tinitialImages?: ImageContent[];\n}> {\n\tif (parsed.fileArgs.length === 0) {\n\t\treturn buildInitialMessage({ parsed, stdinContent });\n\t}\n\n\tconst { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });\n\treturn buildInitialMessage({\n\t\tparsed,\n\t\tfileText: text,\n\t\tfileImages: images,\n\t\tstdinContent,\n\t});\n}\n\n/** Result from resolving a session argument */\ntype ResolvedSession =\n\t| { type: \"path\"; path: string } // Direct file path\n\t| { type: \"local\"; path: string } // Found in current project\n\t| { type: \"global\"; path: string; cwd: string } // Found in different project\n\t| { type: \"not_found\"; arg: string }; // Not found anywhere\n\n/**\n * Resolve a session argument to a file path.\n * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.\n */\nasync function findLocalSessionByExactId(\n\tsessionId: string,\n\tcwd: string,\n\tsessionDir?: string,\n): Promise<{ type: \"local\"; path: string } | undefined> {\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatch = localSessions.find((s) => s.id === sessionId);\n\treturn localMatch ? { type: \"local\", path: localMatch.path } : undefined;\n}\n\nasync function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): Promise<ResolvedSession> {\n\t// If it looks like a file path, resolve it before handing it to the session manager.\n\tif (sessionArg.includes(\"/\") || sessionArg.includes(\"\\\\\") || sessionArg.endsWith(\".jsonl\")) {\n\t\treturn { type: \"path\", path: resolvePath(sessionArg, cwd) };\n\t}\n\n\t// Try to match as session ID in current project first\n\tconst localSessions = await SessionManager.list(cwd, sessionDir);\n\tconst localMatch =\n\t\tlocalSessions.find((s) => s.id === sessionArg) ?? localSessions.find((s) => s.id.startsWith(sessionArg));\n\n\tif (localMatch) {\n\t\treturn { type: \"local\", path: localMatch.path };\n\t}\n\n\t// Try global search across all projects\n\tconst allSessions = await SessionManager.listAll(sessionDir);\n\tconst globalMatch =\n\t\tallSessions.find((s) => s.id === sessionArg) ?? allSessions.find((s) => s.id.startsWith(sessionArg));\n\n\tif (globalMatch) {\n\t\treturn { type: \"global\", path: globalMatch.path, cwd: globalMatch.cwd };\n\t}\n\n\t// Not found anywhere\n\treturn { type: \"not_found\", arg: sessionArg };\n}\n\n/** Prompt user for yes/no confirmation */\nasync function promptConfirm(message: string): Promise<boolean> {\n\treturn new Promise((resolve) => {\n\t\tconst rl = createInterface({\n\t\t\tinput: process.stdin,\n\t\t\toutput: process.stdout,\n\t\t});\n\t\trl.question(`${message} [y/N] `, (answer) => {\n\t\t\trl.close();\n\t\t\tresolve(answer.toLowerCase() === \"y\" || answer.toLowerCase() === \"yes\");\n\t\t});\n\t});\n}\n\nfunction validateForkFlags(parsed: Args): void {\n\tif (!parsed.fork) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t\tparsed.noSession ? \"--no-session\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --fork cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction validateSessionIdFlags(parsed: Args): void {\n\tif (parsed.sessionId === undefined) return;\n\n\tconst conflictingFlags = [\n\t\tparsed.session ? \"--session\" : undefined,\n\t\tparsed.continue ? \"--continue\" : undefined,\n\t\tparsed.resume ? \"--resume\" : undefined,\n\t].filter((flag): flag is string => flag !== undefined);\n\n\tif (conflictingFlags.length > 0) {\n\t\tconsole.error(chalk.red(`Error: --session-id cannot be combined with ${conflictingFlags.join(\", \")}`));\n\t\tprocess.exit(1);\n\t}\n\n\ttry {\n\t\tassertValidSessionId(parsed.sessionId);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction openSessionOrExit(path: string, sessionDir?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.open(path, sessionDir);\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nfunction forkSessionOrExit(sourcePath: string, cwd: string, sessionDir?: string, sessionId?: string): SessionManager {\n\ttry {\n\t\treturn SessionManager.forkFrom(sourcePath, cwd, sessionDir, { id: sessionId });\n\t} catch (error: unknown) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\tprocess.exit(1);\n\t}\n}\n\nexport async function createSessionManager(\n\tparsed: Args,\n\tcwd: string,\n\tsessionDir: string | undefined,\n\tsettingsManager: SettingsManager,\n): Promise<SessionManager> {\n\tif (parsed.noSession || parsed.help || parsed.listModels !== undefined) {\n\t\treturn SessionManager.inMemory(cwd, parsed.sessionId !== undefined ? { id: parsed.sessionId } : undefined);\n\t}\n\n\tif (parsed.fork) {\n\t\tif (parsed.sessionId) {\n\t\t\tconst existingTarget = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);\n\t\t\tif (existingTarget) {\n\t\t\t\tconsole.error(chalk.red(`Session already exists with id '${parsed.sessionId}'`));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\n\t\tconst resolved = await resolveSessionPath(parsed.fork, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\tcase \"global\":\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir, parsed.sessionId);\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.session) {\n\t\tconst resolved = await resolveSessionPath(parsed.session, cwd, sessionDir);\n\n\t\tswitch (resolved.type) {\n\t\t\tcase \"path\":\n\t\t\tcase \"local\":\n\t\t\t\treturn openSessionOrExit(resolved.path, sessionDir);\n\n\t\t\tcase \"global\": {\n\t\t\t\tconsole.log(chalk.yellow(`Session found in different project: ${resolved.cwd}`));\n\t\t\t\tconst shouldFork = await promptConfirm(\"Fork this session into current directory?\");\n\t\t\t\tif (!shouldFork) {\n\t\t\t\t\tconsole.log(chalk.dim(\"Aborted.\"));\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t}\n\t\t\t\treturn forkSessionOrExit(resolved.path, cwd, sessionDir);\n\t\t\t}\n\n\t\t\tcase \"not_found\":\n\t\t\t\tconsole.error(chalk.red(`No session found matching '${resolved.arg}'`));\n\t\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tif (parsed.resume) {\n\t\ttry {\n\t\t\tconst selectedPath = await selectSession(\n\t\t\t\t(onProgress) => SessionManager.list(cwd, sessionDir, onProgress),\n\t\t\t\t(onProgress) => SessionManager.listAll(sessionDir, onProgress),\n\t\t\t\tsettingsManager,\n\t\t\t);\n\t\t\tif (!selectedPath) {\n\t\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\treturn SessionManager.open(selectedPath, sessionDir);\n\t\t} finally {\n\t\t\tstopThemeWatcher();\n\t\t}\n\t}\n\n\tif (parsed.continue) {\n\t\treturn SessionManager.continueRecent(cwd, sessionDir);\n\t}\n\n\tif (parsed.sessionId) {\n\t\tconst existingSession = await findLocalSessionByExactId(parsed.sessionId, cwd, sessionDir);\n\t\tif (existingSession) {\n\t\t\treturn SessionManager.open(existingSession.path, sessionDir);\n\t\t}\n\t\tconsole.error(\n\t\t\tchalk.yellow(\n\t\t\t\t`Warning: No project session found with id '${parsed.sessionId}'; creating a new session with that id.`,\n\t\t\t),\n\t\t);\n\t}\n\n\treturn SessionManager.create(cwd, sessionDir, { id: parsed.sessionId });\n}\n\nfunction buildSessionOptions(\n\tparsed: Args,\n\tscopedModels: ScopedModel[],\n\thasExistingSession: boolean,\n\tmodelRuntime: ModelRuntime,\n\tsettingsManager: SettingsManager,\n): {\n\toptions: CreateAgentSessionOptions;\n\tcliThinkingFromModel: boolean;\n\tdiagnostics: AgentSessionRuntimeDiagnostic[];\n} {\n\tconst options: CreateAgentSessionOptions = {};\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\tlet cliThinkingFromModel = false;\n\n\t// Model from CLI\n\t// - supports --provider <name> --model <pattern>\n\t// - supports --model <provider>/<pattern>\n\tif (parsed.model) {\n\t\tconst resolved = resolveCliModel({\n\t\t\tcliProvider: parsed.provider,\n\t\t\tcliModel: parsed.model,\n\t\t\tcliThinking: parsed.thinking,\n\t\t\tmodelRuntime,\n\t\t});\n\t\tif (resolved.warning) {\n\t\t\tdiagnostics.push({ type: \"warning\", message: resolved.warning });\n\t\t}\n\t\tif (resolved.error) {\n\t\t\tdiagnostics.push({ type: \"error\", message: resolved.error });\n\t\t}\n\t\tif (resolved.model) {\n\t\t\toptions.model = resolved.model;\n\t\t\t// Allow \"--model <pattern>:<thinking>\" as a shorthand.\n\t\t\t// Explicit --thinking still takes precedence (applied later).\n\t\t\tif (!parsed.thinking && resolved.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = resolved.thinkingLevel;\n\t\t\t\tcliThinkingFromModel = true;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!options.model && scopedModels.length > 0 && !hasExistingSession) {\n\t\t// Check if saved default is in scoped models - use it if so, otherwise first scoped model\n\t\tconst savedProvider = settingsManager.getDefaultProvider();\n\t\tconst savedModelId = settingsManager.getDefaultModel();\n\t\tconst savedModel = savedProvider && savedModelId ? modelRuntime.getModel(savedProvider, savedModelId) : undefined;\n\t\tconst savedInScope = savedModel ? scopedModels.find((sm) => modelsAreEqual(sm.model, savedModel)) : undefined;\n\n\t\tif (savedInScope) {\n\t\t\toptions.model = savedInScope.model;\n\t\t\t// Use thinking level from scoped model config if explicitly set\n\t\t\tif (!parsed.thinking && savedInScope.thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = savedInScope.thinkingLevel;\n\t\t\t}\n\t\t} else {\n\t\t\toptions.model = scopedModels[0].model;\n\t\t\t// Use thinking level from first scoped model if explicitly set\n\t\t\tif (!parsed.thinking && scopedModels[0].thinkingLevel) {\n\t\t\t\toptions.thinkingLevel = scopedModels[0].thinkingLevel;\n\t\t\t}\n\t\t}\n\t}\n\n\t// Thinking level from CLI (takes precedence over scoped model thinking levels set above)\n\tif (parsed.thinking) {\n\t\toptions.thinkingLevel = parsed.thinking;\n\t}\n\n\t// Scoped models for Ctrl+P cycling\n\t// Keep thinking level undefined when not explicitly set in the model pattern.\n\t// Undefined means \"inherit current session thinking level\" during cycling.\n\tif (scopedModels.length > 0) {\n\t\toptions.scopedModels = scopedModels.map((sm) => ({\n\t\t\tmodel: sm.model,\n\t\t\tthinkingLevel: sm.thinkingLevel,\n\t\t}));\n\t}\n\n\t// API key from CLI - set as a non-persistent runtime override\n\t// (handled by caller before createAgentSession)\n\n\t// Tools\n\tif (parsed.noTools) {\n\t\toptions.noTools = \"all\";\n\t} else if (parsed.noBuiltinTools) {\n\t\toptions.noTools = \"builtin\";\n\t}\n\tif (parsed.tools) {\n\t\toptions.tools = [...parsed.tools];\n\t}\n\tif (parsed.excludeTools) {\n\t\toptions.excludeTools = [...parsed.excludeTools];\n\t}\n\n\treturn { options, cliThinkingFromModel, diagnostics };\n}\n\nfunction resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | undefined {\n\treturn paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));\n}\n\nasync function promptForMissingSessionCwd(\n\tissue: SessionCwdIssue,\n\tsettingsManager: SettingsManager,\n): Promise<string | undefined> {\n\treturn showStartupSelector(settingsManager, formatMissingSessionCwdPrompt(issue), [\n\t\t{ label: \"Continue\", value: issue.fallbackCwd },\n\t\t{ label: \"Cancel\", value: undefined },\n\t]);\n}\n\nexport interface MainOptions {\n\textensionFactories?: InlineExtension[];\n\t/** Already-parsed public CLI boundary value. */\n\tparsedCommand?: ParsedCliCommand;\n}\n\nexport async function main(args: string[], options?: MainOptions) {\n\tresetTimings();\n\tconst extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];\n\tconst offlineMode = args.includes(\"--offline\") || isTruthyEnvFlag(getApexEnvironment(\"APEX_CODE_OFFLINE\"));\n\tif (offlineMode) {\n\t\tsetApexEnvironment(\"APEX_CODE_OFFLINE\", \"1\");\n\t\tsetApexEnvironment(\"APEX_CODE_SKIP_VERSION_CHECK\", \"1\");\n\t}\n\n\tif (await runAuthCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (await runMcpCommand(args)) {\n\t\treturn;\n\t}\n\n\tif (process.platform === \"win32\") {\n\t\tcleanupWindowsSelfUpdateQuarantine(getPackageDir());\n\t}\n\tcleanupManagedInstall();\n\n\tconst cwd = process.cwd();\n\tconst agentDir = getAgentDir();\n\tconst bootstrapSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });\n\tapplyHttpProxySettings(bootstrapSettingsManager.getGlobalSettings().httpProxy);\n\tconfigureHttpDispatcher();\n\n\tif (args[0] === \"cost\") {\n\t\tawait runCostCommand(agentDir, args.slice(1));\n\t\treturn;\n\t}\n\n\tif (await handlePackageCommand(args, { extensionFactories })) {\n\t\tconst exitCode = process.exitCode ?? 0;\n\t\tif (process.platform === \"win32\" && exitCode === 0 && args[0] === \"update\") {\n\t\t\t// We normally prefer process.exit(0) for package commands so bad extensions cannot keep\n\t\t\t// one-shot commands alive. On Windows, Node can assert after fetch() if process.exit(0)\n\t\t\t// runs during teardown; let successful `pi update` drain naturally instead.\n\t\t\t// https://github.com/nodejs/node/issues/56645\n\t\t\treturn;\n\t\t}\n\t\tprocess.exit(exitCode);\n\t\treturn;\n\t}\n\n\tif (await handleConfigCommand(args, { extensionFactories })) {\n\t\treturn;\n\t}\n\n\tconst parsed = options?.parsedCommand?.args ?? parseCliCommand(args).args;\n\tif (parsed.diagnostics.length > 0) {\n\t\tfor (const d of parsed.diagnostics) {\n\t\t\tconst color = d.type === \"error\" ? chalk.red : chalk.yellow;\n\t\t\tconsole.error(color(`${d.type === \"error\" ? \"Error\" : \"Warning\"}: ${d.message}`));\n\t\t}\n\t\tif (parsed.diagnostics.some((d) => d.type === \"error\")) {\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\ttime(\"parseArgs\");\n\n\tif (parsed.version) {\n\t\tconsole.log(VERSION);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.export) {\n\t\tlet result: string;\n\t\ttry {\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tresult = await exportFromFile(parsed.export, outputPath);\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : \"Failed to export session\";\n\t\t\tconsole.error(chalk.red(`Error: ${message}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tconsole.log(`Exported to: ${result}`);\n\t\tprocess.exit(0);\n\t}\n\n\tlet appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);\n\t// `agent <operation>` is a one-shot command, never an interactive prompt: coerce it\n\t// into the non-interactive flow so it runs after normal session construction.\n\tif (parsed.agent && appMode === \"interactive\") {\n\t\tappMode = \"print\";\n\t}\n\tif (parsed.agent && (appMode === \"rpc\" || appMode === \"acp\")) {\n\t\tconsole.error(\n\t\t\tchalk.red(\n\t\t\t\t`Error: \"agent ${parsed.agent.operation}\" runs a one-shot session and cannot be combined with --mode ${appMode}; use the protocol's own agent/* methods instead.`,\n\t\t\t),\n\t\t);\n\t\tprocess.exit(1);\n\t}\n\tconst shouldTakeOverStdout = appMode !== \"interactive\" && !isPlainRuntimeMetadataCommand(parsed);\n\tif (shouldTakeOverStdout) {\n\t\ttakeOverStdout();\n\t}\n\n\t// A headless caller cannot answer an `ask`; require an explicit operator\n\t// decision before creating a session. Metadata commands never create one.\n\tconst permissionModeStartup = resolvePermissionModeForStartup({\n\t\tinteractive: appMode === \"interactive\",\n\t\trequestedMode: parsed.permissionMode,\n\t});\n\tif (!permissionModeStartup.ok && !parsed.help && parsed.listModels === undefined) {\n\t\tconsole.error(chalk.red(`Error: ${permissionModeStartup.message}`));\n\t\tprocess.exit(1);\n\t}\n\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\tvalidateForkFlags(parsed);\n\tvalidateSessionIdFlags(parsed);\n\n\t// Run migrations (pass cwd for project-local migrations)\n\tconst { migratedAuthProviders: migratedProviders, deprecationWarnings } = runMigrations(cwd);\n\ttime(\"runMigrations\");\n\n\tconst startupSettingsManager = SettingsManager.create(cwd, agentDir);\n\tconst startupSettingsDiagnostics = collectSettingsDiagnostics(startupSettingsManager);\n\n\t// Experimental first-time setup: theme choice and analytics opt-in.\n\t// Runs before any runtime services are created so the chosen settings apply everywhere.\n\tif (appMode === \"interactive\" && !parsed.help && parsed.listModels === undefined && shouldRunFirstTimeSetup()) {\n\t\tawait showFirstTimeSetup(startupSettingsManager);\n\t\ttime(\"firstTimeSetup\");\n\t}\n\n\tif (appMode === \"interactive\" && parsed.useTheme !== undefined) {\n\t\tstartupSettingsManager.applyOverrides({ theme: parsed.useTheme });\n\t}\n\n\t// Decide the final runtime cwd before creating cwd-bound runtime services.\n\t// --session and --resume may select a session from another project, so project-local\n\t// settings, resources, provider registrations, and models must be resolved only after\n\t// the target session cwd is known. The startup-cwd settings manager is used only for\n\t// sessionDir lookup during session selection.\n\tconst envSessionDir = process.env[ENV_SESSION_DIR];\n\tconst sessionDir =\n\t\t(parsed.sessionDir ? normalizePath(parsed.sessionDir) : undefined) ??\n\t\t(envSessionDir ? expandTildePath(envSessionDir) : undefined) ??\n\t\tstartupSettingsManager.getSessionDir();\n\tconst wantsPersistentSession = !parsed.noSession && !parsed.help && parsed.listModels === undefined;\n\tif (wantsPersistentSession && !parsed.allowConcurrent) {\n\t\tconst liveSessions = readLiveSessionLeases(sessionDir ?? getDefaultSessionDirPath(cwd), cwd);\n\t\tif (liveSessions.length > 0) {\n\t\t\treportConcurrentSessionRefusal(liveSessions, cwd);\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tlet sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);\n\tif (wantsPersistentSession) {\n\t\tconst lease = acquireSessionLease(sessionManager.getSessionDir(), cwd, sessionManager.getSessionId());\n\t\tprocess.once(\"exit\", () => lease.release());\n\t}\n\tconst missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);\n\tif (missingSessionCwdIssue) {\n\t\tif (appMode === \"interactive\") {\n\t\t\tconst selectedCwd = await promptForMissingSessionCwd(missingSessionCwdIssue, startupSettingsManager);\n\t\t\tif (!selectedCwd) {\n\t\t\t\tprocess.exit(0);\n\t\t\t}\n\t\t\tsessionManager = SessionManager.open(missingSessionCwdIssue.sessionFile!, sessionDir, selectedCwd);\n\t\t} else {\n\t\t\tconsole.error(chalk.red(new MissingSessionCwdError(missingSessionCwdIssue).message));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\tif (parsed.name !== undefined) {\n\t\tconst name = normalizeSessionName(parsed.name);\n\t\tif (name === undefined) {\n\t\t\tconsole.error(chalk.red(\"Error: --name requires a non-empty value\"));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tsessionManager.appendSessionInfo(name);\n\t}\n\ttime(\"createSessionManager\");\n\n\tconst trustStore = new ProjectTrustStore(agentDir);\n\tconst sessionCwd = sessionManager.getCwd();\n\tconst autoTrustOnReloadCwd =\n\t\tparsed.projectTrustOverride === undefined && !hasTrustRequiringProjectResources(sessionCwd)\n\t\t\t? sessionCwd\n\t\t\t: undefined;\n\tconst trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? \"print\" : appMode;\n\tconst projectTrustByCwd = new Map<string, boolean>();\n\n\tconst resolvedExtensionPaths = resolveCliPaths(cwd, parsed.extensions);\n\tconst resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills);\n\tconst resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates);\n\tconst resolvedThemePaths = resolveCliPaths(cwd, parsed.themes);\n\tconst createRuntime: CreateAgentSessionRuntimeFactory = async ({\n\t\tcwd,\n\t\tagentDir,\n\t\tsessionManager,\n\t\tsessionStartEvent,\n\t\tprojectTrustContext,\n\t}) => {\n\t\tconst isInitialRuntime = sessionStartEvent === undefined;\n\t\tconst projectTrustDiagnostics: AgentSessionRuntimeDiagnostic[] = [];\n\t\tconst cachedProjectTrust = projectTrustByCwd.get(cwd);\n\t\tconst hasTrustRequiringResources = hasTrustRequiringProjectResources(cwd);\n\t\tconst shouldResolveProjectTrust =\n\t\t\tparsed.projectTrustOverride === undefined && cachedProjectTrust === undefined && hasTrustRequiringResources;\n\t\tconst projectTrusted = shouldResolveProjectTrust\n\t\t\t? false\n\t\t\t: (cachedProjectTrust ??\n\t\t\t\tparsed.projectTrustOverride ??\n\t\t\t\t(!hasTrustRequiringResources || trustStore.get(cwd) === true));\n\t\tconst runtimeSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted });\n\t\tconst services = await createAgentSessionServices({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tsessionId: sessionManager.getSessionId(),\n\t\t\tsettingsManager: runtimeSettingsManager,\n\t\t\tmodelRuntimeSignal: AbortSignal.timeout(15_000),\n\t\t\textensionFlagValues: parsed.unknownFlags,\n\t\t\tresourceLoaderReloadOptions: shouldResolveProjectTrust\n\t\t\t\t? {\n\t\t\t\t\t\tresolveProjectTrust: async ({ extensionsResult }) => {\n\t\t\t\t\t\t\tconst trusted = await resolveProjectTrusted({\n\t\t\t\t\t\t\t\tcwd,\n\t\t\t\t\t\t\t\ttrustStore,\n\t\t\t\t\t\t\t\ttrustOverride: parsed.projectTrustOverride,\n\t\t\t\t\t\t\t\tdefaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),\n\t\t\t\t\t\t\t\textensionsResult,\n\t\t\t\t\t\t\t\tprojectTrustContext:\n\t\t\t\t\t\t\t\t\tprojectTrustContext ??\n\t\t\t\t\t\t\t\t\tcreateProjectTrustContext({\n\t\t\t\t\t\t\t\t\t\tcwd,\n\t\t\t\t\t\t\t\t\t\tmode: isInitialRuntime ? trustPromptMode : appMode,\n\t\t\t\t\t\t\t\t\t\tsettingsManager: startupSettingsManager,\n\t\t\t\t\t\t\t\t\t\thasUI: isInitialRuntime && trustPromptMode === \"interactive\",\n\t\t\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t\t\tonExtensionError: (message) => projectTrustDiagnostics.push({ type: \"warning\", message }),\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\tprojectTrustByCwd.set(cwd, trusted);\n\t\t\t\t\t\t\treturn trusted;\n\t\t\t\t\t\t},\n\t\t\t\t\t}\n\t\t\t\t: undefined,\n\t\t\tresourceLoaderOptions: {\n\t\t\t\tadditionalExtensionPaths: resolvedExtensionPaths,\n\t\t\t\tadditionalSkillPaths: resolvedSkillPaths,\n\t\t\t\tadditionalPromptTemplatePaths: resolvedPromptTemplatePaths,\n\t\t\t\tadditionalThemePaths: resolvedThemePaths,\n\t\t\t\tnoExtensions: parsed.noExtensions,\n\t\t\t\tnoSkills: parsed.noSkills,\n\t\t\t\tnoPromptTemplates: parsed.noPromptTemplates,\n\t\t\t\tnoThemes: parsed.noThemes,\n\t\t\t\tnoContextFiles: parsed.noContextFiles,\n\t\t\t\tsystemPrompt: parsed.systemPrompt,\n\t\t\t\tappendSystemPrompt: parsed.appendSystemPrompt,\n\t\t\t\textensionFactories,\n\t\t\t},\n\t\t});\n\t\tconst { settingsManager, modelRuntime, resourceLoader } = services;\n\t\tconst permissionStore = new FilePermissionRuleStore({\n\t\t\tcwd,\n\t\t\tagentDir,\n\t\t\tprojectTrusted: settingsManager.isProjectTrusted(),\n\t\t\tinitialRules: (parsed.allowedTools ?? []).map((toolName) => ({\n\t\t\t\tsource: \"cliArg\" as const,\n\t\t\t\ttoolName,\n\t\t\t\tbehavior: \"allow\" as const,\n\t\t\t})),\n\t\t});\n\t\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = [\n\t\t\t...projectTrustDiagnostics,\n\t\t\t...services.diagnostics,\n\t\t\t...collectSettingsDiagnostics(settingsManager),\n\t\t\t...resourceLoader.getExtensions().errors.map(({ path, error }) => ({\n\t\t\t\ttype: \"error\" as const,\n\t\t\t\tmessage: `Failed to load extension \"${path}\": ${error}`,\n\t\t\t})),\n\t\t];\n\n\t\tconst modelPatterns = parsed.models ?? settingsManager.getEnabledModels();\n\t\tconst scopedModels =\n\t\t\tmodelPatterns && modelPatterns.length > 0\n\t\t\t\t? await resolveModelScope(modelPatterns, modelRuntime, { signal: AbortSignal.timeout(15_000) })\n\t\t\t\t: [];\n\t\tconst {\n\t\t\toptions: sessionOptions,\n\t\t\tcliThinkingFromModel,\n\t\t\tdiagnostics: sessionOptionDiagnostics,\n\t\t} = buildSessionOptions(\n\t\t\tparsed,\n\t\t\tscopedModels,\n\t\t\tsessionManager.buildSessionContext().messages.length > 0,\n\t\t\tmodelRuntime,\n\t\t\tsettingsManager,\n\t\t);\n\t\tdiagnostics.push(...sessionOptionDiagnostics);\n\n\t\tif (parsed.apiKey) {\n\t\t\tif (!sessionOptions.model) {\n\t\t\t\tdiagnostics.push({\n\t\t\t\t\ttype: \"error\",\n\t\t\t\t\tmessage: \"--api-key requires a model to be specified via --model, --provider/--model, or --models\",\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tawait modelRuntime.setRuntimeApiKey(sessionOptions.model.provider, parsed.apiKey);\n\t\t\t}\n\t\t}\n\n\t\tconst created = await createAgentSessionFromServices({\n\t\t\tservices,\n\t\t\tsessionManager,\n\t\t\tsessionStartEvent,\n\t\t\tmodel: sessionOptions.model,\n\t\t\tthinkingLevel: sessionOptions.thinkingLevel,\n\t\t\tscopedModels: sessionOptions.scopedModels,\n\t\t\ttools: sessionOptions.tools,\n\t\t\texcludeTools: sessionOptions.excludeTools,\n\t\t\tnoTools: sessionOptions.noTools,\n\t\t\tcustomTools: sessionOptions.customTools,\n\t\t\t// ACP bridge (spec 2026-08-31-acp-adapter.md): permission asks route to\n\t\t\t// the ACP client through the server created by runAcpMode; absent\n\t\t\t// server (non-ACP modes) leaves today's fail-closed behavior.\n\t\t\tpermissionResponderFactory:\n\t\t\t\tparsed.mode === \"acp\"\n\t\t\t\t\t? () => {\n\t\t\t\t\t\t\tconst server = acpServerRef.current;\n\t\t\t\t\t\t\tif (!server) return undefined;\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tasync ask(request) {\n\t\t\t\t\t\t\t\t\treturn server.askPermission(\n\t\t\t\t\t\t\t\t\t\tserver.currentSessionId ?? \"\",\n\t\t\t\t\t\t\t\t\t\trequest.toolName,\n\t\t\t\t\t\t\t\t\t\trequest.description,\n\t\t\t\t\t\t\t\t\t\trequest.sessionScope !== undefined,\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t: undefined,\n\t\t\tpermissionGate: {\n\t\t\t\tstore: permissionStore,\n\t\t\t\tflagMode: parsed.permissionMode,\n\t\t\t\tgetMode: async () =>\n\t\t\t\t\tresolveEffectiveMode(parsed.permissionMode, (await permissionStore.snapshot()).modesBySource),\n\t\t\t},\n\t\t});\n\t\t// ADR 0010: a conservative default nobody can see is indistinguishable from a bug,\n\t\t// so the projection is what reports it rather than a filter written here.\n\t\tconst undeclared = unclassifiedToolNames(created.session.getToolContractSnapshot());\n\t\tif (undeclared.length > 0) {\n\t\t\tdiagnostics.push({\n\t\t\t\ttype: \"warning\",\n\t\t\t\tmessage: `Unclassified tool contracts (approval required): ${undeclared.join(\", \")}`,\n\t\t\t});\n\t\t}\n\t\tconst cliThinkingOverride = parsed.thinking !== undefined || cliThinkingFromModel;\n\t\tif (created.session.model && cliThinkingOverride) {\n\t\t\tcreated.session.setThinkingLevel(created.session.thinkingLevel);\n\t\t}\n\n\t\treturn {\n\t\t\t...created,\n\t\t\tservices,\n\t\t\tdiagnostics,\n\t\t};\n\t};\n\t// ACP bridge (spec 2026-08-31-acp-adapter.md): runAcpMode installs the live\n\t// server here once the mode dispatch creates it.\n\tconst acpServerRef: { current?: AcpServer } = { current: undefined };\n\ttime(\"createRuntime\");\n\tconst runtime = await createAgentSessionRuntime(createRuntime, {\n\t\tcwd: sessionManager.getCwd(),\n\t\tagentDir,\n\t\tsessionManager,\n\t});\n\ttime(\"createAgentSessionRuntime\");\n\tconst { services, session, modelFallbackMessage } = runtime;\n\tconst { settingsManager, modelRuntime, resourceLoader } = services;\n\tsetCapabilityOverrides(settingsManager.getTerminalCapabilityOverrides());\n\tapplyHttpProxySettings(settingsManager.getGlobalSettings().httpProxy);\n\tconfigureHttpDispatcher(settingsManager.getHttpIdleTimeoutMs());\n\n\tif (parsed.help) {\n\t\treportDiagnostics(startupSettingsDiagnostics);\n\t\tconst extensionFlags = resourceLoader\n\t\t\t.getExtensions()\n\t\t\t.extensions.flatMap((extension) => Array.from(extension.flags.values()));\n\t\tprintHelp(extensionFlags);\n\t\tprocess.exit(0);\n\t}\n\n\tif (parsed.listModels !== undefined) {\n\t\treportDiagnostics(startupSettingsDiagnostics);\n\t\tconst searchPattern = typeof parsed.listModels === \"string\" ? parsed.listModels : undefined;\n\t\tawait listModels(modelRuntime, searchPattern, AbortSignal.timeout(15_000));\n\t\tprocess.exit(0);\n\t}\n\n\t// Read piped stdin content (if any) - skip for RPC mode which uses stdin for JSON-RPC\n\tlet stdinContent: string | undefined;\n\tif (appMode !== \"rpc\") {\n\t\tstdinContent = await readPipedStdin();\n\t\tif (stdinContent !== undefined && appMode === \"interactive\") {\n\t\t\tappMode = \"print\";\n\t\t}\n\t}\n\ttime(\"readPipedStdin\");\n\n\tconst { initialMessage, initialImages } = await prepareInitialMessage(\n\t\tparsed,\n\t\tsettingsManager.getImageAutoResize(),\n\t\tstdinContent,\n\t);\n\ttime(\"prepareInitialMessage\");\n\tinitTheme(settingsManager.getTheme(), appMode === \"interactive\");\n\ttime(\"initTheme\");\n\n\t// Show deprecation warnings in interactive mode\n\tif (appMode === \"interactive\" && deprecationWarnings.length > 0) {\n\t\tawait showDeprecationWarnings(deprecationWarnings);\n\t}\n\n\ttime(\"resolveModelScope\");\n\tconst startupDiagnostics = deduplicateDiagnostics([...startupSettingsDiagnostics, ...runtime.diagnostics]);\n\tconst hasRuntimeErrors = runtime.diagnostics.some((diagnostic) => diagnostic.type === \"error\");\n\tif (appMode !== \"interactive\" || hasRuntimeErrors) {\n\t\treportDiagnostics(startupDiagnostics);\n\t}\n\tif (hasRuntimeErrors) {\n\t\tif (runtime.diagnostics.some((diagnostic) => diagnostic.message.includes(\"Failed to load extension\"))) {\n\t\t\tconsole.error(chalk.yellow(EXTENSION_LOAD_FAILURE_HINT));\n\t\t}\n\t\tprocess.exit(1);\n\t}\n\ttime(\"createAgentSession\");\n\n\t// Child-run lifecycle command: the session is loaded, its own registry is the sole\n\t// owner, and the operation runs here in the non-interactive flow. Lifecycle\n\t// operations never need a parent model, so this sits before the no-model check.\n\tif (parsed.agent) {\n\t\tconst exitCode = await runAgentLifecycleCommand(session, parsed.agent, { json: appMode === \"json\" });\n\t\tstopThemeWatcher();\n\t\trestoreStdout();\n\t\tprocess.exitCode = exitCode;\n\t\treturn;\n\t}\n\n\tif (appMode !== \"interactive\" && !session.model) {\n\t\tconsole.error(chalk.red(formatNoModelsAvailableMessage()));\n\t\tprocess.exit(1);\n\t}\n\n\tconst startupBenchmark = isTruthyEnvFlag(getApexEnvironment(\"APEX_CODE_STARTUP_BENCHMARK\"));\n\tif (startupBenchmark && appMode !== \"interactive\") {\n\t\tconsole.error(chalk.red(\"Error: APEX_CODE_STARTUP_BENCHMARK only supports interactive mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\t// RPC refreshes catalogs here in the background; interactive mode starts its refresh after TUI initialization.\n\tif (!offlineMode && appMode === \"rpc\") {\n\t\tconst controller = new AbortController();\n\t\tconst timeout = setTimeout(() => controller.abort(), 15_000);\n\t\tvoid modelRuntime\n\t\t\t.refresh({ signal: controller.signal })\n\t\t\t.catch(() => {})\n\t\t\t.finally(() => clearTimeout(timeout));\n\t}\n\n\tif (appMode === \"rpc\") {\n\t\tprintTimings();\n\t\tawait runRpcMode(runtime);\n\t} else if (appMode === \"acp\") {\n\t\tprintTimings();\n\t\tawait runAcpMode(runtime, acpServerRef);\n\t} else if (appMode === \"interactive\") {\n\t\tconst interactiveMode = new InteractiveMode(runtime, {\n\t\t\tmigratedProviders,\n\t\t\tstartupDiagnostics,\n\t\t\tmodelFallbackMessage,\n\t\t\tautoTrustOnReloadCwd,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t\tinitialMessages: parsed.messages,\n\t\t\tverbose: parsed.verbose,\n\t\t\ttuiMode: parsed.tuiMode,\n\t\t\tinitialThemeSetting: parsed.useTheme,\n\t\t});\n\t\tif (startupBenchmark) {\n\t\t\tawait interactiveMode.init();\n\t\t\ttime(\"interactiveMode.init\");\n\t\t\t// Give the TUI's stdin handler a brief chance to consume terminal query replies\n\t\t\t// (Kitty keyboard protocol, device attributes, cell size) before restoring the terminal.\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 150));\n\t\t\tinteractiveMode.stop();\n\t\t\tstopThemeWatcher();\n\t\t\tprintTimings();\n\t\t\tif (process.stdout.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stdout.once(\"drain\", resolve));\n\t\t\t}\n\t\t\tif (process.stderr.writableLength > 0) {\n\t\t\t\tawait new Promise<void>((resolve) => process.stderr.once(\"drain\", resolve));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\tprintTimings();\n\t\tawait interactiveMode.run();\n\t} else {\n\t\tprintTimings();\n\t\tconst exitCode = await runPrintMode(runtime, {\n\t\t\tmode: toPrintOutputMode(appMode),\n\t\t\tmessages: parsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialImages,\n\t\t});\n\t\tstopThemeWatcher();\n\t\trestoreStdout();\n\t\tif (exitCode !== 0) {\n\t\t\tprocess.exitCode = exitCode;\n\t\t}\n\t\treturn;\n\t}\n}\n"]}