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.
- package/README.md +140 -83
- package/bin/ctg.mjs +14 -732
- package/docs/beta-rewards-e2e-plan.md +48 -0
- package/docs/beta-rewards-e2e-results.md +84 -0
- package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
- package/docs/glyph-review/test-isolation-v1.md +26 -0
- package/docs/glyph-review/typecheck-base.log +1 -0
- package/docs/glyph-review/typecheck-v4.log +1 -0
- package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
- package/docs/help-dispatch-review.md +69 -0
- package/docs/launch-readiness-live-results.md +65 -0
- package/docs/legacy-cli.md +150 -0
- package/docs/live-theme-plugin-api.md +18 -0
- package/docs/native-cli.md +102 -0
- package/docs/native-compatibility.md +16 -0
- package/docs/native-daily-quests.md +132 -0
- package/docs/native-glyph-pty.md +59 -0
- package/docs/native-manual-testing.md +336 -0
- package/docs/native-slash-acceptance-handoff.md +49 -0
- package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
- package/docs/native-terminal-performance.md +310 -0
- package/docs/native-terminal-semantics-evidence/README.md +25 -0
- package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
- package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
- package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
- package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
- package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
- package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
- package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
- package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
- package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
- package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
- package/docs/native-terminal-semantics.md +89 -0
- package/docs/native-upgrade.md +175 -0
- package/docs/opencode-composed-source-review.md +89 -0
- package/docs/opencode-migration.md +225 -9
- package/docs/opencode-owned-runtime.md +241 -0
- package/docs/opencode-runtime.md +12 -0
- package/docs/opencode-theme-provider-proof/README.md +55 -0
- package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
- package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
- package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
- package/docs/owned-host-controls-proof.md +39 -0
- package/docs/owned-runtime-release-manifest.md +42 -0
- package/docs/owned-visual-clear-proof.md +40 -0
- package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
- package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
- package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
- package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
- package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
- package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
- package/docs/release-notes-beta.1.md +60 -0
- package/docs/release-notes-native-draft.md +73 -0
- package/docs/release-notes-preview.10.md +121 -0
- package/docs/release-notes-preview.11.md +76 -0
- package/docs/release-notes-preview.12.md +55 -0
- package/docs/release-notes-preview.13.md +68 -0
- package/docs/release-notes-preview.9.md +130 -0
- package/docs/source-preview.md +120 -0
- package/evaluate.mjs +28 -1
- package/lib/budget.mjs +1 -1
- package/lib/codegraph.mjs +1 -1
- package/lib/delivery-review.mjs +19 -6
- package/lib/delivery.mjs +3 -2
- package/lib/environment.mjs +8 -0
- package/lib/guidance.mjs +1 -1
- package/lib/opencode/cli-runtime.mjs +1171 -0
- package/lib/opencode/cli.mjs +252 -0
- package/lib/opencode/credentials.mjs +63 -8
- package/lib/opencode/demo.mjs +53 -0
- package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
- package/lib/opencode/evaluation.mjs +32 -12
- package/lib/opencode/headless.mjs +22 -5
- package/lib/opencode/provider-policy.mjs +23 -0
- package/lib/opencode/review.mjs +37 -16
- package/lib/opencode/runtime-artifact.mjs +335 -0
- package/lib/opencode/runtime-github-transport.mjs +121 -0
- package/lib/opencode/runtime-release.json +36 -0
- package/lib/opencode/runtime.mjs +299 -20
- package/lib/opencode/service.mjs +77 -35
- package/lib/opencode/session-options.mjs +6 -2
- package/lib/opencode/update.mjs +222 -0
- package/lib/opencode/workspace.mjs +1 -1
- package/lib/planning-advice.mjs +2 -2
- package/lib/providers/experiential.mjs +2 -2
- package/lib/tui/framing.mjs +1 -80
- package/lib/update.mjs +10 -1
- package/package.json +33 -7
- package/plugins/carthagent/README.md +23 -0
- package/plugins/carthagent/src/bootstrap.ts +1 -1
- package/plugins/carthagent/src/daily-evidence.mjs +57 -0
- package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
- package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
- package/plugins/carthagent/src/index.ts +71 -24
- package/plugins/carthagent/src/rpc.ts +51 -6
- package/plugins/carthagent/src/server/codegraph.ts +12 -5
- package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
- package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
- package/plugins/carthagent/src/server/delivery.ts +227 -112
- package/plugins/carthagent/src/server/framing.mjs +80 -0
- package/plugins/carthagent/src/server/policy.ts +1 -1
- package/plugins/carthagent/src/server/providers.ts +59 -31
- package/plugins/carthagent/src/server/session-controls.ts +3 -1
- package/plugins/carthagent/src/server/ship-agents.ts +77 -0
- package/plugins/carthagent/src/server/ship-context.mjs +60 -0
- package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
- package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
- package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
- package/plugins/carthagent/src/testing.ts +5 -0
- package/plugins/carthagent/src/tui.tsx +496 -0
- package/plugins/carthagent/src/ui/client.ts +127 -0
- package/plugins/carthagent/src/ui/commands.ts +70 -0
- package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
- package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
- package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
- package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
- package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
- package/plugins/carthagent/src/ui/host-state.ts +42 -0
- package/plugins/carthagent/src/ui/model-command.ts +32 -0
- package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
- package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
- package/plugins/carthagent/src/ui/native-review.mjs +159 -0
- package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
- package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
- package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
- package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
- package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
- package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
- package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
- package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
- package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
- package/plugins/carthagent/src/ui/voyages.tsx +131 -0
- package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
- package/plugins/carthagent/src/ui/workspace.tsx +379 -0
- package/plugins/carthagent/src/voyages-rpc.ts +64 -0
- package/plugins/carthagent/src/workspace-rpc.ts +36 -0
- package/plugins/carthagent/tests/commands.test.ts +107 -0
- package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
- package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
- package/plugins/carthagent/themes/carthage.json +67 -0
- package/plugins/carthagent/tui.ts +26 -1
- package/script/ctg/build-owned-runtime.sh +166 -0
- package/tests/budget-engine.test.mjs +4 -3
- package/tests/cli.test.mjs +52 -96
- package/tests/daily-evidence.test.mjs +62 -0
- package/tests/delivery-review.test.mjs +57 -2
- package/tests/delivery.test.mjs +22 -1
- package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
- package/tests/generate-owned-runtime-release.test.mjs +354 -0
- package/tests/installed-runtime-contract.test.mjs +43 -0
- package/tests/native-cli-host-smoke.mjs +457 -0
- package/tests/native-daily-editor-focus.test.mjs +213 -0
- package/tests/native-daily-editor.test.mjs +43 -0
- package/tests/native-daily-quests-layout.test.mjs +55 -0
- package/tests/native-daily-quests-rpc.test.mjs +456 -0
- package/tests/native-daily-quests-ui.test.mjs +778 -0
- package/tests/native-daily-source.test.mjs +525 -0
- package/tests/native-demo-ui.test.mjs +48 -0
- package/tests/native-evaluation-admission.test.mjs +24 -0
- package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
- package/tests/native-fixture-runtime.test.mjs +111 -0
- package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
- package/tests/native-glyph-pty.test.mjs +107 -0
- package/tests/native-manual-projects.test.mjs +170 -0
- package/tests/native-model-command.test.mjs +67 -0
- package/tests/native-owned-runtime-env.test.mjs +140 -0
- package/tests/native-paired-guards.test.mjs +464 -0
- package/tests/native-provider-policy.test.mjs +31 -0
- package/tests/native-pty-invocation.test.mjs +114 -0
- package/tests/native-pty-runtime.test.mjs +45 -0
- package/tests/native-resume-session.test.mjs +46 -0
- package/tests/native-review-consumer.test.mjs +286 -0
- package/tests/native-runtime-identity.test.mjs +214 -0
- package/tests/native-select-mouse.test.mjs +23 -0
- package/tests/native-ship-guidance.test.mjs +16 -0
- package/tests/native-ship-parity.test.mjs +526 -0
- package/tests/native-ship-plan.test.mjs +85 -0
- package/tests/native-ship-rpc.test.mjs +71 -0
- package/tests/native-ui-commands.test.mjs +6 -0
- package/tests/native-voyages-layout.test.mjs +28 -0
- package/tests/native-voyages-navigation.test.mjs +169 -0
- package/tests/native-voyages-rpc.test.mjs +397 -0
- package/tests/native-voyages-ui.test.mjs +307 -0
- package/tests/opencode-atomic-write.test.mjs +55 -0
- package/tests/opencode-cli.test.mjs +1312 -0
- package/tests/opencode-codegraph-plugin.test.mjs +47 -0
- package/tests/opencode-delivery.test.mjs +16 -0
- package/tests/opencode-providers.test.mjs +245 -18
- package/tests/opencode-runtime.test.mjs +597 -8
- package/tests/opencode-session-controls.test.mjs +15 -0
- package/tests/opencode-ship-rpc.test.mjs +71 -0
- package/tests/opencode-update.test.mjs +475 -0
- package/tests/opencode-workspace.test.mjs +51 -2
- package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
- package/tests/release-workflow.test.mjs +46 -0
- package/tests/runtime-artifact.test.mjs +269 -0
- package/tests/runtime-development-trust.test.mjs +231 -0
- package/tests/runtime-github-transport.test.mjs +132 -0
- package/tests/runtime-token-env.test.mjs +23 -0
- package/tests/server.test.mjs +3 -1
- package/tests/ship-telemetry.test.mjs +1 -1
- package/tests/source-preview.test.mjs +253 -0
- package/tests/test_native_terminal_probe.py +76 -0
- package/tests/test_native_terminal_semantics.py +232 -0
- package/tests/update.test.mjs +42 -5
- package/tests/verify-draft-runtime-gate.test.mjs +280 -0
- package/tests/verify-owned-runtime-release.test.mjs +371 -0
- package/tests/workspace-live.test.mjs +127 -0
- package/tools/benchmark/diagnose-request.mjs +59 -36
- package/tools/benchmark/eval-common.mjs +3 -3
- package/tools/benchmark/evaluators.test.mjs +4 -4
- package/tools/benchmark/guard.test.mjs +35 -2
- package/tools/benchmark/guard.ts +20 -7
- package/tools/benchmark/planning-startup.test.mjs +30 -34
- package/tools/benchmark/runner.mjs +12 -7
- package/tools/benchmark/runner.test.mjs +23 -7
- package/tools/benchmark/upstream-error.test.mjs +16 -2
- package/tools/generate-owned-runtime-release.mjs +217 -0
- package/tools/installed-runtime-contract.mjs +16 -0
- package/tools/manual-runtime-manifest.mjs +39 -0
- package/tools/native-fixture-browser-cleanup.mjs +59 -0
- package/tools/native-fixture-stop-diagnostics.mjs +43 -0
- package/tools/native-fixture.mjs +199 -40
- package/tools/native-glyph-helpers.py +127 -0
- package/tools/native-glyph-pty.py +144 -0
- package/tools/native-manual-projects.mjs +259 -0
- package/tools/native-manual.mjs +338 -0
- package/tools/native-model-picker-acceptance.py +306 -0
- package/tools/native-owned-runtime-env.sh +47 -0
- package/tools/native-paired-guards.mjs +282 -0
- package/tools/native-paired-manifest.mjs +72 -0
- package/tools/native-paired-orchestration.sh +51 -0
- package/tools/native-plugin-readiness.mjs +33 -0
- package/tools/native-plugin-readiness.test.mjs +53 -0
- package/tools/native-pty-invocation.mjs +26 -0
- package/tools/native-pty-runtime.mjs +83 -0
- package/tools/native-pty-smoke.py +147 -24
- package/tools/native-public-repo-shell-result.mjs +13 -0
- package/tools/native-public-repo-shell-result.test.mjs +28 -0
- package/tools/native-runtime-identity.mjs +201 -0
- package/tools/native-slash-acceptance-env.py +38 -0
- package/tools/native-terminal-semantics.py +876 -0
- package/tools/owned-shell-observer/index.ts +16 -0
- package/tools/owned-shell-observer/package.json +1 -0
- package/tools/owned-visual-clear-env.py +48 -0
- package/tools/owned-visual-clear-model.py +21 -0
- package/tools/pinned-owned-runtime-inputs.mjs +46 -0
- package/tools/prepare-native-manual.mjs +122 -0
- package/tools/run-native-terminal-paired.sh +403 -0
- package/tools/runtime-token-env.mjs +8 -0
- package/tools/seed-native-switch-fixture.mjs +79 -0
- package/tools/test-native-controls.mjs +3 -1
- package/tools/test-native-daily-quests.mjs +65 -0
- package/tools/test-native-default-cli.mjs +106 -0
- package/tools/test-native-delivery-host.mjs +102 -20
- package/tools/test-native-evaluation.mjs +7 -3
- package/tools/test-native-glyph-pty.mjs +94 -0
- package/tools/test-native-host.mjs +87 -63
- package/tools/test-native-outcomes.mjs +3 -1
- package/tools/test-native-package.mjs +206 -38
- package/tools/test-native-performance.mjs +26 -0
- package/tools/test-native-product.mjs +90 -0
- package/tools/test-native-pty.mjs +34 -9
- package/tools/test-native-public-repo.mjs +224 -0
- package/tools/test-native-refresh-reentry.mjs +106 -0
- package/tools/test-native-report.mjs +27 -20
- package/tools/test-native-review.mjs +75 -0
- package/tools/test-native-runtime.mjs +120 -0
- package/tools/test-native-ship-models.mjs +176 -0
- package/tools/test-native-ship-walkthrough.mjs +96 -0
- package/tools/test-native-slash-commands.mjs +125 -0
- package/tools/test-native-slash-commands.py +242 -0
- package/tools/test-native-swe-host.mjs +224 -55
- package/tools/test-native-tasks.mjs +77 -0
- package/tools/test-native-terminal-performance.py +887 -0
- package/tools/test-native-terminal-readiness.sh +85 -0
- package/tools/test-native-terminal-semantics-installed.mjs +38 -0
- package/tools/test-native-visual-clear-installed.mjs +48 -0
- package/tools/test-owned-host-controls.mjs +238 -0
- package/tools/test-owned-opencode-runtime.mjs +35 -0
- package/tools/test-owned-visual-clear-env.py +120 -0
- package/tools/test-owned-visual-clear.py +373 -0
- package/tools/verify-draft-runtime-gate.mjs +138 -0
- package/tools/verify-owned-runtime-release.mjs +114 -0
- 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
|
+
+)
|