carthagent 1.0.7 → 1.1.0-beta.1

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 (294) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,1217 @@
1
+ diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts
2
+ index 4580c6b..0f289bf 100644
3
+ --- a/packages/client/src/effect/api/api.ts
4
+ +++ b/packages/client/src/effect/api/api.ts
5
+ @@ -48,7 +48,9 @@ export type ServerInfoOutput = {
6
+ readonly pid: number
7
+ readonly urls: ReadonlyArray<string>
8
+ readonly paths: { readonly tmp: string }
9
+ - readonly capabilities?: { readonly persistentPty?: boolean | undefined } | undefined
10
+ + readonly capabilities?:
11
+ + | { readonly persistentPty?: boolean | undefined; readonly shellOrigin?: boolean | undefined }
12
+ + | undefined
13
+ }
14
+ export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
15
+
16
+ diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts
17
+ index 96ba475..6b08478 100644
18
+ --- a/packages/client/src/promise/generated/types.ts
19
+ +++ b/packages/client/src/promise/generated/types.ts
20
+ @@ -5,7 +5,7 @@ export type ServerInfo = {
21
+ pid: number
22
+ urls: Array<string>
23
+ paths: { tmp: string }
24
+ - capabilities?: { persistentPty?: boolean | undefined } | undefined
25
+ + capabilities?: { persistentPty?: boolean | undefined; shellOrigin?: boolean | undefined } | undefined
26
+ }
27
+
28
+ export type PairingCode = { code: string; expires_in: number }
29
+ diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts
30
+ index ef0d5d6..346cc68 100644
31
+ --- a/packages/core/src/codemode/tool.ts
32
+ +++ b/packages/core/src/codemode/tool.ts
33
+ @@ -92,7 +92,7 @@ export const create = (
34
+ (name, tool, input) =>
35
+ Effect.gen(function* () {
36
+ const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
37
+ - const executed = yield* executeTool(name, tool, input, context)
38
+ + const executed = yield* executeTool(name, tool, input, { ...context, callIndex: index })
39
+ const content =
40
+ typeof executed.content === "string"
41
+ ? [{ type: "text" as const, text: executed.content }]
42
+ diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts
43
+ index 3cb5279..602114b 100644
44
+ --- a/packages/core/src/session/compaction.ts
45
+ +++ b/packages/core/src/session/compaction.ts
46
+ @@ -205,9 +205,10 @@ export const layer = Layer.effect(
47
+ const compact = Effect.fn("SessionCompaction.compact")(function* (trigger: Trigger): Effect.fn.Return<Outcome> {
48
+ const settings = state.get()
49
+ const context = trigger.context
50
+ + const sessionAuto = context.session.metadata?.["ctg.autoCompaction"]
51
+
52
+ // Only the user compacts when automatic compaction is off, overflow included.
53
+ - if (trigger.reason !== "manual" && !settings.auto) return { status: "skipped" }
54
+ + if (trigger.reason !== "manual" && (!settings.auto || sessionAuto === false)) return { status: "skipped" }
55
+ if (trigger.reason === "auto" && !due(context, calculateCeiling(context.model.limit, settings.buffer)))
56
+ return { status: "skipped" }
57
+ const native = context.model.compaction?.type === "native"
58
+ diff --git a/packages/core/src/session/session.ts b/packages/core/src/session/session.ts
59
+ index 3cd1300..7d82de8 100644
60
+ --- a/packages/core/src/session/session.ts
61
+ +++ b/packages/core/src/session/session.ts
62
+ @@ -178,12 +178,17 @@ export const make = Effect.fn("Session.make")(function* () {
63
+ )
64
+ const shell = Effect.fn("Session.shell")(function* (
65
+ sessionID: SessionSchema.ID,
66
+ - input: { id?: SessionMessage.ID; command: string },
67
+ + input: { id?: SessionMessage.ID; command: string; origin?: "manual"; callID?: string },
68
+ ) {
69
+ const session = yield* get(sessionID)
70
+ // The server owns completion recording even if the submitting client disconnects.
71
+ const running = yield* Effect.gen(function* () {
72
+ - const started = yield* SessionShell.start({ session, command: input.command }).pipe(
73
+ + const started = yield* SessionShell.start({
74
+ + session,
75
+ + command: input.command,
76
+ + origin: input.origin,
77
+ + callID: input.callID,
78
+ + }).pipe(
79
+ Effect.provideService(Instance.Service, instances),
80
+ Effect.tapError((error) =>
81
+ synthetic(sessionID, {
82
+ diff --git a/packages/core/src/session/shell.ts b/packages/core/src/session/shell.ts
83
+ index b4e2a1a..ee9f13e 100644
84
+ --- a/packages/core/src/session/shell.ts
85
+ +++ b/packages/core/src/session/shell.ts
86
+ @@ -7,14 +7,24 @@ import { Plugin } from "../plugin/service.js"
87
+ import { Shell } from "../shell.js"
88
+ import { ShellResult } from "../shell/result.js"
89
+
90
+ -export const start = Effect.fn("SessionShell.start")(function* (input: { session: Session.Info; command: string }) {
91
+ +export const start = Effect.fn("SessionShell.start")(function* (input: {
92
+ + session: Session.Info
93
+ + command: string
94
+ + origin?: "manual"
95
+ + callID?: string
96
+ +}) {
97
+ const instances = yield* Instance.Service
98
+ const shell = yield* Plugin.awaitActivation.pipe(Effect.andThen(Shell.Service), instances.provide(input.session))
99
+ const info = yield* shell.create({
100
+ command: input.command,
101
+ cwd: input.session.location.directory,
102
+ timeout: 0,
103
+ - metadata: { sessionID: input.session.id, background: true },
104
+ + metadata: {
105
+ + ...(input.origin === "manual" || input.origin === "tool" ? { sessionID: input.session.id } : {}),
106
+ + background: true,
107
+ + ...(input.origin === undefined ? {} : { shellOrigin: input.origin }),
108
+ + ...(input.callID === undefined ? {} : { callID: input.callID }),
109
+ + },
110
+ })
111
+ // Keep completion tied to the original shell even if the Session moves.
112
+ return {
113
+ diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts
114
+ index e1d1017..91b3d07 100644
115
+ --- a/packages/core/src/shell.ts
116
+ +++ b/packages/core/src/shell.ts
117
+ @@ -15,7 +15,7 @@ import { FileRetention } from "./file-retention.js"
118
+ import { Location } from "./location.js"
119
+ import { Global } from "@opencode/util/global"
120
+ import { ShellSelect } from "./shell/select.js"
121
+ -import type { ShellCreateBefore } from "@opencode/plugin/effect/shell"
122
+ +import type { ShellCreateAfter, ShellCreateBefore } from "@opencode/plugin/effect/shell"
123
+ import { PluginHooks } from "./plugin/hooks.js"
124
+ import { SessionEnvironment } from "./session/environment.js"
125
+ import { SessionSchema } from "./session/schema.js"
126
+ @@ -265,12 +265,26 @@ const layer = () =>
127
+ cwd: input.cwd ?? location.directory,
128
+ timeout: input.timeout ?? 0,
129
+ shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
130
+ + ...(Schema.is(SessionSchema.ID)(sessionID)
131
+ + && (input.metadata?.shellOrigin === "tool" || input.metadata?.shellOrigin === "manual")
132
+ + ? { sessionID }
133
+ + : {}),
134
+ + ...(input.metadata?.shellOrigin === "tool" || input.metadata?.shellOrigin === "manual"
135
+ + ? { origin: input.metadata.shellOrigin }
136
+ + : {}),
137
+ + ...(input.metadata?.shellOrigin === "tool" || input.metadata?.shellOrigin === "manual"
138
+ + ? typeof input.metadata.callID === "string" && input.metadata.callID.length > 0
139
+ + ? { callID: input.metadata.callID }
140
+ + : {}
141
+ + : {}),
142
+ env: {
143
+ ...baseEnv,
144
+ TERM: "xterm-256color",
145
+ OPENCODE_TERMINAL: "1",
146
+ },
147
+ }
148
+ + // Caller provenance is explicit metadata from an OpenCode-owned entry point.
149
+ + // Invalid or missing provenance stays unowned; never reconstruct it from cwd.
150
+ yield* hooks.trigger("shell", "create.before", invocation)
151
+ if (before) yield* before(invocation)
152
+
153
+ @@ -431,6 +445,17 @@ const layer = () =>
154
+ )
155
+
156
+ const command = yield* Deferred.await(ready)
157
+ + const after: ShellCreateAfter = {
158
+ + command: invocation.command,
159
+ + cwd: invocation.cwd,
160
+ + timeout: invocation.timeout,
161
+ + shell: invocation.shell,
162
+ + ...(invocation.sessionID === undefined ? {} : { sessionID: invocation.sessionID }),
163
+ + ...(invocation.origin === undefined ? {} : { origin: invocation.origin }),
164
+ + ...(invocation.callID === undefined ? {} : { callID: invocation.callID }),
165
+ + shellID: command.info.id,
166
+ + }
167
+ + yield* hooks.trigger("shell", "create.after", after)
168
+ return command.info
169
+ })
170
+
171
+ diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts
172
+ index af454a7..df3f45c 100644
173
+ --- a/packages/core/src/tool/plugin/shell.ts
174
+ +++ b/packages/core/src/tool/plugin/shell.ts
175
+ @@ -206,7 +206,11 @@ export const Plugin = {
176
+ cwd: input.workdir,
177
+ timeout,
178
+ shell: yield* compatibleShell,
179
+ - metadata: { sessionID: context.sessionID },
180
+ + metadata: {
181
+ + sessionID: context.sessionID,
182
+ + shellOrigin: "tool",
183
+ + callID: context.callIndex === undefined ? context.id : `${context.id}:${context.callIndex}`,
184
+ + },
185
+ },
186
+ (invocation) =>
187
+ Effect.gen(function* () {
188
+ diff --git a/packages/core/test/plugin-failure.test.ts b/packages/core/test/plugin-failure.test.ts
189
+ index a800b5d..5d05c4e 100644
190
+ --- a/packages/core/test/plugin-failure.test.ts
191
+ +++ b/packages/core/test/plugin-failure.test.ts
192
+ @@ -48,6 +48,9 @@ it.live("removes a failed plugin's hooks and RPC handlers without affecting heal
193
+ timeout: 1_000,
194
+ shell: "sh",
195
+ env: {},
196
+ + sessionID: "ses_hook_fixture",
197
+ + origin: "tool",
198
+ + callID: "call_hook_fixture",
199
+ })
200
+ yield* trigger
201
+ expect(invoked).toEqual(["broken", "healthy"])
202
+ diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts
203
+ index 1361a0b..1cca9b3 100644
204
+ --- a/packages/core/test/session-compaction.test.ts
205
+ +++ b/packages/core/test/session-compaction.test.ts
206
+ @@ -205,6 +205,39 @@ it.effect("auto compaction estimates current content against the buffered prompt
207
+ const due = (context: SessionContext.Loaded) =>
208
+ compaction.compact({ reason: "auto", context }).pipe(Effect.map((outcome) => outcome.status !== "skipped"))
209
+
210
+ + const optedOut = { ...session, metadata: { "ctg.autoCompaction": false } }
211
+ + const enabledSession = yield* insertSession(Session.ID.make("ses_compaction_enabled"))
212
+ + expect(yield* due({ ...input(300_000, { context: 200_000, output: 32_000 }), session: optedOut })).toBe(false)
213
+ + expect(
214
+ + yield* due({
215
+ + ...input(300_000, { context: 200_000, output: 32_000 }),
216
+ + session: enabledSession,
217
+ + }),
218
+ + ).toBe(true)
219
+ + const persistedOptOut = yield* insertSession(Session.ID.make("ses_compaction_opted_out"), {
220
+ + metadata: { "ctg.autoCompaction": false },
221
+ + })
222
+ + expect(persistedOptOut.metadata?.["ctg.autoCompaction"]).toBe(false)
223
+ + expect(
224
+ + yield* due({ ...input(300_000, { context: 200_000, output: 32_000 }), session: persistedOptOut }),
225
+ + ).toBe(false)
226
+ + const overflow = (context: SessionContext.Loaded) =>
227
+ + compaction.compact({ reason: "overflow", context }).pipe(Effect.map((outcome) => outcome.status !== "skipped"))
228
+ + expect(yield* overflow({ ...input(300_000, { context: 200_000, output: 32_000 }), session: persistedOptOut })).toBe(
229
+ + false,
230
+ + )
231
+ + expect(
232
+ + yield* overflow({ ...input(300_000, { context: 200_000, output: 32_000 }), session: enabledSession }),
233
+ + ).toBe(true)
234
+ + expect(
235
+ + (yield*
236
+ + compaction.compact({
237
+ + reason: "manual",
238
+ + context: { ...input(300_000, { context: 200_000, output: 32_000 }), session: optedOut },
239
+ + inputID: SessionMessage.ID.create(),
240
+ + })).status,
241
+ + ).toBe("completed")
242
+ +
243
+ // 90% of the input limit, which takes precedence over the context window.
244
+ const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
245
+ expect(yield* due(input(244_799, inputLimited))).toBe(false)
246
+ diff --git a/packages/core/test/shell-retention.test.ts b/packages/core/test/shell-retention.test.ts
247
+ index 3f776e4..12df1e0 100644
248
+ --- a/packages/core/test/shell-retention.test.ts
249
+ +++ b/packages/core/test/shell-retention.test.ts
250
+ @@ -8,14 +8,90 @@ import { Environment } from "@opencode/core/environment/index"
251
+ import { Location } from "@opencode/core/location"
252
+ import { AbsolutePath } from "@opencode/core/schema"
253
+ import { Shell } from "@opencode/core/shell"
254
+ +import { PluginHooks } from "@opencode/core/plugin/hooks"
255
+ +import { SessionSchema } from "@opencode/core/session/schema"
256
+ import { Workspace } from "@opencode/core/workspace"
257
+ import { Global } from "@opencode/util/global"
258
+ +import { LayerNode } from "@opencode/util/effect/layer-node"
259
+ import { hostEnvironmentLayer } from "./fixture/environment"
260
+ import { tempGlobalLayer } from "./fixture/global"
261
+ import { location, tempLocationLayer } from "./fixture/location"
262
+ import { tmpdirScoped } from "./fixture/tmpdir"
263
+ import { it } from "./lib/effect"
264
+
265
+ +it.live("shell hooks preserve explicit session and call origin across concurrent creators", () =>
266
+ + Effect.gen(function* () {
267
+ + const observed: Array<{ phase: string; command: string; cwd: string; sessionID?: string; origin?: string; callID?: string; shellID?: string }> = []
268
+ + const hooks = yield* PluginHooks.Service
269
+ + yield* hooks.register("shell", "create.before", (event) =>
270
+ + Effect.sync(() => observed.push({
271
+ + phase: "before",
272
+ + command: event.command,
273
+ + cwd: event.cwd,
274
+ + sessionID: event.sessionID,
275
+ + origin: event.origin,
276
+ + callID: event.callID,
277
+ + })),
278
+ + )
279
+ + yield* hooks.register("shell", "create.after", (event) =>
280
+ + Effect.sync(() => observed.push({
281
+ + phase: "after",
282
+ + command: event.command,
283
+ + cwd: event.cwd,
284
+ + sessionID: event.sessionID,
285
+ + origin: event.origin,
286
+ + callID: event.callID,
287
+ + shellID: event.shellID,
288
+ + })),
289
+ + )
290
+ + const shell = yield* Shell.Service
291
+ + const first = SessionSchema.ID.make("ses_shell_origin_first")
292
+ + const second = SessionSchema.ID.make("ses_shell_origin_second")
293
+ + const cwd = process.cwd()
294
+ + const otherCwd = yield* tmpdirScoped()
295
+ + const created = yield* Effect.all([
296
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd, metadata: { sessionID: first, shellOrigin: "tool", callID: "call-a" } }),
297
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd, metadata: { sessionID: second, shellOrigin: "tool", callID: "call-a" } }),
298
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd: otherCwd.path, metadata: { sessionID: first, shellOrigin: "tool", callID: "call-b" } }),
299
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd, metadata: { sessionID: first, shellOrigin: "manual", callID: "manual:test" } }),
300
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd, metadata: { sessionID: first, shellOrigin: "unknown", callID: "forged:call" } }),
301
+ + shell.create({ shell: "/bin/sh", command: "sleep 0.03", cwd }),
302
+ + ], { concurrency: "unbounded" })
303
+ + const before = observed.filter((event) => event.phase === "before")
304
+ + const after = observed.filter((event) => event.phase === "after")
305
+ + expect(before).toHaveLength(6)
306
+ + expect(after).toHaveLength(6)
307
+ + expect(before).toContainEqual({ phase: "before", command: "sleep 0.03", cwd, sessionID: first, origin: "tool", callID: "call-a" })
308
+ + expect(before).toContainEqual({ phase: "before", command: "sleep 0.03", cwd, sessionID: second, origin: "tool", callID: "call-a" })
309
+ + expect(before).toContainEqual({ phase: "before", command: "sleep 0.03", cwd: otherCwd.path, sessionID: first, origin: "tool", callID: "call-b" })
310
+ + expect(before).toContainEqual({ phase: "before", command: "sleep 0.03", cwd, sessionID: first, origin: "manual", callID: "manual:test" })
311
+ + expect(before.filter((event) => event.sessionID === undefined && event.origin === undefined && event.callID === undefined)).toHaveLength(2)
312
+ + expect(before).toContainEqual({ phase: "before", command: "sleep 0.03", cwd, sessionID: undefined, origin: undefined, callID: undefined })
313
+ + expect(after).toContainEqual(expect.objectContaining({ sessionID: first, origin: "tool", callID: "call-a" }))
314
+ + expect(after).toContainEqual(expect.objectContaining({ sessionID: second, origin: "tool", callID: "call-a" }))
315
+ + expect(after).toContainEqual(expect.objectContaining({ sessionID: first, origin: "tool", callID: "call-b" }))
316
+ + expect(after).toContainEqual(expect.objectContaining({ sessionID: first, origin: "manual", callID: "manual:test" }))
317
+ + expect(after).toContainEqual(expect.objectContaining({ sessionID: undefined, origin: undefined, callID: undefined }))
318
+ + for (const info of created) {
319
+ + expect(after).toContainEqual(expect.objectContaining({
320
+ + phase: "after",
321
+ + command: info.command,
322
+ + cwd: info.cwd,
323
+ + shellID: info.id,
324
+ + }))
325
+ + }
326
+ + }).pipe(
327
+ + Effect.provide(
328
+ + AppNodeBuilder.build(LayerNode.group([Shell.node, PluginHooks.node]), [
329
+ + Location.node.replace(tempLocationLayer),
330
+ + Global.node.replace(tempGlobalLayer),
331
+ + Config.node.replace(Config.testLayer()),
332
+ + Environment.node.replace(hostEnvironmentLayer),
333
+ + ]),
334
+ + ),
335
+ + ),
336
+ +)
337
+ +
338
+ it.live("eviction makes progress past an already-removed shell", () =>
339
+ Effect.gen(function* () {
340
+ const completions = yield* Queue.unbounded<Effect.Effect<void>>()
341
+ diff --git a/packages/plugin/src/effect/shell.ts b/packages/plugin/src/effect/shell.ts
342
+ index d0e6ae7..74a3b04 100644
343
+ --- a/packages/plugin/src/effect/shell.ts
344
+ +++ b/packages/plugin/src/effect/shell.ts
345
+ @@ -6,10 +6,28 @@ export interface ShellCreateBefore {
346
+ timeout: number
347
+ shell: string
348
+ env: Record<string, string | undefined>
349
+ + /** Authoritative caller origin when supplied by an OpenCode-owned shell creator. Never infer this from cwd. */
350
+ + readonly sessionID?: string
351
+ + /** Distinguishes tool execution from the user's `!shell`; missing means an external/unknown creator. */
352
+ + readonly origin?: "tool" | "manual"
353
+ + /** Authoritative shell invocation correlation ID (native tool call ID, namespaced for CodeMode children). */
354
+ + readonly callID?: string
355
+ +}
356
+ +
357
+ +export interface ShellCreateAfter {
358
+ + readonly command: string
359
+ + readonly cwd: string
360
+ + readonly timeout: number
361
+ + readonly shell: string
362
+ + readonly sessionID?: string
363
+ + readonly origin?: "tool" | "manual"
364
+ + readonly callID?: string
365
+ + readonly shellID: string
366
+ }
367
+
368
+ export interface ShellHooks {
369
+ readonly "create.before": ShellCreateBefore
370
+ + readonly "create.after": ShellCreateAfter
371
+ }
372
+
373
+ export interface ShellDomain {
374
+ diff --git a/packages/plugin/src/promise/shell.ts b/packages/plugin/src/promise/shell.ts
375
+ index d0e6ae7..74a3b04 100644
376
+ --- a/packages/plugin/src/promise/shell.ts
377
+ +++ b/packages/plugin/src/promise/shell.ts
378
+ @@ -6,10 +6,28 @@ export interface ShellCreateBefore {
379
+ timeout: number
380
+ shell: string
381
+ env: Record<string, string | undefined>
382
+ + /** Authoritative caller origin when supplied by an OpenCode-owned shell creator. Never infer this from cwd. */
383
+ + readonly sessionID?: string
384
+ + /** Distinguishes tool execution from the user's `!shell`; missing means an external/unknown creator. */
385
+ + readonly origin?: "tool" | "manual"
386
+ + /** Authoritative shell invocation correlation ID (native tool call ID, namespaced for CodeMode children). */
387
+ + readonly callID?: string
388
+ +}
389
+ +
390
+ +export interface ShellCreateAfter {
391
+ + readonly command: string
392
+ + readonly cwd: string
393
+ + readonly timeout: number
394
+ + readonly shell: string
395
+ + readonly sessionID?: string
396
+ + readonly origin?: "tool" | "manual"
397
+ + readonly callID?: string
398
+ + readonly shellID: string
399
+ }
400
+
401
+ export interface ShellHooks {
402
+ readonly "create.before": ShellCreateBefore
403
+ + readonly "create.after": ShellCreateAfter
404
+ }
405
+
406
+ export interface ShellDomain {
407
+ diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts
408
+ index fe641b3..0b6f671 100644
409
+ --- a/packages/plugin/src/tui/context.ts
410
+ +++ b/packages/plugin/src/tui/context.ts
411
+ @@ -196,6 +196,8 @@ export interface SlotMap {
412
+ readonly "prompt.footer.status": PromptFooterInput
413
+ readonly "prompt.footer.file": PromptFooterInput
414
+ readonly "session.composer.top": { readonly sessionID: string }
415
+ + /** Persistent, host-owned client presentation state for a session transcript. */
416
+ + readonly "session.transcript": { readonly sessionID: string; readonly clear: () => Promise<void> }
417
+ readonly "session.panel": PanelInput
418
+ readonly "sidebar.content": { readonly sessionID: string }
419
+ readonly "sidebar.footer": { readonly sessionID: string }
420
+ @@ -557,4 +559,12 @@ export interface Context {
421
+ readonly keymap: Keymap
422
+ readonly storage: Storage
423
+ readonly ui: UI
424
+ + /** Host-owned transcript presentation controls. Clearing never mutates server messages. */
425
+ + readonly transcript: {
426
+ + readonly before: (sessionID: string) => string | undefined
427
+ + readonly clear: (sessionID: string) => {
428
+ + readonly before: string | undefined
429
+ + readonly persist: () => Promise<void>
430
+ + }
431
+ + }
432
+ }
433
+ diff --git a/packages/protocol/src/groups/server.ts b/packages/protocol/src/groups/server.ts
434
+ index 4b4cab1..e010fc1 100644
435
+ --- a/packages/protocol/src/groups/server.ts
436
+ +++ b/packages/protocol/src/groups/server.ts
437
+ @@ -14,6 +14,7 @@ export const ServerInfo = Schema.Struct({
438
+ capabilities: Schema.optional(
439
+ Schema.Struct({
440
+ persistentPty: Schema.optional(Schema.Boolean),
441
+ + shellOrigin: Schema.optional(Schema.Boolean),
442
+ }),
443
+ ),
444
+ }).annotate({ identifier: "ServerInfo" })
445
+ diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts
446
+ index a9ee506..03fb9d3 100644
447
+ --- a/packages/protocol/src/groups/session.ts
448
+ +++ b/packages/protocol/src/groups/session.ts
449
+ @@ -489,10 +489,9 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S, FormI ext
450
+ .middleware(sessionLocationMiddleware)
451
+ .annotateMerge(
452
+ OpenApi.annotations({
453
+ - identifier: "session.shell",
454
+ - summary: "Run shell command",
455
+ - description:
456
+ - "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.",
457
+ + identifier: "session.shell",
458
+ + summary: "Run shell command",
459
+ + description: "Run one user-initiated shell command in this session's working directory.",
460
+ }),
461
+ ),
462
+ )
463
+ diff --git a/packages/schema/src/tool.ts b/packages/schema/src/tool.ts
464
+ index 06dec1c..a515a73 100644
465
+ --- a/packages/schema/src/tool.ts
466
+ +++ b/packages/schema/src/tool.ts
467
+ @@ -16,6 +16,8 @@ export interface Context {
468
+ readonly agent: Agent.ID
469
+ readonly messageID: SessionMessage.ID
470
+ readonly id: CallID
471
+ + /** Deterministic child invocation ordinal for tools executed inside one CodeMode call. */
472
+ + readonly callIndex?: number
473
+ readonly progress: (update: Metadata) => Effect.Effect<void>
474
+ }
475
+
476
+ diff --git a/packages/server/src/handlers/server.ts b/packages/server/src/handlers/server.ts
477
+ index d85594b..e618d00 100644
478
+ --- a/packages/server/src/handlers/server.ts
479
+ +++ b/packages/server/src/handlers/server.ts
480
+ @@ -17,12 +17,18 @@ export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handler
481
+ .handle("server.info", () =>
482
+ Effect.gen(function* () {
483
+ const info = yield* ServerInfo.Service
484
+ + // The shell-origin contract is advertised only by the detached CTG
485
+ + // build channel, never by upstream/default builds.
486
+ + const shellOriginAvailable = info.app.channel?.startsWith("ctg.") === true
487
+ return {
488
+ version: info.app.version ?? "unknown",
489
+ pid: process.pid ?? 0,
490
+ urls: info.urls(),
491
+ paths: info.paths,
492
+ - capabilities: { persistentPty: PersistentPty.available },
493
+ + capabilities: {
494
+ + persistentPty: PersistentPty.available,
495
+ + ...(shellOriginAvailable ? { shellOrigin: true } : {}),
496
+ + },
497
+ }
498
+ }),
499
+ )
500
+ diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts
501
+ index dd85c2f..930d684 100644
502
+ --- a/packages/server/src/handlers/session.ts
503
+ +++ b/packages/server/src/handlers/session.ts
504
+ @@ -25,6 +25,7 @@ import {
505
+ import { AbsolutePath } from "@opencode/core/schema"
506
+ import { locationErrors } from "../location"
507
+ import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } from "./session-error"
508
+ +import { randomUUID } from "node:crypto"
509
+
510
+ const DefaultSessionsLimit = 50
511
+
512
+ @@ -427,7 +428,13 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
513
+ "session.shell",
514
+ Effect.fn(function* (ctx) {
515
+ yield* session
516
+ - .shell({ sessionID: ctx.params.sessionID, id: ctx.payload.id, command: ctx.payload.command })
517
+ + .shell({
518
+ + sessionID: ctx.params.sessionID,
519
+ + id: ctx.payload.id,
520
+ + command: ctx.payload.command,
521
+ + origin: "manual",
522
+ + callID: `manual:${randomUUID()}`,
523
+ + })
524
+ .pipe(Effect.catchTag("Session.NotFoundError", missingSession))
525
+ return HttpApiSchema.NoContent.make()
526
+ }),
527
+ diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts
528
+ index 6b08faf..8a1c8fc 100644
529
+ --- a/packages/server/src/handlers/shell.ts
530
+ +++ b/packages/server/src/handlers/shell.ts
531
+ @@ -21,8 +21,14 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers)
532
+ Effect.fn(function* (ctx) {
533
+ const shell = yield* Shell.Service
534
+ const location = yield* Location.Service
535
+ + // This public location-scoped API is not a trusted tool invocation
536
+ + // boundary. Callers cannot self-assert session/call attribution.
537
+ + const metadata = { ...(ctx.payload.metadata ?? {}) }
538
+ + delete metadata.sessionID
539
+ + delete metadata.shellOrigin
540
+ + delete metadata.callID
541
+ return yield* response(
542
+ - shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
543
+ + shell.create({ ...ctx.payload, metadata, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie),
544
+ )
545
+ }),
546
+ )
547
+ diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
548
+ index 88939eb..4c343c0 100644
549
+ --- a/packages/tui/src/app.tsx
550
+ +++ b/packages/tui/src/app.tsx
551
+ @@ -95,13 +95,19 @@ import { Config, ConfigProvider, useConfig } from "./config"
552
+ import { newSessionLocation } from "./config/new-session-location"
553
+ import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
554
+ import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
555
+ +import { TranscriptProvider } from "./plugin/api"
556
+ import { localPluginDirectories } from "./plugin/discovery"
557
+ import { PluginRoute, Slot } from "./plugin/render"
558
+ import { CommandPaletteDialog } from "./component/command-palette"
559
+ import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
560
+
561
+ import { DialogVariant } from "./component/dialog-variant"
562
+ -import { destroyRenderer } from "./util/renderer"
563
+ +import {
564
+ + destroyRenderer,
565
+ + destroyUnacquiredRenderer,
566
+ + saveTerminalTitle,
567
+ + trackSavedTerminalTitle,
568
+ +} from "./util/renderer"
569
+ import { cliErrorMessage, errorFormat } from "./util/error"
570
+ import { AttentionProvider } from "./context/attention"
571
+ import { StorageProvider, useStorage } from "./context/storage"
572
+ @@ -252,6 +258,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
573
+ const renderer = yield* Effect.gen(function* () {
574
+ if (handoff) {
575
+ handoff.renderer.useMouse = options.useMouse
576
+ + saveTerminalTitle()
577
+ + trackSavedTerminalTitle(handoff.renderer)
578
+ return yield* Effect.acquireRelease(Effect.succeed(handoff.renderer), (renderer) =>
579
+ Effect.sync(() => destroyRenderer(renderer)),
580
+ )
581
+ @@ -260,13 +268,24 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
582
+ const { Drive } = yield* Effect.promise(() => import("@opencode/simulation/frontend"))
583
+ return yield* Drive.create(options, input.app.version)
584
+ }
585
+ - return yield* Effect.acquireRelease(
586
+ + const acquired = yield* Effect.acquireRelease(
587
+ Effect.tryPromise({
588
+ - try: () => createCliRenderer(options),
589
+ + try: async () => {
590
+ + saveTerminalTitle()
591
+ + try {
592
+ + const renderer = await createCliRenderer(options)
593
+ + trackSavedTerminalTitle(renderer)
594
+ + return renderer
595
+ + } catch (error) {
596
+ + destroyUnacquiredRenderer()
597
+ + throw error
598
+ + }
599
+ + },
600
+ catch: (error) => (error instanceof Error ? error : new Error(String(error))),
601
+ }),
602
+ (renderer) => Effect.sync(() => destroyRenderer(renderer)),
603
+ )
604
+ + return acquired
605
+ })
606
+ renderer.setMaxListeners(15)
607
+ const clipboard = yield* Effect.acquireRelease(
608
+ @@ -380,8 +399,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
609
+ <PermissionProvider>
610
+ <DataProvider directory={directory}>
611
+ <LocationProvider>
612
+ - <SessionTabsProvider>
613
+ - <SessionTerminalsProvider>
614
+ + <TranscriptProvider>
615
+ + <SessionTabsProvider>
616
+ + <SessionTerminalsProvider>
617
+ <ThemeProvider
618
+ mode={mode}
619
+ source={createThemeSource(global.config)}
620
+ @@ -416,8 +436,9 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
621
+ </PromptStashProvider>
622
+ </LocalProvider>
623
+ </ThemeProvider>
624
+ - </SessionTerminalsProvider>
625
+ - </SessionTabsProvider>
626
+ + </SessionTerminalsProvider>
627
+ + </SessionTabsProvider>
628
+ + </TranscriptProvider>
629
+ </LocationProvider>
630
+ </DataProvider>
631
+ </PermissionProvider>
632
+ @@ -767,7 +788,6 @@ function App() {
633
+ name: "session.clear",
634
+ title: "Clear session",
635
+ category: "Session",
636
+ - slash: { name: "clear" },
637
+ run: () => {
638
+ const model = local.model.current()
639
+ const agent = local.agent.current()
640
+ diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
641
+ index a9f699c..730a6da 100644
642
+ --- a/packages/tui/src/component/prompt/index.tsx
643
+ +++ b/packages/tui/src/component/prompt/index.tsx
644
+ @@ -1306,7 +1306,10 @@ export function Prompt(props: PromptProps) {
645
+ history.append(entry)
646
+ if (currentMode === "shell") {
647
+ move.startSubmit()
648
+ - const send = () => client.api.session.shell({ sessionID: target, command: inputText })
649
+ + const send = () => client.api.session.shell({
650
+ + sessionID: target,
651
+ + command: inputText,
652
+ + })
653
+ void (newSession ? newSession.gate.then(send).catch(newSession.recover) : send())
654
+ setStore("mode", "normal")
655
+ } else if (slashHead && isCommand) {
656
+ diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx
657
+ index 35a4eac..a151d84 100644
658
+ --- a/packages/tui/src/plugin/api.tsx
659
+ +++ b/packages/tui/src/plugin/api.tsx
660
+ @@ -32,9 +32,43 @@ import { useSessionTabs } from "../context/session-tabs"
661
+ import { useOptionalPanel } from "../context/panel"
662
+ import { useLocal } from "../context/local"
663
+ import { abbreviateHome } from "../util/path-format"
664
+ +import { createContext, useContext, type ParentProps } from "solid-js"
665
+
666
+ export type Dispose = () => Promise<void>
667
+
668
+ +const Transcript = createContext<ReturnType<typeof createTranscriptHost>>()
669
+ +
670
+ +export function TranscriptProvider(props: ParentProps) {
671
+ + const value = createTranscriptHost(useData())
672
+ + return <Transcript.Provider value={value}>{props.children}</Transcript.Provider>
673
+ +}
674
+ +
675
+ +export function createTranscriptHost(data: ReturnType<typeof useData>) {
676
+ + const [persisted, persist] = useStorage().store<Record<string, { before?: string }>>("transcript-presentation", {
677
+ + initial: {},
678
+ + })
679
+ + return {
680
+ + before: (sessionID: string) => persisted[sessionID]?.before,
681
+ + clear(sessionID: string) {
682
+ + const before = data.session.message.list(sessionID).at(-1)?.id
683
+ + return {
684
+ + before,
685
+ + persist: () =>
686
+ + persist((draft) => {
687
+ + if (before) draft[sessionID] = { before }
688
+ + else delete draft[sessionID]
689
+ + }),
690
+ + }
691
+ + },
692
+ + }
693
+ +}
694
+ +
695
+ +export function useTranscript() {
696
+ + const value = useContext(Transcript)
697
+ + if (!value) throw new Error("TranscriptProvider is missing")
698
+ + return value
699
+ +}
700
+ +
701
+ // Slot inputs erased to their union: the registry stores one render shape
702
+ // regardless of which path a claim targets.
703
+ export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
704
+ @@ -62,11 +96,14 @@ export type Registry = {
705
+ // The host services a plugin context adapts. Collected once by the provider
706
+ // (hooks must run during component setup) and shared by every activation.
707
+ export function usePluginHost() {
708
+ + const data = useData()
709
+ + const transcript = useTranscript()
710
+ return {
711
+ owner: getOwner(),
712
+ renderer: useRenderer(),
713
+ client: useClient(),
714
+ - data: useData(),
715
+ + data,
716
+ + transcript,
717
+ route: useRoute(),
718
+ keymap: Keymap.use(),
719
+ shortcuts: Keymap.useShortcuts(),
720
+ @@ -158,6 +195,7 @@ export function createPluginContext(input: {
721
+ renderer: host.renderer,
722
+ client: host.client.api,
723
+ data: host.data,
724
+ + transcript: host.transcript,
725
+ attention: host.attention,
726
+ get theme() {
727
+ return host.themes.currentTokens()
728
+ diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx
729
+ index 31c4f4c..b04abf5 100644
730
+ --- a/packages/tui/src/plugin/context.tsx
731
+ +++ b/packages/tui/src/plugin/context.tsx
732
+ @@ -62,6 +62,13 @@ type Value = {
733
+ readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
734
+ }
735
+ readonly markdown: () => MarkdownOptions["renderNode"]
736
+ + readonly transcript: {
737
+ + readonly before: (sessionID: string) => string | undefined
738
+ + readonly clear: (sessionID: string) => {
739
+ + readonly before: string | undefined
740
+ + readonly persist: () => Promise<void>
741
+ + }
742
+ + }
743
+ readonly activate: (id: string) => Promise<boolean>
744
+ readonly deactivate: (id: string) => Promise<boolean>
745
+ }
746
+ @@ -608,6 +615,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
747
+ route: (id, name) => store.registrations[id]?.routes[name]?.render,
748
+ slots: { register: registerSlot, resolved },
749
+ markdown,
750
+ + transcript: host.transcript,
751
+ // Manual dialog toggles join the same chain as reconciles so a
752
+ // toggle mid-reload cannot mix registrations across generations.
753
+ activate: (id) => enqueue(() => activate(id)),
754
+ @@ -752,6 +760,6 @@ function isPlugin(value: unknown): value is Plugin.Definition {
755
+
756
+ export function usePlugin() {
757
+ const value = useContext(PluginContext)
758
+ - if (!value) throw new Error("PluginProvider is missing")
759
+ - return value
760
+ + if (value) return value
761
+ + throw new Error("PluginProvider is missing")
762
+ }
763
+ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx
764
+ index 0b8228e..dff91bf 100644
765
+ --- a/packages/tui/src/routes/session/index.tsx
766
+ +++ b/packages/tui/src/routes/session/index.tsx
767
+ @@ -170,7 +170,9 @@ export function Session(props: {
768
+ const theme = useTheme()
769
+ const promptRef = usePromptRef()
770
+ const session = createMemo(() => data.session.get(route.sessionID))
771
+ + const plugin = usePlugin()
772
+ const messages = () => data.session.message.list(route.sessionID)
773
+ + const clearTranscript = (sessionID = route.sessionID) => plugin.transcript.clear(sessionID)
774
+ const messageIndexes = createMemo(() => new Map(messages().map((message, index) => [message.id, index])))
775
+ const legacy = createMemo(() => legacyTurns(messages()))
776
+ const messagesBeforeRevert = () => {
777
+ @@ -278,11 +280,14 @@ export function Session(props: {
778
+ })
779
+ const editor = useEditorContext()
780
+ const [rowsSynced, setRowsSynced] = createSignal(false)
781
+ + const [transcriptBefore, setTranscriptBefore] = createSignal<string>()
782
+ + createEffect(() => setTranscriptBefore(plugin.transcript.before(route.sessionID)))
783
+ const rows = createSessionRows(
784
+ () => route.sessionID,
785
+ (id) => {
786
+ if (id === sessionID) setRowsSynced(true)
787
+ },
788
+ + transcriptBefore,
789
+ )
790
+ const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
791
+ const anchors = createTimelineAnchors()
792
+ @@ -291,6 +296,21 @@ export function Session(props: {
793
+ const [firstJump, setFirstJump] = createSignal<() => void>()
794
+ const [synced, setSynced] = createSignal(false)
795
+ const sessionTabs = useSessionTabs()
796
+ + const clearTranscriptView = () => {
797
+ + const cutoff = clearTranscript(route.sessionID)
798
+ + setTranscriptBefore(cutoff.before)
799
+ + batch(() => {
800
+ + setHiddenRows(undefined)
801
+ + setVisibleRowsEnd(undefined)
802
+ + setAwayFromBottom(false)
803
+ + setNavigationSlack(0)
804
+ + setNavigationMessage(undefined)
805
+ + })
806
+ + sessionTabs.setScrollAnchor(route.sessionID, undefined)
807
+ + scroll.stickyScroll = true
808
+ + afterLayout(() => scroll.scrollTo(scroll.scrollHeight))
809
+ + void cutoff.persist().catch((error) => toast.show({ message: errorMessage(error), variant: "error" }))
810
+ + }
811
+ const [awayFromBottom, setAwayFromBottom] = createSignal(false)
812
+ const [latestHovered, setLatestHovered] = createSignal(false)
813
+ let ensureAllRowsPending: (() => void)[] | undefined
814
+ @@ -822,6 +842,31 @@ export function Session(props: {
815
+ ]
816
+
817
+ const baseAndUnfocusedCommands = [
818
+ + {
819
+ + id: "session.transcript.clear",
820
+ + title: "Clear transcript view",
821
+ + group: "Session",
822
+ + slash: { name: "clear" },
823
+ + run: () => {
824
+ + clearTranscriptView()
825
+ + },
826
+ + },
827
+ + {
828
+ + id: "session.auto-compaction.toggle",
829
+ + title: "Toggle automatic compaction for this session",
830
+ + group: "Session",
831
+ + slash: { name: "autocompact" },
832
+ + run: () => {
833
+ + const enabled = session()?.metadata?.["ctg.autoCompaction"] !== false
834
+ + const metadata = { ...session()?.metadata }
835
+ + if (enabled) metadata["ctg.autoCompaction"] = false
836
+ + else delete metadata["ctg.autoCompaction"]
837
+ + void client.api.session
838
+ + .update({ sessionID: route.sessionID, metadata })
839
+ + .then(() => data.session.sync(route.sessionID))
840
+ + .catch((error) => toast.show({ message: errorMessage(error), variant: "error" }))
841
+ + },
842
+ + },
843
+ {
844
+ id: "session.first",
845
+ title: "First message",
846
+ @@ -1418,6 +1463,19 @@ export function Session(props: {
847
+ updateAwayFromBottom()
848
+ }}
849
+ >
850
+ + <Slot
851
+ + path="session.transcript"
852
+ + input={{
853
+ + sessionID: route.sessionID,
854
+ + clear: async () => {
855
+ + const cutoff = clearTranscript(route.sessionID)
856
+ + setTranscriptBefore(cutoff.before)
857
+ + void cutoff.persist().catch((error) =>
858
+ + toast.show({ message: errorMessage(error), variant: "error" }),
859
+ + )
860
+ + },
861
+ + }}
862
+ + />
863
+ <For each={visibleRows()}>
864
+ {(row, index) => (
865
+ <SessionRowView
866
+ diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts
867
+ index 52e45c9..f350034 100644
868
+ --- a/packages/tui/src/routes/session/rows.ts
869
+ +++ b/packages/tui/src/routes/session/rows.ts
870
+ @@ -44,7 +44,11 @@ export async function completeGroupBoundary(input: {
871
+ }
872
+ }
873
+
874
+ -export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
875
+ +export function createSessionRows(
876
+ + sessionID: Accessor<string>,
877
+ + onSynced?: (sessionID: string) => void,
878
+ + transcriptBefore?: Accessor<string | undefined>,
879
+ +) {
880
+ const data = useData()
881
+ const client = useClient()
882
+ const config = useConfig()
883
+ @@ -60,7 +64,9 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
884
+ const queued = new Set(
885
+ pending.flatMap((item) => (item.type === "user" && item.delivery === "queue" ? [item.id] : [])),
886
+ )
887
+ - const visible = queued.size === 0 ? messages : messages.filter((message) => !queued.has(message.id))
888
+ + const cutoff = transcriptBefore?.()
889
+ + const cutoffIndex = cutoff ? messages.findIndex((message) => message.id === cutoff) : -1
890
+ + const visible = messages.slice(cutoffIndex + 1).filter((message) => !queued.has(message.id))
891
+ const boundary = revertBoundary()
892
+ const rows = reduceSessionRows(
893
+ boundary ? visible.filter((message) => message.id < boundary) : visible,
894
+ @@ -143,6 +149,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
895
+ // Re-reduce when the revert boundary changes (stage/clear/commit). These reactions defer
896
+ // their first run: the mount effect above has already reduced the same state.
897
+ createEffect(on(revertBoundary, rebuild, { defer: true }))
898
+ + createEffect(on(() => transcriptBefore?.(), rebuild, { defer: true }))
899
+
900
+ createEffect(
901
+ on(
902
+ diff --git a/packages/tui/src/util/renderer.ts b/packages/tui/src/util/renderer.ts
903
+ index a0c16fa..92df561 100644
904
+ --- a/packages/tui/src/util/renderer.ts
905
+ +++ b/packages/tui/src/util/renderer.ts
906
+ @@ -1,7 +1,36 @@
907
+ import type { CliRenderer } from "@opentui/core"
908
+
909
+ -export function destroyRenderer(renderer: Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">) {
910
+ - renderer.setTerminalTitle("")
911
+ - if (renderer.isDestroyed) return
912
+ - renderer.destroy()
913
+ +type Write = (data: string) => unknown
914
+ +type TitleRenderer = Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">
915
+ +
916
+ +const SAVE_TITLE = "\x1b[22;0t"
917
+ +const RESTORE_TITLE = "\x1b[23;0t"
918
+ +const saved = new WeakSet<object>()
919
+ +const restored = new WeakSet<object>()
920
+ +
921
+ +export function saveTerminalTitle(write: Write = (data) => process.stdout.write(data)) {
922
+ + write(SAVE_TITLE)
923
+ +}
924
+ +
925
+ +export function trackSavedTerminalTitle(renderer: TitleRenderer) {
926
+ + saved.add(renderer)
927
+ +}
928
+ +
929
+ +export function restoreUnacquiredTerminalTitle(write: Write = (data) => process.stdout.write(data)) {
930
+ + write(RESTORE_TITLE)
931
+ +}
932
+ +
933
+ +export function destroyRenderer(renderer: TitleRenderer, write: Write = (data) => process.stdout.write(data)) {
934
+ + if (restored.has(renderer)) return
935
+ + restored.add(renderer)
936
+ + try {
937
+ + renderer.setTerminalTitle("")
938
+ + if (!renderer.isDestroyed) renderer.destroy()
939
+ + } finally {
940
+ + if (saved.has(renderer)) restoreUnacquiredTerminalTitle(write)
941
+ + }
942
+ +}
943
+ +
944
+ +export function destroyUnacquiredRenderer(write: Write = (data) => process.stdout.write(data)) {
945
+ + restoreUnacquiredTerminalTitle(write)
946
+ }
947
+ diff --git a/packages/tui/test/clear-session.test.tsx b/packages/tui/test/clear-session.test.tsx
948
+ index b53525d..5fd351a 100644
949
+ --- a/packages/tui/test/clear-session.test.tsx
950
+ +++ b/packages/tui/test/clear-session.test.tsx
951
+ @@ -2,6 +2,8 @@ import { expect, test } from "bun:test"
952
+ import { createAppFixture } from "./fixture/app"
953
+ import { directory, json } from "./fixture/tui-client"
954
+ import { tmpdir } from "./fixture/fixture"
955
+ +import type { SessionMessageInfo } from "@opencode/client"
956
+ +import { mkdir } from "node:fs/promises"
957
+
958
+ const location = { directory, project: { id: "project", directory, canonical: directory } }
959
+ const session = {
960
+ @@ -15,18 +17,34 @@ const session = {
961
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
962
+ time: { created: 0, updated: 0 },
963
+ }
964
+ +const messages: SessionMessageInfo[] = [
965
+ + { type: "user", id: "msg_0001", text: "Durable older prompt", time: { created: 1 } },
966
+ + { type: "user", id: "msg_0002", text: "Durable latest prompt", time: { created: 2 } },
967
+ +]
968
+ +let metadataUpdates: Record<string, unknown>[] = []
969
+ +let manualCompactions = 0
970
+ +let sessionMetadata: Record<string, unknown> | undefined
971
+
972
+ -function render(state: string) {
973
+ +function render(
974
+ + state: string,
975
+ + extra?: (url: URL, request: Request) => Response | undefined | Promise<Response | undefined>,
976
+ +) {
977
+ return createAppFixture({
978
+ state,
979
+ args: { sessionID: session.id },
980
+ config: { animations: false, tabs: { mode: "on" } },
981
+ - fetch: (url) => {
982
+ + fetch: async (url, request) => {
983
+ + const handled = await extra?.(url, request)
984
+ + if (handled) return handled
985
+ if (url.pathname === "/api/fs/list") return json({ location, data: [] })
986
+ if (url.pathname === "/api/location") return json(location)
987
+ if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
988
+ - if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
989
+ - if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
990
+ + if (url.pathname === `/api/session/${session.id}`) return json({ data: { ...session, metadata: sessionMetadata } })
991
+ + if (/^\/api\/session\/[^/]+\/compact$/.test(url.pathname) && request.method === "POST")
992
+ + return new Response(null, { status: 204 })
993
+ + if (url.pathname === `/api/session/${session.id}/message`)
994
+ + return json({ data: messages.toReversed(), cursor: {} })
995
+ + if (/^\/api\/session\/[^/]+\/(inbox|permission)$/.test(url.pathname))
996
+ return json({ data: [], cursor: {} })
997
+ if (url.pathname === "/api/agent")
998
+ return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
999
+ @@ -40,17 +58,66 @@ function render(state: string) {
1000
+ })
1001
+ }
1002
+
1003
+ -test("/clear replaces the active session tab", async () => {
1004
+ +test("/clear hides only the active transcript view and preserves durable rows", async () => {
1005
+ + metadataUpdates = []
1006
+ + sessionMetadata = undefined
1007
+ await using state = await tmpdir()
1008
+ - await using setup = await render(state.path)
1009
+ + await mkdir(`${state.path}/test/tui`, { recursive: true })
1010
+ + await Bun.write(
1011
+ + `${state.path}/test/tui/transcript-presentation.json`,
1012
+ + JSON.stringify({ ses_other: { before: "msg_other_0002" } }),
1013
+ + )
1014
+ + {
1015
+ + await using setup = await render(state.path)
1016
+ + await setup.waitForFrame((frame) => frame.includes("Durable latest prompt"))
1017
+ + await setup.mockInput.typeText("/clear")
1018
+ + setup.mockInput.pressEnter()
1019
+ + const frame = await setup.waitForFrame(
1020
+ + (frame) => !frame.includes("Durable latest prompt") && frame.includes("Session to clear"),
1021
+ + )
1022
+ + expect(frame).not.toContain("Durable latest prompt")
1023
+ + expect(frame).toContain("Session to clear")
1024
+ + expect(messages.map((message) => message.id)).toEqual(["msg_0001", "msg_0002"])
1025
+ + }
1026
+
1027
+ + await using reopened = await render(state.path)
1028
+ + await reopened.waitForFrame((current) => current.includes("Session to clear") && !current.includes("Durable latest prompt"))
1029
+ + const persisted = JSON.parse(await Bun.file(`${state.path}/test/tui/transcript-presentation.json`).text())
1030
+ + expect(persisted).toEqual({
1031
+ + ses_other: { before: "msg_other_0002" },
1032
+ + [session.id]: { before: "msg_0002" },
1033
+ + })
1034
+ + expect(messages.map((message) => message.id)).toEqual(["msg_0001", "msg_0002"])
1035
+ +}, { timeout: 60_000 })
1036
+ +
1037
+ +test("/autocompact persists a session-local opt-out and manual compact remains available", async () => {
1038
+ + metadataUpdates = []
1039
+ + manualCompactions = 0
1040
+ + sessionMetadata = undefined
1041
+ + await using state = await tmpdir()
1042
+ + await using setup = await render(state.path, async (url, request) => {
1043
+ + if (request.method === "PATCH" && url.pathname === `/api/session/${session.id}`) {
1044
+ + const body = (await request.json()) as { metadata: Record<string, unknown> }
1045
+ + metadataUpdates.push(body.metadata)
1046
+ + sessionMetadata = body.metadata
1047
+ + return new Response(null, { status: 204 })
1048
+ + }
1049
+ + if (request.method === "POST" && url.pathname === `/api/session/${session.id}/compact`) {
1050
+ + manualCompactions++
1051
+ + return new Response(null, { status: 204 })
1052
+ + }
1053
+ + return undefined
1054
+ + })
1055
+ await setup.waitForFrame((frame) => frame.includes("Session to clear"))
1056
+ - await setup.mockInput.typeText("/clear")
1057
+ + await setup.mockInput.typeText("/autocompact")
1058
+ setup.mockInput.pressEnter()
1059
+ - const frame = await setup.waitForFrame((frame) => !frame.includes("Session to clear"))
1060
+ -
1061
+ - expect(frame).not.toContain("Session to clear")
1062
+ -})
1063
+ + await setup.waitFor(() => metadataUpdates.length === 1)
1064
+ + expect(metadataUpdates).toEqual([{ "ctg.autoCompaction": false }])
1065
+ + await setup.mockInput.typeText("/compact")
1066
+ + setup.mockInput.pressEnter()
1067
+ + await setup.waitFor(() => manualCompactions === 1)
1068
+ + expect(metadataUpdates).toEqual([{ "ctg.autoCompaction": false }])
1069
+ +}, { timeout: 60_000 })
1070
+
1071
+ test("/new keeps the active session tab", async () => {
1072
+ await using state = await tmpdir()
1073
+ diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts
1074
+ index 9accc15..b9729bf 100644
1075
+ --- a/packages/tui/test/fixture/tui-client.ts
1076
+ +++ b/packages/tui/test/fixture/tui-client.ts
1077
+ @@ -94,7 +94,13 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
1078
+ if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
1079
+ if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
1080
+ if (url.pathname === "/api/info")
1081
+ - return json({ version: "test", pid: 0, urls: [], paths: { tmp: "/tmp" }, capabilities: { persistentPty: true } })
1082
+ + return json({
1083
+ + version: "test",
1084
+ + pid: 0,
1085
+ + urls: [],
1086
+ + paths: { tmp: "/tmp" },
1087
+ + capabilities: { persistentPty: true },
1088
+ + })
1089
+ if (url.pathname === "/api/location")
1090
+ return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
1091
+ if (url.pathname === "/api/plugin")
1092
+ diff --git a/packages/tui/test/util/renderer.test.ts b/packages/tui/test/util/renderer.test.ts
1093
+ index a5c4e7b..6c015fd 100644
1094
+ --- a/packages/tui/test/util/renderer.test.ts
1095
+ +++ b/packages/tui/test/util/renderer.test.ts
1096
+ @@ -1,30 +1,63 @@
1097
+ import { expect, test } from "bun:test"
1098
+ -import { destroyRenderer } from "../../src/util/renderer"
1099
+ +import {
1100
+ + destroyRenderer,
1101
+ + destroyUnacquiredRenderer,
1102
+ + saveTerminalTitle,
1103
+ + trackSavedTerminalTitle,
1104
+ +} from "../../src/util/renderer"
1105
+ +import type { CliRenderer } from "@opentui/core"
1106
+
1107
+ -test("clears the terminal title before destroying the renderer", () => {
1108
+ - const calls: string[] = []
1109
+ - destroyRenderer({
1110
+ - isDestroyed: false,
1111
+ - setTerminalTitle(title) {
1112
+ +const renderer = (isDestroyed: boolean, calls: string[]) =>
1113
+ + ({
1114
+ + isDestroyed,
1115
+ + setTerminalTitle(title: string) {
1116
+ calls.push(`title:${title}`)
1117
+ },
1118
+ destroy() {
1119
+ calls.push("destroy")
1120
+ },
1121
+ - })
1122
+ - expect(calls).toEqual(["title:", "destroy"])
1123
+ + }) satisfies Pick<CliRenderer, "isDestroyed" | "setTerminalTitle" | "destroy">
1124
+ +
1125
+ +test("saves the host title with the VT title-stack control", () => {
1126
+ + const output: string[] = []
1127
+ + saveTerminalTitle((data) => output.push(data))
1128
+ + expect(output).toEqual(["\x1b[22;0t"])
1129
+ +})
1130
+ +
1131
+ +test("saves a handed-off renderer title once and restores it on cleanup", () => {
1132
+ + const calls: string[] = []
1133
+ + const instance = renderer(false, calls)
1134
+ + const write = (data: string) => calls.push(`write:${data}`)
1135
+ + saveTerminalTitle(write)
1136
+ + trackSavedTerminalTitle(instance)
1137
+ + trackSavedTerminalTitle(instance)
1138
+ + destroyRenderer(instance, write)
1139
+ + expect(calls).toEqual(["write:\x1b[22;0t", "title:", "destroy", "write:\x1b[23;0t"])
1140
+ +})
1141
+ +
1142
+ +test("clears the terminal title before destroying the renderer", () => {
1143
+ + const calls: string[] = []
1144
+ + const instance = renderer(false, calls)
1145
+ + saveTerminalTitle((data) => calls.push(`write:${data}`))
1146
+ + trackSavedTerminalTitle(instance)
1147
+ + calls.length = 0
1148
+ + destroyRenderer(instance, (data) => calls.push(`write:${data}`))
1149
+ + destroyRenderer(instance, (data) => calls.push(`write:${data}`))
1150
+ + expect(calls).toEqual(["title:", "destroy", "write:\x1b[23;0t"])
1151
+ })
1152
+
1153
+ test("still clears the title after renderer destruction", () => {
1154
+ const calls: string[] = []
1155
+ - destroyRenderer({
1156
+ - isDestroyed: true,
1157
+ - setTerminalTitle(title) {
1158
+ - calls.push(`title:${title}`)
1159
+ - },
1160
+ - destroy() {
1161
+ - calls.push("destroy")
1162
+ - },
1163
+ - })
1164
+ - expect(calls).toEqual(["title:"])
1165
+ + const instance = renderer(true, calls)
1166
+ + saveTerminalTitle((data) => calls.push(`write:${data}`))
1167
+ + trackSavedTerminalTitle(instance)
1168
+ + calls.length = 0
1169
+ + destroyRenderer(instance, (data) => calls.push(`write:${data}`))
1170
+ + expect(calls).toEqual(["title:", "write:\x1b[23;0t"])
1171
+ +})
1172
+ +
1173
+ +test("restores the title stack when renderer acquisition fails", () => {
1174
+ + const calls: string[] = []
1175
+ + destroyUnacquiredRenderer((data) => calls.push(data))
1176
+ + expect(calls).toEqual(["\x1b[23;0t"])
1177
+ })
1178
+ diff --git a/packages/server/test/shell-provenance.test.ts b/packages/server/test/shell-provenance.test.ts
1179
+ new file mode 100644
1180
+ index 0000000..2f742d7
1181
+ --- /dev/null
1182
+ +++ b/packages/server/test/shell-provenance.test.ts
1183
+ @@ -0,0 +1,34 @@
1184
+ +import { expect } from "bun:test"
1185
+ +import { Effect } from "effect"
1186
+ +import { tmpdir } from "../../core/test/fixture/tmpdir"
1187
+ +import { it } from "../../core/test/lib/effect"
1188
+ +import { ServerFetch } from "../src/fetch"
1189
+ +
1190
+ +it.live("public shell HTTP requests cannot claim session, origin, or call identity", () =>
1191
+ + Effect.gen(function* () {
1192
+ + const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-shell-provenance-")))
1193
+ + const handler = yield* ServerFetch.make({
1194
+ + app: { version: "test" },
1195
+ + database: { path: ":memory:" },
1196
+ + fs: { filewatcher: false },
1197
+ + models: { fetch: false },
1198
+ + })
1199
+ + const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/shell", {
1200
+ + method: "POST",
1201
+ + headers: { "content-type": "application/json" },
1202
+ + body: JSON.stringify({
1203
+ + command: "printf provenance",
1204
+ + cwd: tmp.path,
1205
+ + metadata: {
1206
+ + sessionID: "ses_attacker_controlled",
1207
+ + shellOrigin: "tool",
1208
+ + callID: "forged-tool-call",
1209
+ + retained: "ordinary metadata",
1210
+ + },
1211
+ + }),
1212
+ + })))
1213
+ + expect(response.status).toBe(200)
1214
+ + const body = (yield* Effect.promise(() => response.json())) as { data: { metadata: Record<string, unknown> } }
1215
+ + expect(body.data.metadata).toEqual({ retained: "ordinary metadata" })
1216
+ + }).pipe(Effect.scoped),
1217
+ +)