diff --git a/.agent/critical-audit/2026-08-19-braid-durable-interaction/findings.jsonl b/.agent/critical-audit/2026-08-19-braid-durable-interaction/findings.jsonl new file mode 100644 index 0000000..c9d546f --- /dev/null +++ b/.agent/critical-audit/2026-08-19-braid-durable-interaction/findings.jsonl @@ -0,0 +1,8 @@ +{"id":1,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/retained-execution-state.ts","line":15,"defect":"The retained execution key did not always include the canonical requested interaction map.","scenario":"The same run and operation are prepared with a changed plan, question, or approval set, then the process-local key reuses state for a different interaction contract.","status":"measured before patch · resolved","evidence":"src/adapters/runtime/retained-execution-state.ts:15-32; src/domain/run-interactions.ts; test/tangle-retained-lifecycle.test.ts:377-402","fix":"Derive the canonical interaction map from the retained plan capabilities when the request does not carry it.","verification":"Focused retained and interactive suites → 42/42 pass.","costIfShipped":"1 incompatible preparation can reuse the wrong interaction contract.","savedIfFixed":"1 of 1 interaction-key regression cases now rejects the mismatch.","claimLevel":"measured"} +{"id":2,"priority":"P1","severity":"HIGH","file":"src/app/production-composition.ts","line":192,"defect":"Production retained recovery did not preserve the persisted receipt, admission, session, and workspace identity across process restart.","scenario":"A process restarts after retained admission, then recovery reconstructs a weaker request and cannot attach to the exact admitted provider run.","status":"measured before patch · resolved","evidence":"src/app/production-composition.ts:192-246; src/adapters/runtime/tangle-retained-execution.ts:20-35; test/tangle-retained-restart.test.ts:62-225","fix":"Build recovery input from persisted receipt and admission data, and pass it through both retained provider adapters.","verification":"Focused retained and interactive suites → 42/42 pass; typecheck passes.","costIfShipped":"1 restart can lose exact retained-run attachment and replay.","savedIfFixed":"1 of 1 restart recovery cases now retains the saved identity.","claimLevel":"measured"} +{"id":3,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/production-backend-common.ts","line":105,"defect":"Provider execution identity used normalization and truncation that could collide for distinct opaque environment or session IDs.","scenario":"Two provider IDs share the retained readable prefix, then lookup or cleanup addresses the wrong environment.","status":"measured before patch · resolved","evidence":"src/adapters/runtime/production-backend-common.ts:105-121; src/adapters/connections/tangle-retained-control-lookup.ts:22-35; test/interactive-execution-adapters.test.ts:490-496; test/tangle-retained-lifecycle.test.ts:239-306","fix":"Preserve safe IDs and append a bounded digest for unsafe or long IDs through one shared identity helper.","verification":"Focused retained and interactive suites → 42/42 pass.","costIfShipped":"1 colliding provider identity can redirect control or cleanup.","savedIfFixed":"2 of 2 collision and length regression checks now pass.","claimLevel":"measured"} +{"id":4,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/tangle-retained-interactive-execution.ts","line":106,"defect":"Interactive cancellation and control races could forward an expired foreground signal or materialize work after a persisted cancellation.","scenario":"Cancellation overlaps start, restart recovery, or provider stop, then the adapter either aborts its own control call or creates provider work after the user requested cancellation.","status":"measured before patch · resolved","evidence":"src/adapters/runtime/tangle-retained-interactive-execution.ts:106-119,250-376,400-412; src/adapters/runtime/retained-execution.ts:203-300; test/interactive-execution-adapters.test.ts:327-471; test/retained-execution-lifecycle.test.ts:286-331","fix":"Coalesce one start promise, recheck cancellation before materialization, and strip an already-aborted foreground signal before native claim or stop.","verification":"Focused retained and interactive suites → 42/42 pass, including no-provider-after-cancel and stop-signal checks.","costIfShipped":"1 provider process or failed control call per affected cancellation race.","savedIfFixed":"4 of 4 cancellation and signal regression cases now pass.","claimLevel":"measured"} +{"id":5,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/retained-execution.ts","line":114,"defect":"Restarted retained controls did not consistently validate supplied provider session and control references against the persisted admission.","scenario":"A caller supplies a stale or foreign reference after restart, then detach, status, reconnect, or recovery can target a different process or materialize pre-dispatch work.","status":"measured before patch · resolved","evidence":"src/adapters/runtime/retained-execution.ts:114-191,406-435,565-602; src/adapters/runtime/tangle-retained-interactive-contract.ts:145-178; test/retained-execution-lifecycle.test.ts:209-272; test/interactive-execution-adapters.test.ts:190-229","fix":"Validate persisted session and exact control identity before recovery, while preserving the explicit rejected acknowledgement for persisted intent cancellation.","verification":"Focused retained and interactive suites → 42/42 pass.","costIfShipped":"1 stale control request can act on the wrong retained identity.","savedIfFixed":"3 of 3 session, control-reference, and pre-dispatch admission checks now pass.","claimLevel":"measured"} +{"id":6,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/tangle-retained-interactive-execution.ts","line":240,"defect":"Detaching a persisted partial native admission recovered and materialized a provider process.","scenario":"A process restarts after intent or environment admission but before provider start, then detach creates native work that the user asked to leave detached.","status":"measured before patch · resolved","evidence":"src/adapters/runtime/tangle-retained-interactive-execution.ts:240-274; test/interactive-execution-adapters.test.ts:379-423","fix":"Mark partial persisted admissions detached without materializing a native handle.","verification":"Focused retained and interactive suites → 42/42 pass, including the no-materialization regression.","costIfShipped":"1 unintended provider process per detached partial admission.","savedIfFixed":"1 of 1 detached-partial regression cases now avoids provider start.","claimLevel":"measured"} +{"id":7,"priority":"P1","severity":"HIGH","file":"src/adapters/runtime/tangle-retained-interactive-contract.ts","line":42,"defect":"The installed Runtime native interactive handle has no structured interaction response method, so Braid cannot safely advertise plan, question, or approval responses on that path.","scenario":"A native Tangle run exposes structured interaction actions, but the canonical response route has no provider operation and would require a second protocol or duplicate execution.","status":"measured · unresolved dependency boundary","evidence":"src/adapters/runtime/tangle-retained-interactive-contract.ts:42-53; installed @tangle-network/agent-runtime AgentInteractiveSession surface; pnpm run test:coordination → 66/67 with 1 failure; pnpm run test:contract → 195/196 with the same failure","fix":"Fail closed by removing unsupported structured interactions from native capabilities until the Runtime package adds the canonical response operation.","verification":"The local fail-closed behavior passes focused suites; the two broader suites retain the same dependency-only failure: provider cli-bridge rejects permission, question, and plan interactions.","costIfShipped":"1 native structured response request cannot complete on the current released dependency.","savedIfFixed":"1 unsupported action path is hidden instead of creating a duplicate or false control route.","claimLevel":"measured"} +{"id":8,"priority":"P2","severity":"MEDIUM","file":"src/adapters/runtime/tangle-retained-interactive-projection.ts","line":42,"defect":"Native PTY bytes are not part of Braid's durable logical event journal, so exact byte-transcript replay is not available after restart.","scenario":"A user detaches and later attaches a native session, then Braid replays synthetic observation and final envelopes but cannot replay every terminal byte from the prior attachment.","status":"measured · unresolved design boundary","evidence":"src/adapters/runtime/tangle-retained-interactive-projection.ts:42-154; src/ports/native-interactive-execution.ts:8-15; pnpm run test:virtual-terminal → 147/147 pass for the supported synthetic replay boundary","fix":"No safe local fix exists without adding a second terminal transport or changing the Runtime provider contract; retain the single native attachment path.","verification":"Virtual-terminal suite passes 147/147 and proves synthetic replay, not byte-for-byte PTY history.","costIfShipped":"1 native session lacks exact byte history after a process restart.","savedIfFixed":"No local savings claimed; a provider-level transcript contract is required.","claimLevel":"measured boundary"} diff --git a/.agent/critical-audit/2026-08-19-braid-durable-interaction/manifest.json b/.agent/critical-audit/2026-08-19-braid-durable-interaction/manifest.json new file mode 100644 index 0000000..e8db289 --- /dev/null +++ b/.agent/critical-audit/2026-08-19-braid-durable-interaction/manifest.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "scope": "Braid current uncommitted durable-interaction and TUI architecture", + "files": 70, + "base": "e3ed3d16f178839560ae4809c8c4df2914bab32e", + "head": "e3ed3d16f178839560ae4809c8c4df2914bab32e", + "workingTree": true, + "diffSha256": "b7eb2ad86f91f334caa6e5d916d36198f3683ed351ae4ff4884c99caeba24d74", + "projectType": "Node.js ESM package", + "reviewers": [ + "primary: retained lifecycle, identity, cancellation, replay, and TUI boundaries", + "independent pass attempted; no usable report returned" + ], + "mode": "current working-tree adversarial review with focused local checks", + "verdict": "REQUEST_CHANGES", + "counts": { + "critical": 0, + "high": 7, + "medium": 1, + "low": 0, + "resolved": 6, + "unresolved": 2, + "dropped": 0 + } +} diff --git a/.agent/critical-audit/2026-08-19-braid-durable-interaction/summary.md b/.agent/critical-audit/2026-08-19-braid-durable-interaction/summary.md new file mode 100644 index 0000000..b0f78d1 --- /dev/null +++ b/.agent/critical-audit/2026-08-19-braid-durable-interaction/summary.md @@ -0,0 +1,50 @@ +# Audit: Braid durable interaction and TUI architecture — e3ed3d1..e3ed3d1 — n=70 files, 8 findings + +**Verdict:** REQUEST_CHANGES — one P1 dependency boundary remains · 0 CRITICAL / 1 HIGH open / 6 HIGH fixed / 1 MEDIUM open +**Worst:** #7 `src/adapters/runtime/tangle-retained-interactive-contract.ts:42` — the released Runtime cannot answer structured native interactions. +**Next:** release the canonical native response operation, then rerun coordination and contract suites. + +## Scope + +| Field | Value | +|---|---| +| Files | n=70 source paths reviewed: 63 tracked changes plus 7 untracked paths; 3 audit files are excluded | +| Base..head | `e3ed3d16f178839560ae4809c8c4df2914bab32e..e3ed3d16f178839560ae4809c8c4df2914bab32e` | +| Project type | Node.js ESM package | +| Review focus | durable identity, restart recovery, interaction normalization, cancellation, control races, routing, ownership, TUI behavior | +| Not inspected | Provider changes not present in this worktree; package manifests were intentionally not edited. | + +## Findings — 8 total, ranked + +| # | Priority | file:line | Defect | Failure scenario | Status | Evidence | Fix | Verification | Cost if shipped | Saved if fixed | +|---:|---|---|---|---|---|---|---|---|---|---| +| 1 | P1 | `src/adapters/runtime/retained-execution-state.ts:15` | Retained key omitted the canonical interaction map. | Changed plan interactions reused incompatible prepared state. | fixed | key regression test | Derive the map from plan capabilities. | focused 42/42 | 1 incompatible preparation can reuse wrong state | 1/1 regression cases pass | +| 2 | P1 | `src/app/production-composition.ts:192` | Restart recovery dropped persisted identity data. | Recovery could not attach to the exact admitted provider run. | fixed | restart test | Preserve receipt, admission, session, and workspace. | focused 42/42 | 1 restart can lose exact attachment | 1/1 restart case passes | +| 3 | P1 | `src/adapters/runtime/production-backend-common.ts:105` | Opaque provider IDs could collide after normalization. | Lookup or cleanup could address the wrong environment. | fixed | identity and long-session tests | Use bounded readable IDs with a digest. | focused 42/42 | 1 collision can redirect control | 2/2 identity checks pass | +| 4 | P1 | `src/adapters/runtime/tangle-retained-interactive-execution.ts:106` | Cancellation races used stale signals or materialized late work. | Stop overlapped start or restart recovery could fail or create work after cancel. | fixed | cancellation and stop-signal tests | Coalesce start, recheck cancel, and strip expired signals. | focused 42/42 | 1 process or failed call per race | 4/4 regression cases pass | +| 5 | P1 | `src/adapters/runtime/retained-execution.ts:114` | Restarted controls did not fully validate saved identity. | A stale reference could target another retained process. | fixed | control identity tests | Validate session and exact persisted references. | focused 42/42 | 1 stale request can target wrong identity | 3/3 checks pass | +| 6 | P1 | `src/adapters/runtime/tangle-retained-interactive-execution.ts:240` | Partial native detach materialized provider work. | Detaching an intent after restart could start a process. | fixed | no-materialization test | Detach partial admission without recovery. | focused 42/42 | 1 unintended process per case | 1/1 case passes | +| 7 | P1 | `src/adapters/runtime/tangle-retained-interactive-contract.ts:42` | Native structured responses are unsupported by the released Runtime. | Plan, question, or approval response has no canonical provider operation. | open dependency | coordination 66/67 and contract 195/196 share one installed-Runtime failure | Hide unsupported actions until the Runtime release adds the operation. | local fail-closed tests pass | 1 native structured response cannot complete | 1 false action path hidden | +| 8 | P2 | `src/adapters/runtime/tangle-retained-interactive-projection.ts:42` | Native PTY bytes are outside the durable logical journal. | Restart replay returns synthetic envelopes, not every prior terminal byte. | open design | projection source and virtual-terminal 147/147 | Requires a provider-level transcript contract; no duplicate local terminal protocol. | supported synthetic boundary passes | 1 byte transcript is unavailable after restart | none claimed locally | + +No P0 defect was confirmed. +No TUI mode-specific execution path or duplicate provider execution was confirmed. + +## Proof + +- `pnpm run typecheck` passed. +- Biome checked 54 changed TypeScript files and passed. +- Focused retained, interactive, identity, restart, and cancellation tests passed 42/42. +- `pnpm run test:virtual-terminal` passed 147/147. +- `pnpm run boundaries` passed with zero cyclic strongly connected components. +- `pnpm run dependencies:check` passed with no high or critical vulnerability finding. +- `git diff --check` passed. +- No `package.json` or `pnpm-lock.yaml` path changed. +- No commit or push was performed. + +The coordination and contract suites each retain one dependency-only failure. +The installed Runtime rejects the fixture interaction map `permission, question, plan` for provider `cli-bridge`. + +## Self-gate + +9/9 passed — verdict has a number; every finding has file and line; every finding has a scenario; every finding has status; every finding has evidence; every finding has a fix or explicit dependency boundary; every finding has verification; every finding has cost on both sides; no P0 claim lacks a check. diff --git a/.agent/critical-audit/2026-08-20T10-24-46Z/findings.jsonl b/.agent/critical-audit/2026-08-20T10-24-46Z/findings.jsonl new file mode 100644 index 0000000..3fe6abc --- /dev/null +++ b/.agent/critical-audit/2026-08-20T10-24-46Z/findings.jsonl @@ -0,0 +1,9 @@ +{"id":1,"severity":"HIGH","status":"fixed","file":"scripts/live-required/contracts.mjs:490-501","defect":"A passed interactive receipt accepted status-only observations.","scenario":"A caller supplied valid-looking identity and true facts with observations {status: 'passed'}; proofReceipt returned passed without structured runtime evidence.","evidence":"Before fix: Luna inline proofReceipt probe exited 0 with status=passed. After fix: node --test scripts/live-required.test.mjs test/tangle-live07-wiring.test.mjs -> 28/28, including status-only rejection.","fix":"Require checks, configuration, run, sandbox, identityContinuity, and processCleanup records.","verification":"test/tangle-live07-wiring.test.mjs:217-226"} +{"id":2,"severity":"HIGH","status":"open-live-boundary","file":"scripts/live-bridge/process-tree.mjs:9-35","defect":"POSIX cleanup proves only the original process group, not escaped descendants.","scenario":"A PTY child starts a detached setsid child; the parent exits, processGroupStatus returns gone=true, and cleanup can remove HOME/workspace while the detached child remains alive.","evidence":"Luna PTY probe: gone=true, aliveAfterParentExit=true; process-tree smoke only proves group status.","fix":"Use a cgroup, job object, or supervised descendant registry before deleting runtime state.","verification":"Add a real detached-descendant test and require zero owned processes before cleanup.","costIfShipped":"1 workspace deletion with a live owned child per affected run","savedIfFixed":"1 unsafe cleanup prevented per affected run"} +{"id":3,"severity":"MEDIUM","status":"open","file":"scripts/live-required/tangle-sandbox-braid-interactive.mjs:442-447","defect":"Interactive cleanup verifies and deletes one control-reference resource, not the complete Braid-owned retained resource set.","scenario":"The run creates a second Braid-owned retained Sandbox with the same session metadata but another environment ID; exact control-ref deletion passes while the second resource remains.","evidence":"Code performs one observe/get/delete/get through cleanupRetainedResourceByControlRef; soak has the broader owned-set census, but LIVE-08 does not.","fix":"Reuse a shared owned-resource census and require exactly one match before deletion and zero remaining matches after deletion.","verification":"Add a live cleanup test that creates two owned retained resources and expects LIVE-08 failure.","costIfShipped":"1 retained resource leak per duplicate-resource run","savedIfFixed":"1 duplicate-resource leak prevented per duplicate-resource run"} +{"id":4,"severity":"MEDIUM","status":"open","file":"scripts/live-required/tangle-sandbox-braid-interactive.mjs:778-784","defect":"The interactive input assertion counts raw PTY echo, not provider acceptance or response.","scenario":"The local terminal echoes input while Pi or the provider ignores it; occurrence count increases and the input check passes.","evidence":"Code waits only for occurrences(runtime.output, markers.input); no provider-bound event or response contains the marker.","fix":"Require a run-bound provider event or response for each input marker.","verification":"Add a live fault test that forces local echo without provider delivery and expects failure.","costIfShipped":"1 false input-success claim per ignored input","savedIfFixed":"1 false input-success claim prevented per ignored input"} +{"id":5,"severity":"MEDIUM","status":"open-live-boundary","file":"scripts/live-required/tangle-sandbox-braid-soak.mjs:399-454","defect":"Replay checks prove no duplicate visible delivery, not provider-side exactly-once execution.","scenario":"A provider executes one executionId twice but emits one deduplicated event stream; event IDs, cursors, and local run count remain valid, so the proof passes.","evidence":"Code validates visible event uniqueness and cursor progress; no provider execution-attempt receipt or side-effect counter exists.","fix":"Require a provider execution receipt or an idempotent side-effect counter for exactly-once claims.","verification":"Run a provider fault test that duplicates execution and verify the proof fails.","costIfShipped":"1 hidden duplicate execution per provider retry fault","savedIfFixed":"1 hidden duplicate execution detected per provider retry fault"} +{"id":6,"severity":"MEDIUM","status":"open","file":"scripts/live-required/tangle-sandbox-braid-interactive.mjs:383-389","defect":"Interactive event order is now required, but input and terminal attach causality remain live-only.","scenario":"A local TUI accepts /attach or input while the remote session ignores the operation; identity and terminal geometry still look stable.","evidence":"The script checks ordered lifecycle events and remote geometry, but input waits on PTY output and attach has no provider acknowledgement field.","fix":"Require provider-bound input acknowledgement and an attach acknowledgement tied to the same control reference.","verification":"Run the real Pi Sandbox path with injected ignored input/attach responses.","costIfShipped":"1 false interactive-success claim per ignored remote operation","savedIfFixed":"1 false interactive-success claim prevented per ignored remote operation"} +{"id":7,"severity":"MEDIUM","status":"open","file":"scripts/live-required/tangle-sandbox-braid-interactive.mjs:896-934","defect":"LIVE-08 does not collect token, cost, latency, account-usage, or resource-usage telemetry.","scenario":"The interactive run passes with model and resource identity but its usage and spend remain unknown; the receipt has no metric fields.","evidence":"Returned proof contains configuration, run, sandbox identity, and process cleanup; soak-only telemetryDisclosure and sessionSpend are absent from LIVE-08.","fix":"Reuse the existing telemetry and spend disclosure helpers, or label LIVE-08 partial until those fields are observed or explicitly unavailable.","verification":"Require a live interactive receipt with token, cost, latency, and resource metadata statuses.","costIfShipped":"1 interactive run with unreported spend per LIVE-08 run","savedIfFixed":"1 interactive spend gap closed per LIVE-08 run"} +{"id":8,"severity":"MEDIUM","status":"open","file":"scripts/live-required/tangle-sandbox-braid-soak.mjs:855-910","defect":"The soak passes requireZeroActiveResourceDelta=false for the canary and parallel attempts.","scenario":"Another account user creates or deletes resources during the cohort; account-wide active delta is shared and cannot attribute a leak to one proof.","evidence":"test/tangle-sandbox-braid-soak-script.test.mjs explicitly accepts shared account churn; exact per-attempt resource cleanup remains checked.","fix":"Keep shared deltas as non-ownership telemetry and require the final exact owned-resource census for every attempt, or run an exclusive cohort.","verification":"Run an exclusive cohort and assert active delta zero, then run a shared cohort and mark attribution unavailable.","costIfShipped":"1 leak attribution ambiguity per shared cohort","savedIfFixed":"1 leak attribution ambiguity removed per shared cohort"} +{"id":9,"severity":"MEDIUM","status":"open-live-boundary","file":"test/tangle-live07-wiring.test.mjs:129-158","defect":"The local wiring test injects a synthetic LIVE-08 receipt and does not execute runInteractiveProof.","scenario":"A regression in PTY input, attach, resize, or cloud cleanup leaves the wiring test green because the injected runner returns a fabricated passed receipt.","evidence":"node --test test/tangle-live07-wiring.test.mjs -> 5/5; the interactive runner is injected at lines 154-157.","fix":"Add one real provider-backed Pi interactive test; keep the synthetic test for dispatch contract coverage.","verification":"Run the real staging Sandbox proof with credentials and capture a passed receipt.","costIfShipped":"1 undetected LIVE-08 regression per release","savedIfFixed":"1 LIVE-08 regression detected per release"} diff --git a/.agent/critical-audit/2026-08-20T10-24-46Z/manifest.json b/.agent/critical-audit/2026-08-20T10-24-46Z/manifest.json new file mode 100644 index 0000000..c043b0c --- /dev/null +++ b/.agent/critical-audit/2026-08-20T10-24-46Z/manifest.json @@ -0,0 +1,39 @@ +{ + "skill": "critical-audit", + "scope": [ + "scripts/live-required/tangle-sandbox-braid-soak.mjs", + "scripts/live-required/tangle-sandbox-braid-interactive.mjs", + "scripts/live-required/contracts.mjs", + "scripts/live-required/headless.mjs", + "scripts/live-required/tangle-sandbox-braid-stress.mjs", + "scripts/live-required/tangle-sandbox-braid-stress-support.mjs", + "scripts/live-bridge/process.mjs", + "scripts/live-bridge/process-tree.mjs", + "scripts/live-bridge/protocol.mjs", + "scripts/packed-binary.mjs", + "scripts/live-required/configuration.mjs", + "test/tangle-live07-wiring.test.mjs", + "test/tangle-sandbox-braid-soak-script.test.mjs", + "test/tangle-sandbox-braid-stress-script.test.mjs", + "test/tangle-sandbox-braid-stress-runtime.test.mjs", + "scripts/live-bridge/process.test.mjs", + "scripts/live-required.test.mjs" + ], + "base": "3d7b79f6f4f3270821b1b2fcbd777fb411f1ded9", + "head": "7e19843217b9d1c9233fb1c5cdc93f54aa8552ff", + "workingTree": "dirty; audit includes uncommitted Braid changes", + "reviewers": ["operator", "codex-gpt-5.6-luna"], + "liveBoundary": { + "status": "unavailable", + "reason": "Sandbox endpoint/profile and protected credential environment variables were absent" + }, + "checks": [ + "node --check scripts/live-required/tangle-sandbox-braid-interactive.mjs scripts/live-required/tangle-sandbox-braid-soak.mjs scripts/live-required/contracts.mjs scripts/live-required/headless.mjs", + "pnpm exec biome check on the five changed proof files", + "node --test scripts/live-required.test.mjs test/tangle-live07-wiring.test.mjs -> 28/28", + "node --test test/tangle-sandbox-braid-soak-script.test.mjs test/tangle-sandbox-braid-stress-script.test.mjs test/tangle-sandbox-braid-stress-runtime.test.mjs scripts/live-bridge/process.test.mjs -> 41/41", + "node scripts/live-required/tangle-sandbox-braid-interactive.mjs -> unavailable, exit 2", + "node scripts/live-required/tangle-sandbox-braid-soak.mjs -> unavailable, exit 2", + "PTY process-tree smoke -> supported:true, gone:true" + ] +} diff --git a/.agent/critical-audit/2026-08-20T10-24-46Z/summary.md b/.agent/critical-audit/2026-08-20T10-24-46Z/summary.md new file mode 100644 index 0000000..9ad6058 --- /dev/null +++ b/.agent/critical-audit/2026-08-20T10-24-46Z/summary.md @@ -0,0 +1,43 @@ +# Audit: Braid cloud proof path — 3d7b79f..7e19843 — n=17 files, 8 findings + +**Verdict:** REQUEST_CHANGES — no live provider run, and LIVE-08 lacks full telemetry · 0 CRITICAL / 2 HIGH / 6 MEDIUM / 0 LOW + +**Worst:** #2 `scripts/live-bridge/process-tree.mjs:9-35` — process-group exit does not cover escaped descendants · cost if shipped 1 unsafe workspace cleanup/run + +**Next:** run with staging Sandbox credentials, then address #2, #3, #4, and #7 before calling LIVE-08 complete. + +## Scope + +| Field | Value | +|---|---| +| Files | n=17, two requested scripts, direct helpers, and proof tests | +| Base..head | `3d7b79f6f4f3270821b1b2fcbd777fb411f1ded9..7e19843217b9d1c9233fb1c5cdc93f54aa8552ff` plus dirty working tree | +| Project type | Node package with live release scripts | +| Reviewers | operator + codex `gpt-5.6-luna`, serial review | +| Not inspected | agent-dev-container and unrelated Braid production code | + +## Findings — 8 of 9, ranked + +| # | Sev | file:line | Defect | Failure scenario (input/state → wrong result) | Status | Evidence | Fix | Verification | Cost if shipped | Saved if fixed | +|---:|---|---|---|---|---|---|---|---:|---:|---:| +| 1 | HIGH | `scripts/live-required/contracts.mjs:490-501` | Status-only interactive receipts passed. | True facts plus `{status:'passed'}` → passed receipt without runtime sections. | resolved | `node --test ... -> 28/28`; new rejection test at `test/tangle-live07-wiring.test.mjs:217` | Require six structured observation sections. | 28/28 self-tests. | 1 false proof/run | 1 false proof/run | +| 2 | HIGH | `scripts/live-bridge/process-tree.mjs:9-35` | Group status misses escaped descendants. | Detached child survives parent exit → group gone, workspace cleanup runs. | open-live-boundary | Luna PTY probe: `gone:true`, `aliveAfterParentExit:true`. | Use cgroup/job/supervisor ownership. | Add detached-child test and cleanup refusal. | 1 unsafe cleanup/run | 1 unsafe cleanup prevented/run | +| 3 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-interactive.mjs:442-447` | LIVE-08 deletes one exact resource, not the complete owned set. | Second owned Sandbox remains → exact target deletion still passes. | open | One `get/delete/get`; soak has broader census. | Shared owned-resource census. | Live duplicate-resource test. | 1 leak/run | 1 leak prevented/run | +| 4 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-interactive.mjs:778-784` | Input check accepts local PTY echo. | Provider ignores input → echo count advances → input passes. | open | No provider-bound marker check. | Require provider event/response. | Ignored-input live fault test. | 1 false input pass/run | 1 false input pass prevented/run | +| 5 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-soak.mjs:399-454` | Replay does not prove hidden provider exactly-once execution. | Provider runs same execution twice but deduplicates events → proof passes. | open-live-boundary | No execution-attempt receipt. | Provider receipt or side-effect counter. | Duplicate-execution fault test. | 1 hidden duplicate/run | 1 duplicate detected/run | +| 6 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-interactive.mjs:383-389` | Attach/input causality is not provider-bound. | Local operations succeed while remote operation is ignored → stable identity looks valid. | open-live-boundary | Ordered lifecycle events now exist; attach ack/input response absent. | Require provider-bound acknowledgements. | Real Pi Sandbox fault test. | 1 false interactive pass/run | 1 false pass prevented/run | +| 7 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-interactive.mjs:896-934` | LIVE-08 omits spend and resource telemetry. | Run passes with unknown tokens/cost/latency/account usage. | open | Soak has telemetry; interactive does not. | Reuse disclosures or mark partial. | Live receipt metric-status check. | 1 spend gap/run | 1 spend gap closed/run | +| 8 | MEDIUM | `scripts/live-required/tangle-sandbox-braid-soak.mjs:855-910` | Shared cohort delta cannot attribute account churn. | Concurrent external resource activity → account delta is ambiguous. | open | Canary/cohort pass `false`; exact owned cleanup still runs. | Exclusive cohort or explicit unattributed status. | Shared plus exclusive cohort checks. | 1 ambiguous cohort | 1 ambiguity removed/cohort | + +## Assumptions & unverified + +| Assumption | Finding it would flip | Check that settles it | +|---|---:|---| +| Sandbox staging/prod is reachable with current credentials. | 2,3,4,5,6,7,8 | Run both scripts with endpoint, model, runner, and protected credential. | +| Provider event records include causal attach/input acknowledgements. | 4,6 | Inspect one live `--record-state` frame and Sandbox terminal event stream. | +| `tangle-sandbox` resource ownership is unique by control reference. | 3 | List all retained resources before and after LIVE-08. | + +## Self-gate + +9/9 passed — failed: none. +1 verdict = decision + 1 number · 2 every finding has file:line · 3 concrete failure scenario · 4 status label · 5 evidence is a pointer · 6 cost both sides · 7 fix + verification per row · 8 zero adjectives standing in for counts · 9 words ≤600 outside tables. diff --git a/.agent/pursuits/2026-08-19-orchestrate-braid-launch.md b/.agent/pursuits/2026-08-19-orchestrate-braid-launch.md new file mode 100644 index 0000000..7333a8b --- /dev/null +++ b/.agent/pursuits/2026-08-19-orchestrate-braid-launch.md @@ -0,0 +1,31 @@ +# Braid launch workflow + +## Goal + +Ship one durable interaction path across CLI Bridge and Tangle Sandbox. + +## Structure + +Three source-package tracks run in parallel. +Package publication is the only barrier because Braid must consume released artifacts. + +1. Agent SDK: finish CLI Bridge discovery, replay, request forwarding, and process-restart proof. +2. Agent Runtime: merge exact retained event identity and publish it. +3. Sandbox: finish exact process control, browser attachment, and restart proof. +4. Braid: adopt the releases, remove temporary paths, and run both real recovery flows. +5. Release: capture the terminal flow, merge, publish, and verify a clean install. + +## Coordination + +- Four existing workers cover disjoint Agent SDK, Runtime, and Sandbox files. +- The operator owns integration, commits, package publication, and production claims. +- Each source-package change receives an independent adversarial review. +- A worker failure does not stop the other package tracks. + +## Acceptance + +- Pi survives Bridge process restart and resumes from a nonzero event cursor without duplicates. +- A Tangle cloud process survives client restart and rejects stale process identities. +- Questions, permissions, plans, and worker activity use canonical typed events. +- Braid contains no provider output parser or duplicate execution path. +- A clean published install reproduces both real flows and the recorded terminal UX. diff --git a/.agent/skill-runs.jsonl b/.agent/skill-runs.jsonl index 3fa2a6d..c05baa5 100644 --- a/.agent/skill-runs.jsonl +++ b/.agent/skill-runs.jsonl @@ -59,3 +59,10 @@ {"skill":"/reconcile","ts":"2026-08-13T21:45:28Z","project":"braid-goal-latest-wt","target":"cordiverse/cordis as a Braid foundation","operatorPrompt":"","durationMin":null,"verdict":"REJECT","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/autopsy","ts":"2026-08-13T21:58:28Z","project":"braid-goal-latest-wt","target":"Sandbox isolated package smoke skipped prepack","operatorPrompt":"","durationMin":null,"verdict":"FIXED_INFRA_BUG","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null} {"skill":"/deep-clean","ts":"2026-08-15T03:11:57Z","project":"braid-goal-latest-wt","target":"Braid release branch: removed stale generated artifacts and speculative release machinery","operatorPrompt":"","durationMin":null,"verdict":"GREEN","dispatchedTo":"/converge","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/product-design-audit","ts":"2026-08-19T06:09:04Z","project":"braid-exact-interactive-20260816","target":"Braid bounded TUI route metadata, profile framing, activity header, and focused verification","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/product-design","ts":"2026-08-19T06:09:04Z","project":"braid-exact-interactive-20260816","target":"Braid bounded TUI route metadata, profile framing, and responsive proof","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/product-design-audit","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/report","ts":"2026-08-19T07:02:22Z","project":"braid-exact-interactive-20260816","target":"Braid dependency on Sandbox UI","operatorPrompt":"","durationMin":null,"verdict":"PASS","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/diagnose","ts":"2026-08-19T08:55:46Z","project":"braid-exact-interactive-20260816","target":"Braid contract/headless/PTY baseline and retained interactive failures","operatorPrompt":"","durationMin":null,"verdict":"Braid-owned failures fixed; three pinned dependency failures remain","dispatchedTo":"/verify","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/verify","ts":"2026-08-19T08:55:46Z","project":"braid-exact-interactive-20260816","target":"Braid retained/headless/native PTY verification","operatorPrompt":"","durationMin":null,"verdict":"HOLD: 761/766 full tests pass; 3 external failures remain","dispatchedTo":"upgrade the owning dependencies, then rerun contract and production native routing","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/verify","ts":"2026-08-19T08:59:19Z","project":"braid-exact-interactive-20260816","target":"Braid retained/headless/native PTY verification (final)","operatorPrompt":"","durationMin":null,"verdict":"HOLD: 762/766 full tests pass; 2 pinned dependency failures remain","dispatchedTo":"upgrade the owning dependencies, then rerun contract and production native routing","operatorOverride":null,"transcriptPath":null,"traceDir":null} +{"skill":"/critical-audit","ts":"2026-08-19T11:05:46Z","project":"braid-exact-interactive-20260816","target":"Braid durable interaction and TUI architecture n=70 files","operatorPrompt":"","durationMin":null,"verdict":"REQUEST_CHANGES","dispatchedTo":"/stop","operatorOverride":null,"transcriptPath":null,"traceDir":null} diff --git a/README.md b/README.md index 273153a..1354abf 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,13 @@ A portable [`AgentProfile`](https://github.com/tangle-network/agent-sdk/tree/mai Braid sends every turn through [`agent-runtime`](https://github.com/tangle-network/agent-runtime), then keeps the transcript, branches, approvals, activity, graph, and trace analysis together. -![Braid sending a Product engineer AgentProfile through agent-runtime, Local CLI Bridge, Claude Code, and Opus, then asking a cited question about the retained run](artifacts/demo/braid-live.gif) +![Historical Braid recording through Local CLI Bridge and Claude Code](artifacts/demo/braid-live.gif) -This recording uses a packed Braid artifact and a real `Product engineer` AgentProfile. +The checked-in recording and [capture manifest](artifacts/demo/braid-live.json) are historical Braid `0.1.3` evidence from Local CLI Bridge and Claude Code `opus`. -It routes one coding task through Local CLI Bridge to Claude Code and `opus`. +They are not the current `0.2.0` Pi release claim. -It verifies the edited workspace, then switches to a `Trace analyst` AgentProfile on `sonnet`. - -It runs `/ask` over the frozen trace and renders cited findings, model calls, tokens, cost provenance, and latency. - -The [capture manifest](artifacts/demo/braid-live.json) records the package hash, route, profile, limits, usage, latency, workspace proof, analysis evidence, and artifact hashes. +A fresh public recording requires a packed `0.2.0` Braid binary, a ready Pi backend, and measured usage from the exact run. ## Install @@ -42,15 +38,15 @@ The first-run flow selects an AgentProfile and a connection. No runner-specific Braid configuration is required. -For an offline terminal walkthrough, use the deterministic fixture. +For a live terminal walkthrough, run the local CLI Bridge capture. ```bash -braid --fixture deterministic +pnpm capture:demo:live ``` -The fixture proves rendering and state transitions only. +The capture routes a human prompt through Pi and records the measured result. -It does not prove a live runner, model, connection, inference route, or sandbox. +It requires a configured local CLI Bridge and a real provider credential. ## How it works @@ -97,6 +93,9 @@ const profile: AgentProfile = { provider: 'tangle-router', default: 'tangle-router/glm-5.2', reasoningEffort: 'high', + maxVisibleOutputTokens: 16_384, + maxReasoningTokens: 32_768, + maxTotalOutputTokens: 49_152, }, prompt: { instructions: [ @@ -111,11 +110,13 @@ const profile: AgentProfile = { A connection supplies transport and credential references. -The run binds the exact profile snapshot, selected connection, effective runner, model, reasoning effort, output limit, and execution environment before dispatch. +The run binds the exact profile snapshot, selected connection, effective runner, model, reasoning effort, three configured output limits, and execution environment before dispatch. + +Visible output, reasoning output, and total output limits are separate configured dimensions. -Reasoning effort and maximum output are separate dimensions. +Reasoning effort controls the requested thinking tier when the selected route supports it. -Reasoning effort controls the requested thinking tier when the selected route supports it, while maximum output limits emitted tokens independently. +Configured limits never stand in for measured usage. ### Example effective run receipt @@ -129,7 +130,9 @@ The following values illustrate the shape of one receipt and are not a live run | Runner | `pi` | | Model | `tangle-router/glm-5.2` | | Reasoning | `high` | -| Max output | `16,384 tokens` | +| Max visible output | `16,384 tokens` | +| Max reasoning output | `32,768 tokens` | +| Max total output | `49,152 tokens` | | Connection | `Local CLI Bridge` | | Execution location | `local workspace through CLI Bridge` | | Environment | `local process; sandbox fields not applicable` | @@ -172,7 +175,7 @@ Each analysis has its own run identity, source digest, analyst profile, model, b The standard Braid install includes `uv` for `/ask`. -On first use, `uv` downloads a managed Python 3.12 runtime and runs `agent-eval-rpc[dspy]==0.144.11` in an isolated cached environment. +On first use, `uv` downloads a managed Python 3.12 runtime and runs `agent-eval-rpc[dspy]==0.145.15` in an isolated cached environment. Set `BRAID_PYTHON` only when an operator must use a preinstalled compatible environment instead. @@ -180,7 +183,7 @@ Findings remain separate until the user explicitly sends selected findings to a ## Interactive and headless modes -Interactive mode is the full-screen terminal experience with a multiline composer, streaming transcript, activity pane, selectors, and focused overlays. +Interactive mode is the full-screen terminal experience with a multiline composer, streaming transcript, selectors, and focused overlays. Use inline mode when preserving normal terminal scrollback matters. @@ -301,13 +304,13 @@ pnpm check pnpm capture:visual ``` -`pnpm check` covers formatting, linting, types, dependency boundaries, attribution, licenses, deterministic tests, live checks, and release checks configured by the repository. +`pnpm check` covers formatting, linting, types, dependency boundaries, attribution, licenses, automated tests, live checks, and release checks configured by the repository. -`pnpm capture:visual` drives the built CLI through a pseudo-terminal and records the deterministic terminal evidence required by the verification plan. +When a ready Pi backend is configured, `pnpm capture:visual` drives the packed CLI through a pseudo-terminal and records terminal state evidence plus a live Pi and CLI Bridge product capture. -The checked-in W6 captures prove deterministic rendering and keyboard paths. +The checked-in W6 state captures prove rendering and keyboard paths. -They are not evidence of a live Pi, CLI Bridge, Tangle inference, or Tangle sandbox run. +A successful product capture is the evidence for the live Pi, CLI Bridge, inference route, and sandbox run. The [verification plan](docs/08-verification.md) defines the required live, headless, terminal, security, installation, and release evidence. diff --git a/THIRD_PARTY_LICENSES.json b/THIRD_PARTY_LICENSES.json index 49614bd..c380ad5 100644 --- a/THIRD_PARTY_LICENSES.json +++ b/THIRD_PARTY_LICENSES.json @@ -77,6 +77,14 @@ "license": "MIT", "name": "@tangle-network/sandbox" }, + { + "license": "MIT", + "name": "@types/proper-lockfile" + }, + { + "license": "MIT", + "name": "@types/retry" + }, { "license": "Apache-2.0", "name": "b4a" diff --git a/artifacts/verification/ui-regression-20260816/120x40.png b/artifacts/verification/ui-regression-20260816/120x40.png new file mode 100644 index 0000000..91da00b Binary files /dev/null and b/artifacts/verification/ui-regression-20260816/120x40.png differ diff --git a/artifacts/verification/ui-regression-20260816/80x24.png b/artifacts/verification/ui-regression-20260816/80x24.png new file mode 100644 index 0000000..31a4f21 Binary files /dev/null and b/artifacts/verification/ui-regression-20260816/80x24.png differ diff --git a/artifacts/verification/w6/120x40-plain.txt b/artifacts/verification/w6/120x40-plain.txt index a1c8793..894047a 100644 --- a/artifacts/verification/w6/120x40-plain.txt +++ b/artifacts/verification/w6/120x40-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/braid-transcript-first-wt +Braid ready — /home/drew/code/.worktrees/braid-exact-interactive-20260816 status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture @@ -6,12 +6,9 @@ appearance: none; high contrast off; reduced motion off event 2: draft.changed; status: ready for a message event 3: run.requested; status: admitted: W6 plain proof event 4: effect.upserted; status: streaming -event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-c7a112f8-ec11-44cb-beed-76fd6693a70b","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"5396aad7-ceec-411e-a001-ce0a2edf2a12","backend":"executor","status":"active","createdAt":"2026-08-12T12:37:26.903Z","updatedAt":"2026-08-12T12:37:26.903Z","metadata":"[unavailable]"}," -event 6: application.shutdown.requested; status: streaming -event 7: run.control.requested; status: cancelling: requested -event 8: run.cancel.requested; status: cancelling: requested -event 9: effect.upserted; status: cancelling -event 10: effect.upserted; status: cancelling -event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED -event 12: run.finished; status: aborted -event 13: effect.upserted; status: cancelled +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-f464b334-bc5f-406b-aed7-ac801fbd20e3","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"05bb45e5-a6c1-40e3-b260-359bc2bd56c2","backend":"executor","status":"active","createdAt":"2026-08-16T11:04:57.315Z","updatedAt":"2026-08-16T11:04:57.315Z","metadata":"[unavailable]"}," +event 6: run.usage; status: streaming +event 7: run.artifact; status: streaming: agent-turn-result +event 8: run.finished; status: completed: Fixture response through pi: W6 plain proof +event 9: effect.upserted; status: completed +event 10: application.shutdown.requested; status: completed diff --git a/artifacts/verification/w6/120x40.png b/artifacts/verification/w6/120x40.png index 2a62892..e418aa0 100644 Binary files a/artifacts/verification/w6/120x40.png and b/artifacts/verification/w6/120x40.png differ diff --git a/artifacts/verification/w6/120x40.txt b/artifacts/verification/w6/120x40.txt index 023edcd..ada10dc 100644 --- a/artifacts/verification/w6/120x40.txt +++ b/artifacts/verification/w6/120x40.txt @@ -32,9 +32,9 @@ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +› new message -› - - -profile Braid starter · pi / fixture/deterministic · deterministic fixture in 8 · out 12 · calls 1 · model 50ms +─────────────────────────────────── type / for commands · Alt+Enter newline · paste ──────────────────────────────────── +profile Braid starter · pi · local Ctrl+E details diff --git a/artifacts/verification/w6/200x60-plain.txt b/artifacts/verification/w6/200x60-plain.txt index a1c8793..894047a 100644 --- a/artifacts/verification/w6/200x60-plain.txt +++ b/artifacts/verification/w6/200x60-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/braid-transcript-first-wt +Braid ready — /home/drew/code/.worktrees/braid-exact-interactive-20260816 status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture @@ -6,12 +6,9 @@ appearance: none; high contrast off; reduced motion off event 2: draft.changed; status: ready for a message event 3: run.requested; status: admitted: W6 plain proof event 4: effect.upserted; status: streaming -event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-c7a112f8-ec11-44cb-beed-76fd6693a70b","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"5396aad7-ceec-411e-a001-ce0a2edf2a12","backend":"executor","status":"active","createdAt":"2026-08-12T12:37:26.903Z","updatedAt":"2026-08-12T12:37:26.903Z","metadata":"[unavailable]"}," -event 6: application.shutdown.requested; status: streaming -event 7: run.control.requested; status: cancelling: requested -event 8: run.cancel.requested; status: cancelling: requested -event 9: effect.upserted; status: cancelling -event 10: effect.upserted; status: cancelling -event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED -event 12: run.finished; status: aborted -event 13: effect.upserted; status: cancelled +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-f464b334-bc5f-406b-aed7-ac801fbd20e3","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"05bb45e5-a6c1-40e3-b260-359bc2bd56c2","backend":"executor","status":"active","createdAt":"2026-08-16T11:04:57.315Z","updatedAt":"2026-08-16T11:04:57.315Z","metadata":"[unavailable]"}," +event 6: run.usage; status: streaming +event 7: run.artifact; status: streaming: agent-turn-result +event 8: run.finished; status: completed: Fixture response through pi: W6 plain proof +event 9: effect.upserted; status: completed +event 10: application.shutdown.requested; status: completed diff --git a/artifacts/verification/w6/200x60.png b/artifacts/verification/w6/200x60.png index 074d7b0..0f26604 100644 Binary files a/artifacts/verification/w6/200x60.png and b/artifacts/verification/w6/200x60.png differ diff --git a/artifacts/verification/w6/200x60.txt b/artifacts/verification/w6/200x60.txt index 8c1e5aa..17a093c 100644 --- a/artifacts/verification/w6/200x60.txt +++ b/artifacts/verification/w6/200x60.txt @@ -52,9 +52,9 @@ +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +› new message -› - - -profile Braid starter · pi / fixture/deterministic · deterministic fixture in 8 · out 12 · calls 1 · model 49ms +─────────────────────────────────────────────────────────────────────────── type / for commands · Alt+Enter newline · paste ──────────────────────────────────────────────────────────────────────────── +profile Braid starter · pi · local Ctrl+E details diff --git a/artifacts/verification/w6/40x12-plain.txt b/artifacts/verification/w6/40x12-plain.txt index a1c8793..894047a 100644 --- a/artifacts/verification/w6/40x12-plain.txt +++ b/artifacts/verification/w6/40x12-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/braid-transcript-first-wt +Braid ready — /home/drew/code/.worktrees/braid-exact-interactive-20260816 status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture @@ -6,12 +6,9 @@ appearance: none; high contrast off; reduced motion off event 2: draft.changed; status: ready for a message event 3: run.requested; status: admitted: W6 plain proof event 4: effect.upserted; status: streaming -event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-c7a112f8-ec11-44cb-beed-76fd6693a70b","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"5396aad7-ceec-411e-a001-ce0a2edf2a12","backend":"executor","status":"active","createdAt":"2026-08-12T12:37:26.903Z","updatedAt":"2026-08-12T12:37:26.903Z","metadata":"[unavailable]"}," -event 6: application.shutdown.requested; status: streaming -event 7: run.control.requested; status: cancelling: requested -event 8: run.cancel.requested; status: cancelling: requested -event 9: effect.upserted; status: cancelling -event 10: effect.upserted; status: cancelling -event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED -event 12: run.finished; status: aborted -event 13: effect.upserted; status: cancelled +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-f464b334-bc5f-406b-aed7-ac801fbd20e3","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"05bb45e5-a6c1-40e3-b260-359bc2bd56c2","backend":"executor","status":"active","createdAt":"2026-08-16T11:04:57.315Z","updatedAt":"2026-08-16T11:04:57.315Z","metadata":"[unavailable]"}," +event 6: run.usage; status: streaming +event 7: run.artifact; status: streaming: agent-turn-result +event 8: run.finished; status: completed: Fixture response through pi: W6 plain proof +event 9: effect.upserted; status: completed +event 10: application.shutdown.requested; status: completed diff --git a/artifacts/verification/w6/40x12.png b/artifacts/verification/w6/40x12.png index dfe4f04..641f580 100644 Binary files a/artifacts/verification/w6/40x12.png and b/artifacts/verification/w6/40x12.png differ diff --git a/artifacts/verification/w6/40x12.txt b/artifacts/verification/w6/40x12.txt index d85051b..405a593 100644 --- a/artifacts/verification/w6/40x12.txt +++ b/artifacts/verification/w6/40x12.txt @@ -4,9 +4,9 @@ Fixture response through pi: W6 visual proof +──────────────────────────────────────── +› new message -› - - -Braid starter Ctrl+E detail + type / for commands · Alt+Enter newlin… +Braid starter diff --git a/artifacts/verification/w6/80x24-automation.gif b/artifacts/verification/w6/80x24-automation.gif index c872da9..f8ab8b1 100644 Binary files a/artifacts/verification/w6/80x24-automation.gif and b/artifacts/verification/w6/80x24-automation.gif differ diff --git a/artifacts/verification/w6/80x24-plain.txt b/artifacts/verification/w6/80x24-plain.txt index a1c8793..894047a 100644 --- a/artifacts/verification/w6/80x24-plain.txt +++ b/artifacts/verification/w6/80x24-plain.txt @@ -1,4 +1,4 @@ -Braid ready — /home/drew/code/braid-transcript-first-wt +Braid ready — /home/drew/code/.worktrees/braid-exact-interactive-20260816 status: ready for a message conversation: New conversation; branch: branch-1 profile: Braid starter; runner: pi; connection: deterministic fixture @@ -6,12 +6,9 @@ appearance: none; high contrast off; reduced motion off event 2: draft.changed; status: ready for a message event 3: run.requested; status: admitted: W6 plain proof event 4: effect.upserted; status: streaming -event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-c7a112f8-ec11-44cb-beed-76fd6693a70b","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"5396aad7-ceec-411e-a001-ce0a2edf2a12","backend":"executor","status":"active","createdAt":"2026-08-12T12:37:26.903Z","updatedAt":"2026-08-12T12:37:26.903Z","metadata":"[unavailable]"}," -event 6: application.shutdown.requested; status: streaming -event 7: run.control.requested; status: cancelling: requested -event 8: run.cancel.requested; status: cancelling: requested -event 9: effect.upserted; status: cancelling -event 10: effect.upserted; status: cancelling -event 11: run.control.acknowledged; status: cancelling: CONTROL_ACKNOWLEDGED -event 12: run.finished; status: aborted -event 13: effect.upserted; status: cancelled +event 5: run.provider.event; status: streaming: {"type":"backend_start","task":{"id":"turn-f464b334-bc5f-406b-aed7-ac801fbd20e3","intent":"W6 plain proof","metadata":{"identity":{"profileDigest":"sha256:8344a03c82d5cb9207910fe0cb05ef0757b58b8cea9a7cc82f36055694d58983","taskDigest":"sha256:f75eb08b6c6a3120e5297421baed1e5e2553899395ec5981e0fda4c9ce725300"}}},"session":{"id":"05bb45e5-a6c1-40e3-b260-359bc2bd56c2","backend":"executor","status":"active","createdAt":"2026-08-16T11:04:57.315Z","updatedAt":"2026-08-16T11:04:57.315Z","metadata":"[unavailable]"}," +event 6: run.usage; status: streaming +event 7: run.artifact; status: streaming: agent-turn-result +event 8: run.finished; status: completed: Fixture response through pi: W6 plain proof +event 9: effect.upserted; status: completed +event 10: application.shutdown.requested; status: completed diff --git a/artifacts/verification/w6/80x24-transcript-keyboard.gif b/artifacts/verification/w6/80x24-transcript-keyboard.gif index f0b369e..3c3300d 100644 Binary files a/artifacts/verification/w6/80x24-transcript-keyboard.gif and b/artifacts/verification/w6/80x24-transcript-keyboard.gif differ diff --git a/artifacts/verification/w6/80x24.png b/artifacts/verification/w6/80x24.png index 40b3862..abc5e8b 100644 Binary files a/artifacts/verification/w6/80x24.png and b/artifacts/verification/w6/80x24.png differ diff --git a/artifacts/verification/w6/80x24.txt b/artifacts/verification/w6/80x24.txt index 4adc54a..09acd77 100644 --- a/artifacts/verification/w6/80x24.txt +++ b/artifacts/verification/w6/80x24.txt @@ -16,9 +16,9 @@ +──────────────────────────────────────────────────────────────────────────────── +› new message -› - - -profile Braid starter · pi / deterministic Ctrl+E detail +─────────────── type / for commands · Alt+Enter newline · paste ──────────────── +profile Braid starter · pi · via local Ctrl+E details diff --git a/artifacts/verification/w6/braid.gif b/artifacts/verification/w6/braid.gif deleted file mode 100644 index f2f9ccd..0000000 Binary files a/artifacts/verification/w6/braid.gif and /dev/null differ diff --git a/artifacts/verification/w6/braid.png b/artifacts/verification/w6/braid.png deleted file mode 100644 index 542e470..0000000 Binary files a/artifacts/verification/w6/braid.png and /dev/null differ diff --git a/artifacts/verification/w6/braid.txt b/artifacts/verification/w6/braid.txt deleted file mode 100644 index 53ca971..0000000 --- a/artifacts/verification/w6/braid.txt +++ /dev/null @@ -1,18 +0,0 @@ - › Which coding agent will handle my next task, and where will it run? - - · run result · Ctrl+E details - Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its - instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. - - - - - - - - - -› - - -profile Release engineer · pi / openai-codex/gpt-5.6-luna · Local CLI Bridge · high in 1.3k · out 96 · ~$0.0026 diff --git a/artifacts/verification/w6/capture-manifest.json b/artifacts/verification/w6/capture-manifest.json index 9cb4e40..c86cf5e 100644 --- a/artifacts/verification/w6/capture-manifest.json +++ b/artifacts/verification/w6/capture-manifest.json @@ -1,17 +1,18 @@ { "schemaVersion": 3, - "generatedAt": "2026-08-12T12:38:14.171Z", + "generatedAt": "2026-08-16T11:05:49.322Z", "command": "pnpm capture:visual", "binary": "clean npm install from generated tarball", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7", - "fixture": "deterministic", + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1", + "stateFixture": "deterministic", + "liveDemoCommand": "pnpm capture:demo:live", "terminal": "node-pty/xterm-256color", "node": "v24.13.0", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -38,22 +39,43 @@ "80x24-transcript-keyboard.gif" ] }, - "productDemo": { - "steps": [ - "Inspect AgentProfile", - "Run through Local CLI Bridge", - "Inspect live work and the retained run", - "Analyze the frozen trace with /ask", - "Review the analysis list and details together" - ], - "artifacts": [ - "raw/braid-demo.cast", - "braid.txt", - "braid.png", - "braid.gif" - ] - }, "states": [ + { + "name": "command-palette-40", + "columns": 40, + "rows": 12, + "artifacts": { + "semantic-state": "states/command-palette-40.json", + "plain-frame": "states/command-palette-40.txt", + "asciicast": "raw/command-palette-40.cast", + "ansi": "states/command-palette-40.ansi", + "png": "states/command-palette-40.png" + } + }, + { + "name": "command-palette-80", + "columns": 80, + "rows": 24, + "artifacts": { + "semantic-state": "states/command-palette-80.json", + "plain-frame": "states/command-palette-80.txt", + "asciicast": "raw/command-palette-80.cast", + "ansi": "states/command-palette-80.ansi", + "png": "states/command-palette-80.png" + } + }, + { + "name": "command-palette-120", + "columns": 120, + "rows": 24, + "artifacts": { + "semantic-state": "states/command-palette-120.json", + "plain-frame": "states/command-palette-120.txt", + "asciicast": "raw/command-palette-120.cast", + "ansi": "states/command-palette-120.ansi", + "png": "states/command-palette-120.png" + } + }, { "name": "empty", "columns": 80, @@ -190,91 +212,211 @@ "artifacts": [ { "path": "40x12.txt", - "sha256": "06bd2cb4454d314c0fba112343013f35ecb532d7eae36eaf0f81bc5919c7bfa7", + "sha256": "c6a6ebafdf8ab2cab97231bfa0cd20393e97cda253f5e2bb4486d1a2aa5e0841", "kind": "terminal-frame", "columns": 40, "rows": 12 }, { "path": "40x12-plain.txt", - "sha256": "079768ad779ecd50135cdbbc133aeee9b6281ddfffbb22af4e0178bfe41dc615", + "sha256": "b4eba68d5db2676629a7d6004f556f308eb3c6617e3b60c38e6526c3f9f6de37", "kind": "plain-frame", "columns": 40, "rows": 12 }, { "path": "40x12.png", - "sha256": "448563badac78d2c989ede14d92ebad7393f95b5874a766d915ec76c62492780", + "sha256": "34b7dd57b9553776c55a6ed3436a33d4244a6151ca961890ebc9d563611b3b92", "kind": "png", "columns": 40, "rows": 12 }, { "path": "80x24.txt", - "sha256": "9b17cc3d0785706ed07325649a553637adc8ffafc5642ebab2a22197126a99c5", + "sha256": "d4b59289ce1b65a414b2ec51bf7594f83edfbcc0ad998121dc464c06bdb3bad5", "kind": "terminal-frame", "columns": 80, "rows": 24 }, { "path": "80x24-plain.txt", - "sha256": "079768ad779ecd50135cdbbc133aeee9b6281ddfffbb22af4e0178bfe41dc615", + "sha256": "b4eba68d5db2676629a7d6004f556f308eb3c6617e3b60c38e6526c3f9f6de37", "kind": "plain-frame", "columns": 80, "rows": 24 }, { "path": "80x24.png", - "sha256": "36ec2775eff6d256e107c4f194ea65aeb59e43187ce17c37c27e623bf7ab7c92", + "sha256": "8b07e737fd294887162f6ae0a218a8fb255f6221c2f01d6871cf6180dcdeaa9d", "kind": "png", "columns": 80, "rows": 24 }, { "path": "120x40.txt", - "sha256": "1a52a383a1fd94baae24f061f064c12dc2db5c1f557ba16deba89eb25832815a", + "sha256": "894fa2870be18d8f92ca2a5612f4825ffab0e893d5ead743b97d79ae13226688", "kind": "terminal-frame", "columns": 120, "rows": 40 }, { "path": "120x40-plain.txt", - "sha256": "079768ad779ecd50135cdbbc133aeee9b6281ddfffbb22af4e0178bfe41dc615", + "sha256": "b4eba68d5db2676629a7d6004f556f308eb3c6617e3b60c38e6526c3f9f6de37", "kind": "plain-frame", "columns": 120, "rows": 40 }, { "path": "120x40.png", - "sha256": "ae1db9182dae754026070d8a924ccab59bd0cac44a58f4ba68046206f1460beb", + "sha256": "d2cb656a14f115efc5efe57bea8412561f43100266a297397cfe9ebcaae30ec4", "kind": "png", "columns": 120, "rows": 40 }, { "path": "200x60.txt", - "sha256": "6b967e1a525d2f1f5f4d1507744bc2c4969b8d16decda9e31e821658618be57e", + "sha256": "b9e8a5c5e75f5059327e2fcdb22c746e2ddcf0c1e54a5e66745245b3f78fa0e6", "kind": "terminal-frame", "columns": 200, "rows": 60 }, { "path": "200x60-plain.txt", - "sha256": "079768ad779ecd50135cdbbc133aeee9b6281ddfffbb22af4e0178bfe41dc615", + "sha256": "b4eba68d5db2676629a7d6004f556f308eb3c6617e3b60c38e6526c3f9f6de37", "kind": "plain-frame", "columns": 200, "rows": 60 }, { "path": "200x60.png", - "sha256": "9e5fa392f597a70fcc527da54cae93a945fbcb998867fbdc5708652e940021bc", + "sha256": "e4ca0f0f779b4d98f247d48e0cba901f3c9086bfb2ed1dd0e311226999f5d203", "kind": "png", "columns": 200, "rows": 60 }, + { + "path": "states/command-palette-40.json", + "sha256": "5c27b2bd2250517fbe6c2fc421901501f30eb92f6630e2e44d63b899a5b8ac53", + "kind": "semantic-state", + "state": "command-palette-40", + "columns": 40, + "rows": 12 + }, + { + "path": "states/command-palette-40.txt", + "sha256": "602384fa133295eed00b640cb694a874b45ed14eb7e3ee6a784734a4f3c8fce9", + "kind": "plain-frame", + "state": "command-palette-40", + "columns": 40, + "rows": 12 + }, + { + "path": "raw/command-palette-40.cast", + "sha256": "74813c066cfffadfa226c084ecf40d2486afc538e8b252c5eb62af84b394c23e", + "kind": "asciicast", + "state": "command-palette-40", + "columns": 40, + "rows": 12 + }, + { + "path": "states/command-palette-40.ansi", + "sha256": "f2d05698de52c9657177427374a713441d384b541744fb47f131cf08a6892d59", + "kind": "ansi", + "state": "command-palette-40", + "columns": 40, + "rows": 12 + }, + { + "path": "states/command-palette-40.png", + "sha256": "c309e1dcdcd0edca774cdcdc0dcd7d0b350b6711df057a0149b8bff7afac5895", + "kind": "png", + "state": "command-palette-40", + "columns": 40, + "rows": 12 + }, + { + "path": "states/command-palette-80.json", + "sha256": "ccdcf1b381de74a8fb493314661a3903083c59c00effd8f4beefc1d9e501e459", + "kind": "semantic-state", + "state": "command-palette-80", + "columns": 80, + "rows": 24 + }, + { + "path": "states/command-palette-80.txt", + "sha256": "071efa2fab24affd8ad916f750e1663dee47a7d084b1109b1beaace3fea0aac9", + "kind": "plain-frame", + "state": "command-palette-80", + "columns": 80, + "rows": 24 + }, + { + "path": "raw/command-palette-80.cast", + "sha256": "90dba750a60144776b3e0a9c21e0f637adffe7676028382a811266733e4b2c5b", + "kind": "asciicast", + "state": "command-palette-80", + "columns": 80, + "rows": 24 + }, + { + "path": "states/command-palette-80.ansi", + "sha256": "d26c61c53da5a44072602e3588dd46b9388c35da18618ee4759c20e0921cccc1", + "kind": "ansi", + "state": "command-palette-80", + "columns": 80, + "rows": 24 + }, + { + "path": "states/command-palette-80.png", + "sha256": "1271f15c60ceb8ec89cdae9c62978cc584696daefb55eeb9e93eea24f8987ef2", + "kind": "png", + "state": "command-palette-80", + "columns": 80, + "rows": 24 + }, + { + "path": "states/command-palette-120.json", + "sha256": "b8a15b79b21d7c0c4d6217a1b44e2b502516e48048f05b8a6a9aef59d1b57448", + "kind": "semantic-state", + "state": "command-palette-120", + "columns": 120, + "rows": 24 + }, + { + "path": "states/command-palette-120.txt", + "sha256": "5cf0994eafd3560f613040a745867cc99cb43a28ceb42e1c9d1a45f568d62655", + "kind": "plain-frame", + "state": "command-palette-120", + "columns": 120, + "rows": 24 + }, + { + "path": "raw/command-palette-120.cast", + "sha256": "a8900525607d24ef8eae6e76fdf3a80e79b258e2491d5596f515ef6625c12f87", + "kind": "asciicast", + "state": "command-palette-120", + "columns": 120, + "rows": 24 + }, + { + "path": "states/command-palette-120.ansi", + "sha256": "a8daac8e89b2e3485574aae55b83b3a8c9caad0336e4de0fbced1ede57adaf0b", + "kind": "ansi", + "state": "command-palette-120", + "columns": 120, + "rows": 24 + }, + { + "path": "states/command-palette-120.png", + "sha256": "11a66bf2fc2137005b19dcbf2a371abaecfc1dd736617ee9c47321daa6881984", + "kind": "png", + "state": "command-palette-120", + "columns": 120, + "rows": 24 + }, { "path": "states/empty.json", - "sha256": "46ee54a2d8a5c933eff1768c7b57aa7f7b93efdcfbd55aeaf435927132f2644e", + "sha256": "cb4e42b58931992624f32f601210c61ac26b9bf5fe74200b6cc747d23f61d8f0", "kind": "semantic-state", "state": "empty", "columns": 80, @@ -282,7 +424,7 @@ }, { "path": "states/empty.txt", - "sha256": "42a217605b309c5c5ac02531d3b86119a203af7f292ab27f320a967c615a865a", + "sha256": "08ff2f6bf30820c2c1e3046b2ebf08b2184464083f876ee75321f3f94b57041f", "kind": "plain-frame", "state": "empty", "columns": 80, @@ -290,7 +432,7 @@ }, { "path": "raw/empty.cast", - "sha256": "330fd536c7f9edd639b9b6828913f3d49c33509d871141b5652d8c80eac444a8", + "sha256": "89171ef20fb8012225e74af1b7e7e4b33c44acfecefb648f73bd98a839775165", "kind": "asciicast", "state": "empty", "columns": 80, @@ -298,7 +440,7 @@ }, { "path": "states/empty.ansi", - "sha256": "2de36ed50acbd770bf7217f43386f32f573d3967c6df77d5bc5db931a030a1b9", + "sha256": "9bc7c615c17e48643071466dfe4c3da3fed94a908e2e901a4be24f484da647c7", "kind": "ansi", "state": "empty", "columns": 80, @@ -306,7 +448,7 @@ }, { "path": "states/empty.png", - "sha256": "e276855bcab549b345a2bd412464a49bfafe3a53ca6fb23d934b5286ec6b0316", + "sha256": "ca2ae68ed2e75b5c2f9db65f60f4e0cea048ce146d87b74287069729576d3dfd", "kind": "png", "state": "empty", "columns": 80, @@ -314,7 +456,7 @@ }, { "path": "states/active-streaming.json", - "sha256": "0e178e31768a1de7f89794767413d1bc161351ffd5207d6476bbec8ffe7c9393", + "sha256": "5d517f31a384c6c60fe76afc7c221ef34d597b1fa8e8369285b97583ad120306", "kind": "semantic-state", "state": "active-streaming", "columns": 80, @@ -322,7 +464,7 @@ }, { "path": "states/active-streaming.txt", - "sha256": "fca88cebc11ef71b95b55deeabc3ffa29551f4bb23a9756eb4739ee3b3618de0", + "sha256": "24aa14b3ba64753421a08d51382ace0829a893912ec6774d6792cac369d11e0d", "kind": "plain-frame", "state": "active-streaming", "columns": 80, @@ -330,7 +472,7 @@ }, { "path": "raw/active-streaming.cast", - "sha256": "95032f9ff36f0349ab3beaeda0aa17dd403587162c4cb8388e07feb07c9f0910", + "sha256": "1a5b49eb7e6ea678b8c773e6655dd9e9e8ba610a4569b482204d67aaff0fc6c4", "kind": "asciicast", "state": "active-streaming", "columns": 80, @@ -338,7 +480,7 @@ }, { "path": "states/active-streaming.ansi", - "sha256": "c6530f7b36e87ee3737df4ff3df59d6551dc6711c5eda7ec54eb3efd62d35a0a", + "sha256": "bc85b2a0a8d4a819d56ce517831de708f16608c1679f52b331453c733062b8c1", "kind": "ansi", "state": "active-streaming", "columns": 80, @@ -346,7 +488,7 @@ }, { "path": "states/active-streaming.png", - "sha256": "4639ab1c23b7b6bc526cb51bf5b92de0eb621ea1f9f802e0fdf63cd39c9e0ce3", + "sha256": "4caf2c408d06ebd44f24a9fe3fd06f16d6bcb201ef6eeee4a8c9b5fb53a89dd9", "kind": "png", "state": "active-streaming", "columns": 80, @@ -354,7 +496,7 @@ }, { "path": "states/interaction.json", - "sha256": "e20dfbbbe4595799e7daf11268e2027b7f69d4683317077ba3b606eff5df5739", + "sha256": "5c580d158ad16f57ece76bc277f5e8da436c54e71f71be5c8c5649d1994bfbf3", "kind": "semantic-state", "state": "interaction", "columns": 80, @@ -362,7 +504,7 @@ }, { "path": "states/interaction.txt", - "sha256": "b3834e354b37526a8c01dad4a321ba71569a3577b6a9b740c3ca220b0ddc337c", + "sha256": "1fbd4f8c2dff1d98946ffd2ad8c8f56695442097db1c06a56b7f748e89bf3c38", "kind": "plain-frame", "state": "interaction", "columns": 80, @@ -370,7 +512,7 @@ }, { "path": "raw/interaction.cast", - "sha256": "b56bc79da2c5e504648a47963f4e41cabb718e5a5817510771610ba1efb5fdcf", + "sha256": "c6ee4036e3b085b7f645c481726e5953a97c6648689db02b55422d80e4ea5ade", "kind": "asciicast", "state": "interaction", "columns": 80, @@ -378,7 +520,7 @@ }, { "path": "states/interaction.ansi", - "sha256": "03c62fde015d3d5e12886d9cace2750d244f1a9f625b4d7170c8a865f4d393e5", + "sha256": "d978d6566b75e14b7d4581cb9f9f42396d452efb1341891621ffd68446cfca9d", "kind": "ansi", "state": "interaction", "columns": 80, @@ -386,7 +528,7 @@ }, { "path": "states/interaction.png", - "sha256": "be5ff9e21e002530cb78fdbba96830056b703b5d739e14d7f98889d3f88fd556", + "sha256": "0f898e22a9e713e1efdb9b265568820c22cf218b4ab400bae67040a119f26efc", "kind": "png", "state": "interaction", "columns": 80, @@ -394,7 +536,7 @@ }, { "path": "states/automation.json", - "sha256": "2ae692ab250879c46d1308f854d12d682d8b16b13a69b15e42a3490dc4fbbde7", + "sha256": "224622e910643bc235d06e58297575875520e00acf6c5066fd0f18250b3c38ec", "kind": "semantic-state", "state": "automation", "columns": 80, @@ -402,7 +544,7 @@ }, { "path": "states/automation.txt", - "sha256": "82d0afcc23d1988502a1b580d44db01a8dd0c9c5f9d16dcca113d31148470f10", + "sha256": "2507d52854ac0c0fe5ee5c39fa5aaf7a472030b46732b15a6886f76e23b6cea3", "kind": "plain-frame", "state": "automation", "columns": 80, @@ -410,7 +552,7 @@ }, { "path": "raw/automation.cast", - "sha256": "65f4519ecdb0ba8809eae6860da72427b595fcfcf5cd7301ea051b60d39a745d", + "sha256": "740c3166a5396a4802cc79a96773dfbbde8c224636f0d7ecc5312084c22f87b0", "kind": "asciicast", "state": "automation", "columns": 80, @@ -418,7 +560,7 @@ }, { "path": "states/automation.ansi", - "sha256": "fc25c7b836b7a6fcd4e6e33c8e609c8bd5e0228e41861beaae4df5628925f949", + "sha256": "daaa5df4c97b17ba28b6eacbf9b2df647f991357d246091fefc46f4a056209bd", "kind": "ansi", "state": "automation", "columns": 80, @@ -426,7 +568,7 @@ }, { "path": "states/automation.png", - "sha256": "f97a1184b127ddca1620be4c4da9a8823f5f9ddd362fc6d99ec5c97fb7403c88", + "sha256": "04cf4e658eb59c8a4685b10045fb4c42b046576d4e5af0fcd8e24a3d1211518d", "kind": "png", "state": "automation", "columns": 80, @@ -434,7 +576,7 @@ }, { "path": "states/fork-preview.json", - "sha256": "b78c3d8548d6626434774860499edd7c7876f8b108057ab13dceb6921b3bb3d7", + "sha256": "b0b022cbf9238b93b032dff99bcd1bba9cd38b3747d5801c951be405d3533cda", "kind": "semantic-state", "state": "fork-preview", "columns": 80, @@ -450,7 +592,7 @@ }, { "path": "raw/fork-preview.cast", - "sha256": "c7645af81c3839e219613200ed1434737feb08611f78efeb0b599d65a5e50b94", + "sha256": "97827f0a57786bf917f31d0d6f3f036a867fd8b6d31119c2e12378c1c66eee1e", "kind": "asciicast", "state": "fork-preview", "columns": 80, @@ -458,7 +600,7 @@ }, { "path": "states/fork-preview.ansi", - "sha256": "6e69ec241a014064a1dec7248e772142ec72deb0f3b1c7f7cc57b9211c62bf9f", + "sha256": "7d6560dc79aaa38350cb61f61ea4e9005e4866b1a41148312df76237643df36d", "kind": "ansi", "state": "fork-preview", "columns": 80, @@ -466,7 +608,7 @@ }, { "path": "states/fork-preview.png", - "sha256": "1e3563f55e99f06097e964d1ff8c353ce52efd3d816c9551e7c125cf0e678ccb", + "sha256": "15e39559a7774e5b44b6c19ea3bfab5566ff90bc5161207b3436bdb7e74ed6c6", "kind": "png", "state": "fork-preview", "columns": 80, @@ -474,7 +616,7 @@ }, { "path": "states/graph-or-analysis.json", - "sha256": "280dc31c5894850302479ed25930948d5ed52fb8fbd8a8f42aa49144661a15fb", + "sha256": "13d6827d96d105a52f8e609650a1e883c8ac6b258c66f2f1aab89fd97bd4c652", "kind": "semantic-state", "state": "graph-or-analysis", "columns": 80, @@ -482,7 +624,7 @@ }, { "path": "states/graph-or-analysis.txt", - "sha256": "de00305382582cdc1ed849578990abc3fdeae1fc388bc7322c7d656370cf3543", + "sha256": "c84d480eda3001990287551cc063b7fbef7622f5f10559ead4b96c055112ac50", "kind": "plain-frame", "state": "graph-or-analysis", "columns": 80, @@ -490,7 +632,7 @@ }, { "path": "raw/graph-or-analysis.cast", - "sha256": "79aaf455381f031749dee0e59fc71809c33c6a29fadef8ae767fd2d3bd5eed0f", + "sha256": "43c1968dbc3d5ce4a909a816677c024c38fd10af3b48487c31f30729d011aae7", "kind": "asciicast", "state": "graph-or-analysis", "columns": 80, @@ -498,7 +640,7 @@ }, { "path": "states/graph-or-analysis.ansi", - "sha256": "6881a55457e95a7dbacd7526600b54883f116d1d74f021b66b61babf3510d04a", + "sha256": "0453b411c8ba4421e88fe8912a2251b9b53ee674379c10316bb750ea5cc91beb", "kind": "ansi", "state": "graph-or-analysis", "columns": 80, @@ -506,7 +648,7 @@ }, { "path": "states/graph-or-analysis.png", - "sha256": "ed849a9a6f02b4a931dbd6b20b735fd3f341e8b4f851bf362b574146720a049a", + "sha256": "228155d434335121ada906c4da860d7c0d81daa78a605215a59ae20a6b0674f2", "kind": "png", "state": "graph-or-analysis", "columns": 80, @@ -514,7 +656,7 @@ }, { "path": "states/analysis.json", - "sha256": "994b96517d6981f69d8ca61c90378fed9efaaead6ea2c15f7e889e0012aaed0e", + "sha256": "bebe69c66f8640f4c0d935c558aaf574c5b1c738ccf4aa2fb1502c3ed8a08fde", "kind": "semantic-state", "state": "analysis", "columns": 80, @@ -522,7 +664,7 @@ }, { "path": "states/analysis.txt", - "sha256": "a20c35b119f09abe543129e363fda55cc9063c45480536c4d0681dd7e21bc0ea", + "sha256": "2ec459b6402f320d1b9f1991e3e9572216b3d207db8799e274dfbf56d0d693ea", "kind": "plain-frame", "state": "analysis", "columns": 80, @@ -530,7 +672,7 @@ }, { "path": "raw/analysis.cast", - "sha256": "f5181342eeb963b7bab41884918361ca0df2ce005f2ba4a0c288c3217969e0ed", + "sha256": "40380b70ef12a5cd9a2a4b4df08c1298c7c17bf8af5314b112417decc46c5efb", "kind": "asciicast", "state": "analysis", "columns": 80, @@ -538,7 +680,7 @@ }, { "path": "states/analysis.ansi", - "sha256": "5e3ced49f5b91590b308bdb45696c7b5c33f3ff82483182d7e3666448cdbd89b", + "sha256": "a043cf95e099db44b6daa782b9683c3933e66e309391bcf4ede5637c98e9fa1e", "kind": "ansi", "state": "analysis", "columns": 80, @@ -546,7 +688,7 @@ }, { "path": "states/analysis.png", - "sha256": "ca4f2c9e206f35006b98057527d15bd3edc0b98a65c8a2ffa7360f51f69e8cd4", + "sha256": "b0d77c88cd27180fe2f9c2a3b82f8c4f6910b402e6066445620735be8fc189d9", "kind": "png", "state": "analysis", "columns": 80, @@ -554,7 +696,7 @@ }, { "path": "states/comparison.json", - "sha256": "14d4223261bbf219177ec65cde2a3e10aacc397fbe4a1332e47581934c664460", + "sha256": "043e1fc3b60258b57a6357f9414fdf710a753532079ed8bfa1aee60ae0c998f7", "kind": "semantic-state", "state": "comparison", "columns": 80, @@ -562,7 +704,7 @@ }, { "path": "states/comparison.txt", - "sha256": "bf5ce0eb0f5a60fc0f953cb58ee6bff0f04e3309c224896a16b5ac8a42110197", + "sha256": "b03447b3bf3ac214b5f0bc739dc50a01f01b5deba258b501e9fc83f525db8a78", "kind": "plain-frame", "state": "comparison", "columns": 80, @@ -570,7 +712,7 @@ }, { "path": "raw/comparison.cast", - "sha256": "338a18f2fc67cddefb1c01151b32ca10a28091ac710258fc73e767591a17043e", + "sha256": "b999ba6a713dd400caf1f8628b668a67b4c3102d710fd7ca02b339ddaf7e37c0", "kind": "asciicast", "state": "comparison", "columns": 80, @@ -578,7 +720,7 @@ }, { "path": "states/comparison.ansi", - "sha256": "30b05a8bdf89cd5bf2f4acae27363dea64dc575667ec749f70d71c78421a8b58", + "sha256": "07da7cf3d6e160f36fd85f725760111c070fc9234101716c9cb3135ff6d0b799", "kind": "ansi", "state": "comparison", "columns": 80, @@ -586,7 +728,7 @@ }, { "path": "states/comparison.png", - "sha256": "19ec095185e2936f728727096aa00c17d5f8c7239e79e0493f7855c71ea8114a", + "sha256": "69d9a11db9864e7f0f2cfa31fbedbe2e1c326c7708ce8ad2ee6798b077244ec1", "kind": "png", "state": "comparison", "columns": 80, @@ -594,7 +736,7 @@ }, { "path": "states/profile.json", - "sha256": "f47dbe4ccd7178a4c76295ad5247b0f1fcfddb4b9c8a388e771843e88979d06d", + "sha256": "7c8b422ab3cb618bc6aca2a9455d5737f66d1395812d35cafaa984cd7622e699", "kind": "semantic-state", "state": "profile", "columns": 80, @@ -602,7 +744,7 @@ }, { "path": "states/profile.txt", - "sha256": "6a80d3fb1340f87de3eadd69b63ea7728699602860fa6c3d4848372053b9ec29", + "sha256": "25d741a6470f149651a6c7743dd4570a33a5be501678170a23385a4b1f005f3f", "kind": "plain-frame", "state": "profile", "columns": 80, @@ -610,7 +752,7 @@ }, { "path": "raw/profile.cast", - "sha256": "2cbce115bbd07064ec1f60de4fefada320f8a4fd7fa9139731b8cfcf60c4158f", + "sha256": "216e33d0b15d7eacb809065c3ba4f1a21c6d80026cc085e24e55c8a2ad91803c", "kind": "asciicast", "state": "profile", "columns": 80, @@ -618,7 +760,7 @@ }, { "path": "states/profile.ansi", - "sha256": "f0d3f1dd19e1cb79542d321953b2655a9790a436ddee393439363acc047d5ad7", + "sha256": "8246826589ec2ac40952bb75b9e18d2ea56b7b08d3390127843e96bcdfc086cc", "kind": "ansi", "state": "profile", "columns": 80, @@ -626,7 +768,7 @@ }, { "path": "states/profile.png", - "sha256": "baa74d63563fc18d26678e4baccb306e465a1f45738a908ebffab09b059aa1bd", + "sha256": "c3061c68ab192a4518ecc4ae92b385ee5adc791178aaefe0b15d2511562ac254", "kind": "png", "state": "profile", "columns": 80, @@ -634,7 +776,7 @@ }, { "path": "states/narrow.json", - "sha256": "5c1d195eeb9f278ff7cada9bf8288f7dca9d1a7ff44a4a2580ddd7a20db8e617", + "sha256": "6b8ed81e9a68a0fa1af5102550e9f70f71d94a3593a6eb5427b0d3fa282b664e", "kind": "semantic-state", "state": "narrow", "columns": 40, @@ -642,7 +784,7 @@ }, { "path": "states/narrow.txt", - "sha256": "e01838efb7f91c10849b39be7e9b5283549d7fe5b058bf7a251effdfabfa2117", + "sha256": "df26361f3056c15bd93f3a77bd29606b04d52f1294ba2a4d9d14ed75eab558f5", "kind": "plain-frame", "state": "narrow", "columns": 40, @@ -650,7 +792,7 @@ }, { "path": "raw/narrow.cast", - "sha256": "5e085dd96c7218704656478dd72e83c40fd6e231f5a3f9d9029ee3cfd0c449b4", + "sha256": "44dd280999eeaa2c21dfa50fbfdcfd267e38ce0da7c4d0759f92a0a7d472183b", "kind": "asciicast", "state": "narrow", "columns": 40, @@ -658,7 +800,7 @@ }, { "path": "states/narrow.ansi", - "sha256": "5a2f5d8635f98b399247f58de032840146d7dab7c2ec70f265d0e94b6ffe99ed", + "sha256": "1361c7b3b857d207c9bbb3f8b4be705d97eabaf2c1df022a33eb9403c60f34b5", "kind": "ansi", "state": "narrow", "columns": 40, @@ -666,7 +808,7 @@ }, { "path": "states/narrow.png", - "sha256": "2f121a3d785bb6dbca7fa79bc7c071cbd28f5151cc569e3450b0bc8f91179d55", + "sha256": "a9cd6b6003be8034dfbd1438924c7c4513d684ceff97954e983c96bb9f9ff032", "kind": "png", "state": "narrow", "columns": 40, @@ -674,7 +816,7 @@ }, { "path": "states/failure-or-reconnect.json", - "sha256": "5e36663029014857ea1c9895f743d484347e2561bb4cccb2dd8c59424a35e843", + "sha256": "a8db7dfcc25e3d4d45e4a06f47924ff19d67b7c4e24f245ceefea68b5fd372c7", "kind": "semantic-state", "state": "failure-or-reconnect", "columns": 80, @@ -682,7 +824,7 @@ }, { "path": "states/failure-or-reconnect.txt", - "sha256": "649a5db60a906d5ed28b556dbc6231c36d11a3ae8e89fa473cdce4c392c4e447", + "sha256": "b995b865a83e8ae1df59cb81404694422c14b201fae4cec1a33d9347a8c26166", "kind": "plain-frame", "state": "failure-or-reconnect", "columns": 80, @@ -690,7 +832,7 @@ }, { "path": "raw/failure-or-reconnect.cast", - "sha256": "bed225eb46cd87158e6987db8e50c2554cdd6f66dd423b609521f7c35541a6f3", + "sha256": "a8e4a4cf1e09de874f86a739f72fd0e63f19cdd24125eed4b41bb24876c18389", "kind": "asciicast", "state": "failure-or-reconnect", "columns": 80, @@ -698,7 +840,7 @@ }, { "path": "states/failure-or-reconnect.ansi", - "sha256": "0174698483fc5254eef1ff90bbc187ab259e448dc24ddef72e26cf4a6c0fa86c", + "sha256": "a4616bcda3d80595bbd0d3630059e1fa4a4df23f9edc6c7076aff5785f62bd7f", "kind": "ansi", "state": "failure-or-reconnect", "columns": 80, @@ -706,7 +848,7 @@ }, { "path": "states/failure-or-reconnect.png", - "sha256": "0c6976145f9bc93b884b9f05d13d8048852f235b6cc533986d99129b8da53f5a", + "sha256": "1437df31f7e0d011d846e01cf67012c5466b2b4dbfd2267a06377aba9ce5b30a", "kind": "png", "state": "failure-or-reconnect", "columns": 80, @@ -714,57 +856,29 @@ }, { "path": "80x24-flow.gif", - "sha256": "c3719c1f9c30651e7a5ddae10b39601f1953a6eee75f589ecbd83ebdbbee1532", + "sha256": "2a2a3cb57e17d163c27ff11e00af9b48e64ec6c49589a25758f466e4d7274dd7", "kind": "flow", "columns": 80, "rows": 24 }, { "path": "80x24-automation.gif", - "sha256": "1353469379fa0e744353f51fbe1424dec50ef78bd9092ebefec55e67fab04383", + "sha256": "6453b83a25e3d44f28a436209282ed00119334a72d6e2c127b5aa02e97d35e75", "kind": "automation-flow", "state": "automation", "columns": 80, "rows": 24 }, - { - "path": "raw/braid-demo.cast", - "sha256": "1151b0fe2e5148d6531567d89920efcb01958207a67b162e80f4b48d2d8b7b53", - "kind": "product-asciicast", - "columns": 120, - "rows": 18 - }, - { - "path": "braid.txt", - "sha256": "9da6cead048673c48402c4eb40dc9696d39c7bdcb1a2103b755098209f00f53e", - "kind": "product-frame", - "columns": 120, - "rows": 18 - }, - { - "path": "braid.png", - "sha256": "433912d21bbcc26edf7ebf5d84d66aab161e208f0ac0881cc9150d2f710a3552", - "kind": "product-png", - "columns": 120, - "rows": 18 - }, - { - "path": "braid.gif", - "sha256": "c29b2c06c59eea7c9d9cbef7ccc01bb1e0a7a7bdc6e183a0a83b42a86d4f2cdf", - "kind": "product-flow", - "columns": 120, - "rows": 18 - }, { "path": "raw/transcript-keyboard.cast", - "sha256": "81afea3985d5c1589ea675a95eb83a6f6fd4b8f9221206611d3568750a81c186", + "sha256": "63750f2c24f535e5cfeacf666358746244fa4b3fbbf5a0048b847670dee48b82", "kind": "keyboard-asciicast", "columns": 80, "rows": 24 }, { "path": "80x24-transcript-keyboard.gif", - "sha256": "160203e6e25b15251ffb1ffd55eb3e6e0209a584c31017bcd3a746add1e595a9", + "sha256": "a54e70cd45d6095de8696c25c13671308c4760904876e31b4ebbc19de6cd2c7b", "kind": "keyboard-flow", "columns": 80, "rows": 24 diff --git a/artifacts/verification/w6/raw/braid-demo.cast b/artifacts/verification/w6/raw/braid-demo.cast deleted file mode 100644 index 446c98a..0000000 --- a/artifacts/verification/w6/raw/braid-demo.cast +++ /dev/null @@ -1,375 +0,0 @@ -{"version":2,"width":120,"height":18,"timestamp":1786538290,"duration":11.512167,"idle_time_limit":1,"command":"packed braid --fixture deterministic","title":"Braid product demo","env":{"TERM":"xterm-256color"},"stdin":true} -[0.01,"o","\u001b[?2026h\u001b[2J\u001b[H\u001b[3J \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[38;5;145mAsk Release engineer anything.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 Active profile · Release engineer \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145msource Release engineer · trusted · read-only\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mrunner pi · model openai-codex/gpt-5.6-luna\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mthinking high · max output provider default\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mdigest sha256:0419cd01…31378043\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153mprofiles\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[38;5;145m─────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007> \u001b[7m \u001b[27m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[1m\u001b[38;5;111m→ ✓ Release engineer trusted · read-only · Release engineer · pi · openai-codex/gpt-5.6-lu\u001b[0m\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\u001b[38;5;145m─────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007 \u001b[38;5;145menter · ^V valid · ^S save · esc\u001b[39m \u001b[0m\u001b]8;;\u0007 \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195mCtrl+P commands\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.010052,"o","\u001b[7A\u001b[10G\u001b[?25l"] -[1.007919,"i","\u001b"] -[1.019211,"o","\u001b[?25l"] -[1.020923,"o","\u001b[?2026h\u001b[7A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G"] -[1.02095,"o","\u001b[?25l"] -[1.37875,"i","W"] -[1.38029,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mW\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G\u001b[?25l"] -[1.39968,"i","h"] -[1.400949,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4G\u001b[?25l"] -[1.419366,"i","i"] -[1.4204,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G\u001b[?25l"] -[1.439808,"i","c"] -[1.44092,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhic\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[6G\u001b[?25l"] -[1.460401,"i","h"] -[1.461423,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7G\u001b[?25l"] -[1.480802,"i"," "] -[1.481784,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[8G\u001b[?25l"] -[1.501065,"i","c"] -[1.502198,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich c\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.502235,"o","\u001b[9G\u001b[?25l"] -[1.521563,"i","o"] -[1.52279,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich co\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G\u001b[?25l"] -[1.541063,"i","d"] -[1.542318,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich cod\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[11G"] -[1.542359,"o","\u001b[?25l"] -[1.561681,"i","i"] -[1.562789,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich codi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[12G\u001b[?25l"] -[1.581083,"i","n"] -[1.582125,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich codin\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[13G\u001b[?25l"] -[1.601461,"i","g"] -[1.602574,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[14G\u001b[?25l"] -[1.621915,"i"," "] -[1.623102,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[15G\u001b[?25l"] -[1.642439,"i","a"] -[1.643904,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[16G\u001b[?25l"] -[1.662342,"i","g"] -[1.663667,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding ag\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[17G\u001b[?25l"] -[1.682028,"i","e"] -[1.683271,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding age\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[1.702885,"i","n"] -[1.704166,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agen\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[19G\u001b[?25l"] -[1.723488,"i","t"] -[1.724708,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.724746,"o","\u001b[20G\u001b[?25l"] -[1.743048,"i"," "] -[1.744244,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.74428,"o","\u001b[21G\u001b[?25l"] -[1.763575,"i","w"] -[1.76478,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.76482,"o","\u001b[22G\u001b[?25l"] -[1.783122,"i","i"] -[1.784374,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent wi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[23G"] -[1.784406,"o","\u001b[?25l"] -[1.803709,"i","l"] -[1.804928,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent wil\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.804963,"o","\u001b[24G\u001b[?25l"] -[1.823275,"i","l"] -[1.824366,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[25G"] -[1.824402,"o","\u001b[?25l"] -[1.843725,"i"," "] -[1.844852,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[26G\u001b[?25l"] -[1.863225,"i","h"] -[1.864288,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will h\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.864327,"o","\u001b[27G\u001b[?25l"] -[1.88365,"i","a"] -[1.884721,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will ha\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.884761,"o","\u001b[28G\u001b[?25l"] -[1.903134,"i","n"] -[1.904115,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will han\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.904153,"o","\u001b[29G\u001b[?25l"] -[1.923459,"i","d"] -[1.924488,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will hand\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.924528,"o","\u001b[30G\u001b[?25l"] -[1.943046,"i","l"] -[1.944298,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handl\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[31G\u001b[?25l"] -[1.963638,"i","e"] -[1.964927,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[32G"] -[1.964963,"o","\u001b[?25l"] -[1.983286,"i"," "] -[1.984511,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[33G\u001b[?25l"] -[2.003872,"i","m"] -[2.005113,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.005163,"o","\u001b[34G\u001b[?25l"] -[2.024507,"i","y"] -[2.025575,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[35G"] -[2.025611,"o","\u001b[?25l"] -[2.04401,"i"," "] -[2.045016,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[36G\u001b[?25l"] -[2.064357,"i","n"] -[2.065583,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my n\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.065621,"o","\u001b[37G\u001b[?25l"] -[2.084923,"i","e"] -[2.086192,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my ne\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.086239,"o","\u001b[38G\u001b[?25l"] -[2.105588,"i","x"] -[2.106847,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my nex\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[39G\u001b[?25l"] -[2.12522,"i","t"] -[2.126289,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[40G\u001b[?25l"] -[2.145676,"i"," "] -[2.146818,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.146864,"o","\u001b[41G\u001b[?25l"] -[2.165176,"i","t"] -[2.166128,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next t\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[42G\u001b[?25l"] -[2.185428,"i","a"] -[2.186372,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next ta\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[43G\u001b[?25l"] -[2.205675,"i","s"] -[2.206701,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next tas\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[44G"] -[2.206738,"o","\u001b[?25l"] -[2.225094,"i","k"] -[2.226305,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.226342,"o","\u001b[45G\u001b[?25l"] -[2.245815,"i",","] -[2.246847,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task,\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[46G\u001b[?25l"] -[2.266163,"i"," "] -[2.267361,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.267419,"o","\u001b[47G\u001b[?25l"] -[2.286751,"i","a"] -[2.287793,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[48G"] -[2.287946,"o","\u001b[?25l"] -[2.307167,"i","n"] -[2.308411,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, an\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.308455,"o","\u001b[49G\u001b[?25l"] -[2.327785,"i","d"] -[2.328864,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.328904,"o","\u001b[50G\u001b[?25l"] -[2.348214,"i"," "] -[2.349631,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.349789,"o","\u001b[51G\u001b[?25l"] -[2.368077,"i","w"] -[2.369302,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.369343,"o","\u001b[52G\u001b[?25l"] -[2.388666,"i","h"] -[2.389907,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and wh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[53G\u001b[?25l"] -[2.408242,"i","e"] -[2.409389,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and whe\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[54G\u001b[?25l"] -[2.428726,"i","r"] -[2.429774,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and wher\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.429806,"o","\u001b[55G\u001b[?25l"] -[2.448065,"i","e"] -[2.449127,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.449167,"o","\u001b[56G\u001b[?25l"] -[2.468454,"i"," "] -[2.46945,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[57G"] -[2.469486,"o","\u001b[?25l"] -[2.488889,"i","w"] -[2.489859,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where w\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[58G"] -[2.489897,"o","\u001b[?25l"] -[2.509482,"i","i"] -[2.510555,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where wi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[59G\u001b[?25l"] -[2.529925,"i","l"] -[2.531365,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where wil\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[60G\u001b[?25l"] -[2.550706,"i","l"] -[2.551794,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[61G\u001b[?25l"] -[2.570097,"i"," "] -[2.571145,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[62G"] -[2.571178,"o","\u001b[?25l"] -[2.590443,"i","i"] -[2.591426,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will i\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[63G"] -[2.591461,"o","\u001b[?25l"] -[2.610784,"i","t"] -[2.612078,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[64G\u001b[?25l"] -[2.631481,"i"," "] -[2.632599,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[65G\u001b[?25l"] -[2.650983,"i","r"] -[2.652069,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it r\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[66G"] -[2.652113,"o","\u001b[?25l"] -[2.671508,"i","u"] -[2.672531,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it ru\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[67G\u001b[?25l"] -[2.691845,"i","n"] -[2.692781,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it run\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.692819,"o","\u001b[68G\u001b[?25l"] -[2.712084,"i","?"] -[2.713761,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mWhich coding agent will handle my next task, and where will it run?\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[69G\u001b[?25l"] -[2.73204,"i","\r"] -[2.733371,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] -[2.761756,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] -[2.772195,"i","\u001bOQ"] -[2.77715,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[38;5;145m│\u001b[39m \u001b[1m\u001b[38;5;153mlive work\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m run run-000001 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m admitted · pr\u001b[0m...\u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[38;5;222m> running\u001b[39m event backend\u001b[0m...\u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[1B\u001b[12G\u001b[?25l"] -[8.188954,"o","\u001b[?2026h\u001b[12A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[38;5;145m│\u001b[39m \u001b[38;5;145mNo live work.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K AgentProfile supplies its instructions and permissions. Braid keeps the transcript, \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K approvals, branches, and trace analysis. \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195min 1.3k\u001b[39m · \u001b[38;5;195mout 96\u001b[39m · \u001b[38;5;195m~$0.0026\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[8.209899,"i","\u001bOQ"] -[8.218787,"o","\u001b[?2026h\u001b[14A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[1B\u001b[2G\u001b[?25l"] -[9.21187,"i","/"] -[9.213679,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G"] -[9.213713,"o","\u001b[?25l"] -[9.229516,"o","\u001b[?2026h\u001b[7A\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ new Create an empty conversation\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K open\u001b[38;5;145m Search and open conversations\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K profile\u001b[38;5;145m Inspect, select, import, or edit a profile\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K connection\u001b[38;5;145m Inspect, create, test, select, or remove a connection\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K runner\u001b[38;5;145m unavailable — Runner overrides require the current profile compatibility helpers\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/29)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[3G\u001b[?25l"] -[9.242926,"i","a"] -[9.244557,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4G"] -[9.244608,"o","\u001b[?25l"] -[9.260491,"o","\u001b[?2026h\u001b[4B\r\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K analyze\u001b[38;5;145m Run a named trace-analysis recipe\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K approve\u001b[38;5;145m unavailable — Interaction response is not exposed by the current runtime adapter\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K automate\u001b[38;5;145m unavailable — Interaction automation requires the shared response contract\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K activity\u001b[38;5;145m Open run, tool, worker, and usage activity\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/10)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[4G\u001b[?25l"] -[9.273862,"i","c"] -[9.275417,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ac\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G\u001b[?25l"] -[9.291342,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/ac\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ activity Open run, tool, worker, and usage activity\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K detach\u001b[38;5;145m unavailable — There is no active run\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K cancel\u001b[38;5;145m unavailable — There is no active run to cancel\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K branch\u001b[38;5;145m Create a branch at a message boundary\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7A\u001b[5G\u001b[?25l"] -[9.304906,"i","t"] -[9.306439,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/act\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[6G\u001b[?25l"] -[9.322113,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/act\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ activity Open run, tool, worker, and usage activity\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[6G\u001b[?25l"] -[9.33542,"i","i"] -[9.336863,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/acti\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[9.337011,"o","\u001b[7G\u001b[?25l"] -[9.352719,"o","\u001b[7G\u001b[?25l"] -[9.365113,"i","v"] -[9.366414,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activ\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[8G\u001b[?25l"] -[9.382407,"o","\u001b[8G\u001b[?25l"] -[9.395739,"i","i"] -[9.396991,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9G\u001b[?25l"] -[9.412613,"o","\u001b[9G\u001b[?25l"] -[9.425877,"i","t"] -[9.427217,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activit\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G"] -[9.42725,"o","\u001b[?25l"] -[9.442787,"o","\u001b[10G\u001b[?25l"] -[9.456011,"i","y"] -[9.457222,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/activity\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[11G"] -[9.45725,"o","\u001b[?25l"] -[9.473271,"o","\u001b[11G\u001b[?25l"] -[9.486542,"i","\r"] -[9.488313,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[9.488343,"o","\u001b[2A\u001b[2G\u001b[?25l"] -[9.492447,"o","\u001b[?25l"] -[9.506727,"o","\u001b[?2026h\u001b[14A\r\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153mactivity\u001b[39m\u001b[22m \u001b[38;5;145m›\u001b[39m \u001b[38;5;111martifact\u001b[39m \u001b[38;5;145m· completed · 1/4\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m›\u001b[39m \u001b[38;5;115m✓\u001b[39m \u001b[38;5;111martifact\u001b[39m\u001b[38;5;145m · system\u001b[39m \u001b[38;5;145m│\u001b[39m time: 2026-08-12T12:38:04.554Z \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195mevent backend_start\u001b[39m\u001b[38;5;145m · system · seq\u001b[0m…\u001b[0m\u001b[38;5;145m│\u001b[39m run: run-000001 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195madmitted\u001b[39m\u001b[38;5;145m · system · profile b6e862\u001b[0m…\u001b[0m\u001b[38;5;145m│\u001b[39m source event: run-000001:runtime:3 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;115m✓\u001b[39m \u001b[38;5;195mrun run-000001\u001b[39m\u001b[38;5;145m · run\u001b[39m \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m│\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↑↓ select · PgUp/PgDn detail · home/end jump · ←/esc close · tab filter: all\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[?25l"] -[10.386924,"i","\u001b"] -[10.400874,"o","\u001b[?25l"] -[10.403566,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;111m› \u001b[39mWhich coding agent will handle my next task, and where will it run? \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Pi will run the task through Local CLI Bridge with gpt-5.6-luna. The Release engineer AgentProfile supplies its \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K instructions and permissions. Braid keeps the transcript, approvals, branches, and trace analysis. \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Release engineer\u001b[39m · \u001b[38;5;195mpi / openai-codex/gpt-5.6-luna\u001b[39m · \u001b[38;5;195mLocal CLI Bridge\u001b[39m · \u001b[38;5;195mhigh\u001b[39m \u001b[38;5;195min 1.3k\u001b[39m · \u001b[38;5;195mout 96\u001b[39m · \u001b[38;5;195m~$0.0026\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[10.737947,"i","/"] -[10.739269,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3G\u001b[?25l"] -[10.754872,"o","\u001b[?2026h\u001b[7A\r\u001b[2K\u001b[38;5;111m›\u001b[39m/\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ new Create an empty conversation\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K open\u001b[38;5;145m Search and open conversations\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K profile\u001b[38;5;145m Inspect, select, import, or edit a profile\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K connection\u001b[38;5;145m Inspect, create, test, select, or remove a connection\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K runner\u001b[38;5;145m unavailable — Runner overrides require the current profile compatibility helpers\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/29)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[3G\u001b[?25l"] -[10.756006,"i","a"] -[10.756824,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4G"] -[10.756847,"o","\u001b[?25l"] -[10.773767,"o","\u001b[?2026h\u001b[4B\r\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K analyze\u001b[38;5;145m Run a named trace-analysis recipe\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K approve\u001b[38;5;145m unavailable — Interaction response is not exposed by the current runtime adapter\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K automate\u001b[38;5;145m unavailable — Interaction automation requires the shared response contract\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K activity\u001b[38;5;145m Open run, tool, worker, and usage activity\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m (1/10)\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[9A\u001b[4G\u001b[?25l"] -[10.773853,"i","s"] -[10.775146,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/as\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[5G"] -[10.775173,"o","\u001b[?25l"] -[10.790235,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/as\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m──────────────────────────────────────────────\u001b[39m\u001b[38;5;111m alt+enter newline · paste \u001b[39m\u001b[38;5;145m───────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[1m\u001b[38;5;111m→ ask Analyze a frozen run with citations\u001b[39m\u001b[22m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[5G\u001b[?25l"] -[10.792545,"i","k"] -[10.793735,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.793753,"o","\u001b[6G\u001b[?25l"] -[10.809502,"o","\u001b[6G\u001b[?25l"] -[10.810632,"i"," "] -[10.811588,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[7G\u001b[?25l"] -[10.82723,"o","\u001b[?2026h\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m/ask \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2A\u001b[7G\u001b[?25l"] -[10.828422,"i","W"] -[10.829204,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask W\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[8G"] -[10.829227,"o","\u001b[?25l"] -[10.845726,"o","\u001b[8G\u001b[?25l"] -[10.84686,"i","h"] -[10.847715,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask Wh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.847737,"o","\u001b[9G\u001b[?25l"] -[10.86329,"o","\u001b[9G\u001b[?25l"] -[10.865554,"i","a"] -[10.866634,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask Wha\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[10G\u001b[?25l"] -[10.882212,"o","\u001b[10G\u001b[?25l"] -[10.883367,"i","t"] -[10.884367,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.884384,"o","\u001b[11G\u001b[?25l"] -[10.900179,"o","\u001b[11G\u001b[?25l"] -[10.901365,"i"," "] -[10.902434,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.902459,"o","\u001b[12G\u001b[?25l"] -[10.919714,"i","s"] -[10.921148,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What s\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.921184,"o","\u001b[13G\u001b[?25l"] -[10.936695,"o","\u001b[13G\u001b[?25l"] -[10.937847,"i","h"] -[10.938657,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What sh\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.938669,"o","\u001b[14G\u001b[?25l"] -[10.955371,"o","\u001b[14G"] -[10.955428,"o","\u001b[?25l"] -[10.956508,"i","o"] -[10.957597,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What sho\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.957605,"o","\u001b[15G\u001b[?25l"] -[10.973259,"o","\u001b[15G\u001b[?25l"] -[10.974413,"i","u"] -[10.975309,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What shou\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[16G"] -[10.975326,"o","\u001b[?25l"] -[10.99106,"o","\u001b[16G"] -[10.991093,"o","\u001b[?25l"] -[10.992175,"i","l"] -[10.99306,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What shoul\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[10.993076,"o","\u001b[17G\u001b[?25l"] -[11.009817,"o","\u001b[17G\u001b[?25l"] -[11.009918,"i","d"] -[11.010834,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[11.027361,"o","\u001b[18G\u001b[?25l"] -[11.028492,"i"," "] -[11.029308,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[19G\u001b[?25l"] -[11.046835,"i","t"] -[11.048416,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should t\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[20G\u001b[?25l"] -[11.064089,"o","\u001b[20G\u001b[?25l"] -[11.065215,"i","h"] -[11.066237,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should th\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.066267,"o","\u001b[21G\u001b[?25l"] -[11.080912,"o","\u001b[21G\u001b[?25l"] -[11.083166,"i","i"] -[11.084367,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should thi\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[22G\u001b[?25l"] -[11.100278,"o","\u001b[22G\u001b[?25l"] -[11.101429,"i","s"] -[11.102474,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.102491,"o","\u001b[23G\u001b[?25l"] -[11.118838,"o","\u001b[23G\u001b[?25l"] -[11.118915,"i"," "] -[11.119835,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[24G"] -[11.119958,"o","\u001b[?25l"] -[11.137341,"i","a"] -[11.138669,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this a\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[25G\u001b[?25l"] -[11.15385,"o","\u001b[25G\u001b[?25l"] -[11.154984,"i","g"] -[11.156133,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this ag\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.156179,"o","\u001b[26G"] -[11.156187,"o","\u001b[?25l"] -[11.17156,"o","\u001b[26G\u001b[?25l"] -[11.173836,"i","e"] -[11.174713,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this age\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.174741,"o","\u001b[27G\u001b[?25l"] -[11.191166,"o","\u001b[27G\u001b[?25l"] -[11.192291,"i","n"] -[11.192983,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agen\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[28G"] -[11.192992,"o","\u001b[?25l"] -[11.209278,"o","\u001b[28G\u001b[?25l"] -[11.210397,"i","t"] -[11.211125,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[29G"] -[11.211143,"o","\u001b[?25l"] -[11.227603,"o","\u001b[29G\u001b[?25l"] -[11.228732,"i"," "] -[11.229482,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.229497,"o","\u001b[30G\u001b[?25l"] -[11.246775,"i","i"] -[11.247992,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent i\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.248023,"o","\u001b[31G\u001b[?25l"] -[11.263554,"o","\u001b[31G\u001b[?25l"] -[11.265817,"i","m"] -[11.266901,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent im\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[32G\u001b[?25l"] -[11.282489,"o","\u001b[32G\u001b[?25l"] -[11.283847,"i","p"] -[11.284559,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent imp\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[33G\u001b[?25l"] -[11.301185,"o","\u001b[33G\u001b[?25l"] -[11.302303,"i","r"] -[11.303038,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent impr\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.303046,"o","\u001b[34G\u001b[?25l"] -[11.319547,"o","\u001b[34G\u001b[?25l"] -[11.320666,"i","o"] -[11.321999,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent impro\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[35G\u001b[?25l"] -[11.337531,"o","\u001b[35G\u001b[?25l"] -[11.338637,"i","v"] -[11.340119,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improv\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[11.340157,"o","\u001b[36G"] -[11.340171,"o","\u001b[?25l"] -[11.356342,"o","\u001b[36G\u001b[?25l"] -[11.356427,"i","e"] -[11.357511,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[37G\u001b[?25l"] -[11.373499,"o","\u001b[37G\u001b[?25l"] -[11.374654,"i"," "] -[11.37582,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve \u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[38G"] -[11.375883,"o","\u001b[?25l"] -[11.392078,"i","n"] -[11.39348,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve n\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[39G\u001b[?25l"] -[11.409252,"o","\u001b[39G\u001b[?25l"] -[11.410409,"i","e"] -[11.411455,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve ne\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[40G\u001b[?25l"] -[11.427174,"o","\u001b[40G\u001b[?25l"] -[11.428329,"i","x"] -[11.429291,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve nex\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[41G\u001b[?25l"] -[11.44387,"o","\u001b[41G\u001b[?25l"] -[11.446185,"i","t"] -[11.447412,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve next\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[42G\u001b[?25l"] -[11.463246,"o","\u001b[42G\u001b[?25l"] -[11.464372,"i","?"] -[11.469172,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m/ask What should this agent improve next?\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[43G\u001b[?25l"] -[11.482497,"i","\r"] -[11.484067,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G\u001b[?25l"] -[11.485477,"o","\u001b[?25l"] -[11.489179,"o","\u001b[?25l"] -[11.489215,"o","\u001b[?25l"] -[11.502167,"o","\u001b[?2026h\u001b[14A\r\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[1m\u001b[38;5;153mactivity · analyses\u001b[39m\u001b[22m \u001b[38;5;145m›\u001b[39m \u001b[38;5;111m/ask · frozen question\u001b[39m \u001b[38;5;145m· complete · 1/1\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145msource run run-000001 · Release engineer · pi · openai-codex/gpt-5.6-luna · Local CLI Bridge\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 source: a9ed803bccc9148…26b84d74 · frozen \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 analyst: profile-trace-reviewer · completed \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── question\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 What should this agent improve next? \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── findings\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 1. [1] The route was explicit, but the final answer did not cite its materialization receipt. · medium · confidence \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 0.93 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↳ [1] runtime.route completed: Local CLI Bridge → Pi · event event-runtime-route\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 2. [2] Profile inspection and route resolution ran serially even though their inputs were independent. · low · \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 confidence 0.86 \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145m↳ [2] profile.inspect completed before runtime.route started · event event-profile-b…re-route\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;111m── next\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 next: ask a narrower question, or use /compare against another frozen run \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007\u001b[38;5;145m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[0m\u001b]8;;\u0007 \u001b[38;5;145mPgUp/PgDn detail · ←/esc close · page 1/2 · tab filter: analyses\u001b[39m \u001b[0m\u001b]8;;\u0007\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[?25l"] -[11.512167,"o","\u001b[0m"] diff --git a/artifacts/verification/w6/raw/transcript-keyboard.cast b/artifacts/verification/w6/raw/transcript-keyboard.cast index 733c485..711fdb5 100644 --- a/artifacts/verification/w6/raw/transcript-keyboard.cast +++ b/artifacts/verification/w6/raw/transcript-keyboard.cast @@ -1,84 +1,69 @@ -{"version":2,"width":80,"height":24,"timestamp":1786538293,"duration":2.028706,"idle_time_limit":1,"command":"packed braid --fixture deterministic","title":"Braid transcript keyboard navigation","env":{"TERM":"xterm-256color"},"stdin":true} -[0.455262,"o","\u001b[?2004h"] -[0.455437,"o","\u001b[>7u\u001b[?u\u001b[c"] -[0.45545,"o","\u001b[?1049h"] -[0.455461,"o","\u001b[2J\u001b[H"] -[0.455476,"o","\u001b[?25l"] -[0.466018,"o","\u001b[?2026h\u001b[2J\u001b[H\u001b[3J \u001b[0m\u001b]8;;\u0007\r\r\n Ask Braid starter anything. \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;111m›\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m · \u001b[38;5;195mno connection\u001b[39m \u001b[38;5;115mready\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.46606,"o","\u001b[3A\u001b[2G\u001b[?25l"] -[0.505436,"o","\u001b]0;Braid — /home/drew/code/braid-transcript-first-wt\u0007"] -[0.508782,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145mAsk Braid starter anything.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n"] -[0.508801,"o","\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+P commands\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.508807,"o","\u001b[3A\u001b[2G"] -[0.508812,"o","\u001b[?25l"] -[0.523264,"i","keyboard-flow-01"] -[0.523342,"i","\r"] -[0.538154,"o","\u001b[2G\u001b[?25l"] -[0.566513,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] -[0.634528,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E detail\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[0.724794,"i","keyboard-flow-02"] -[0.724876,"i","\r"] -[0.729119,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-02\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[0.730757,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.730777,"o","\u001b[2G\u001b[?25l"] -[0.746311,"o","\u001b[?2026h\u001b[16A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G"] -[0.746365,"o","\u001b[?25l"] -[0.804871,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (2/2)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G"] -[0.80497,"o","\u001b[?25l"] -[0.885845,"i","keyboard-flow-03"] -[0.88592,"i","\r"] -[0.887814,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-03\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.887852,"o","\u001b[18G\u001b[?25l"] -[0.889393,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.889414,"o","\u001b[2G\u001b[?25l"] -[0.906086,"o","\u001b[?2026h\u001b[12A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[0.906118,"o","\u001b[3A\u001b[12G\u001b[?25l"] -[0.963025,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (3/3)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[1.045937,"i","keyboard-flow-04"] -[1.046011,"i","\r"] -[1.047766,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-04\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[1.050081,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G"] -[1.050115,"o","\u001b[?25l"] -[1.067575,"o","\u001b[?2026h\u001b[8A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] -[1.132689,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (4/4)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[1.227601,"i","keyboard-flow-05"] -[1.227671,"i","\r"] -[1.229798,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-05\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[1.231758,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.231789,"o","\u001b[2G\u001b[?25l"] -[1.249551,"o","\u001b[?2026h\u001b[4A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G\u001b[?25l"] -[1.313758,"o","\u001b[?2026h\u001b[18A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (5/5)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.313801,"o","\u001b[3A\u001b[2G\u001b[?25l"] -[1.389686,"i","keyboard-flow-06"] -[1.389776,"i","\r"] -[1.39166,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-06\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G\u001b[?25l"] -[1.393519,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[2G"] -[1.393542,"o","\u001b[?25l"] -[1.412422,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G"] -[1.412462,"o","\u001b[?25l"] -[1.483081,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (6/6)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[1.57018,"i","keyboard-flow-07"] -[1.570255,"i","\r"] -[1.574224,"o","\u001b[2G\u001b[?25l"] -[1.592731,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.592771,"o","\u001b[3A\u001b[12G\u001b[?25l"] -[1.658569,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (7/7)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.658659,"o","\u001b[3A\u001b[2G\u001b[?25l"] -[1.752568,"i","keyboard-flow-08"] -[1.752641,"i","\r"] -[1.755149,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39mkeyboard-flow-08\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[18G"] -[1.755192,"o","\u001b[?25l"] -[1.75801,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.758024,"o","\u001b[2G\u001b[?25l"] -[1.778488,"o","\u001b[?2026h\u001b[20A\r\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m \u001b[38;5;222mworking · Ctrl+C cancel\u001b[39m · \u001b[38;5;195mEnter queues\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[12G"] -[1.778526,"o","\u001b[?25l"] -[1.845392,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] -[1.934374,"i","\u001b[5~"] -[1.934451,"i","\u001b[1;3H"] -[1.937925,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mPgUp/PgDn history · End follow\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[1.937964,"o","\u001b[3A\u001b[2G\u001b[?25l"] -[2.015744,"i","\u001b[6~"] -[2.015826,"i","\u001b[1;3F"] -[2.018668,"o","\u001b[?2026h\u001b[20A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m›\u001b[39m\u001b[7m \u001b[0m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi / deterministic\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] -[2.018706,"o","\u001b[3A\u001b[2G\u001b[?25l"] -[2.028706,"o","\u001b[0m"] +{"version":2,"width":80,"height":24,"timestamp":1786878348,"duration":2.631937,"idle_time_limit":1,"command":"packed braid --fixture deterministic","title":"Braid transcript keyboard navigation","env":{"TERM":"xterm-256color"},"stdin":true} +[0.779238,"o","\u001b[?2004h"] +[0.779647,"o","\u001b[>7u\u001b[?u\u001b[c\u001b[?1049h"] +[0.779711,"o","\u001b[2J\u001b[H\u001b[?25l"] +[0.805016,"o","\u001b[?2026h\u001b[2J\u001b[H\u001b[3J \u001b[0m\u001b]8;;\u0007\r\r\n Ask Braid starter anything. \u001b[0m\u001b]8;;\u0007\r\r\n \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;111m›\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia no connection\u001b[39m \u001b[38;5;115mready\u001b[39m · \u001b[38;5;195mCtrl+P commands\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[3A\u001b[2G\u001b[?25l"] +[0.841268,"o","\u001b]0;Braid — /home/drew/code/.worktrees/braid-exact-interactive-20260816\u0007"] +[0.846892,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145mAsk Braid starter anything.\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[0.846976,"o","\u001b[4A\u001b[15G\u001b[?25l"] +[0.853664,"i","keyboard-flow-01"] +[0.853764,"i","\r"] +[0.864663,"o","\u001b[15G\u001b[?25l"] +[0.912563,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G"] +[0.913158,"o","\u001b[?25l"] +[0.990246,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E details\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[1.074997,"i","keyboard-flow-02"] +[1.075095,"i","\r"] +[1.08568,"o","\u001b[15G\u001b[?25l"] +[1.116653,"o","\u001b[?2026h\u001b[15A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G\u001b[?25l"] +[1.186529,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/2\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (2/2)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[1.278784,"i","keyboard-flow-03"] +[1.279088,"i","\r"] +[1.283155,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m keyboard-flow-03\u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.284806,"o","\u001b[31G\u001b[?25l"] +[1.290344,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[15G\u001b[?25l"] +[1.326542,"o","\u001b[?2026h\u001b[11A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G"] +[1.326745,"o","\u001b[?25l"] +[1.395938,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/3\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (3/3)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.39603,"o","\u001b[4A\u001b[15G\u001b[?25l"] +[1.481215,"i","keyboard-flow-04"] +[1.481325,"i","\r"] +[1.484212,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m keyboard-flow-04\u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[31G\u001b[?25l"] +[1.489147,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[15G\u001b[?25l"] +[1.51367,"o","\u001b[?2026h\u001b[7A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G\u001b[?25l"] +[1.59326,"o","\u001b[?2026h\u001b[17A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (4/4)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[1.683457,"i","keyboard-flow-05"] +[1.683559,"i","\r"] +[1.69227,"o","\u001b[15G"] +[1.694221,"o","\u001b[?25l"] +[1.723277,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/4\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G\u001b[?25l"] +[1.800552,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (5/5)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[1.887207,"i","keyboard-flow-06"] +[1.887289,"i","\r"] +[1.89272,"o","\u001b[15G"] +[1.892877,"o","\u001b[?25l"] +[1.924811,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/5\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[1.924903,"o","\u001b[4A\u001b[12G\u001b[?25l"] +[2.001345,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (6/6)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G"] +[2.001482,"o","\u001b[?25l"] +[2.0916,"i","keyboard-flow-07"] +[2.091707,"i","\r"] +[2.100213,"o","\u001b[15G\u001b[?25l"] +[2.154593,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/6\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l"] +[2.154683,"o","\u001b[4A\u001b[12G\u001b[?25l"] +[2.234007,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (7/7)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G"] +[2.234226,"o","\u001b[?25l"] +[2.313262,"i","keyboard-flow-08"] +[2.31335,"i","\r"] +[2.31697,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m keyboard-flow-08\u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[31G\u001b[?25l"] +[2.327532,"o","\u001b[?2026h\r\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[15G\u001b[?25l"] +[2.365431,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/7\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;222mworking…\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› queue #1\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia not connected\u001b[39m \u001b[38;5;222mCtrl+C cancel\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[12G\u001b[?25l"] +[2.447763,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[2.537801,"i","\u001b[5~"] +[2.537897,"i","\u001b[1;3H"] +[2.541028,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 1/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-01 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 2/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-02 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 3/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-03 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mPgUp/PgDn history · End follow\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G"] +[2.541104,"o","\u001b[?25l"] +[2.617302,"i","\u001b[6~"] +[2.617398,"i","\u001b[1;3F"] +[2.621937,"o","\u001b[?2026h\u001b[19A\r\u001b[2K \u001b[38;5;145m· run result\u001b[39m 4/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-04 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 5/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-05 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 6/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-06 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 7/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-07 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;111m› \u001b[39mkeyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[38;5;145m· run result\u001b[39m 8/8\u001b[38;5;145m · Ctrl+E details\u001b[39m \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K Fixture response through pi: keyboard-flow-08 \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;111m────────────────────────────────────────────────────────────────────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;222m› new message\u001b[39m \u001b[7m \u001b[0m \u001b[0m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K \u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;145m───────────────\u001b[39m\u001b[38;5;111m type / for commands · Alt+Enter newline · paste \u001b[39m\u001b[38;5;145m────────────────\u001b[39m\u001b[0m\u001b]8;;\u0007\r\r\n\u001b[2K\u001b[38;5;195mprofile Braid starter\u001b[39m · \u001b[38;5;195mpi\u001b[39m · \u001b[38;5;195mvia local\u001b[39m \u001b[38;5;195mCtrl+E next detail (8/8)\u001b[39m\u001b[0m\u001b]8;;\u0007\u001b[?2026l\u001b[4A\u001b[15G\u001b[?25l"] +[2.631937,"o","\u001b[0m"] diff --git a/artifacts/verification/w6/states/active-streaming.json b/artifacts/verification/w6/states/active-streaming.json index b5d8deb..ee618f4 100644 --- a/artifacts/verification/w6/states/active-streaming.json +++ b/artifacts/verification/w6/states/active-streaming.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 5, - "frame": " › W6 active streaming\n\n working…\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n› queue #1\n\n\nprofile Braid starter working · Ctrl+C cancel · Enter queues\n", + "frame": " › W6 active streaming\n\n working…\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n› queue #1\n\n\n─────────────── type / for commands · Alt+Enter newline · paste ────────────────\nprofile Braid starter · pi · via not connected Ctrl+C cancel\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 5, "sequence": 5, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [ { @@ -91,8 +98,12 @@ "turnId": "turn-000002", "operationId": "op-terminal-000002", "status": "streaming", + "startedAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z", "inputTokens": 0, "outputTokens": 0, + "tokensKnown": false, + "usdKnown": false, "tokenStatus": "unknown", "costStatus": "unknown", "model": "fixture/deterministic", @@ -152,12 +163,13 @@ }, "view": { "revision": 5, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -205,6 +217,8 @@ { "id": "run-000001", "turnId": "turn-000002", + "conversationId": "conv-1", + "branchId": "branch-1", "operationId": "op-terminal-000002", "status": "running", "profileName": "Braid starter", @@ -297,7 +311,7 @@ "title": "event backend_start", "status": "running", "detail": "sequence 1", - "occurredAt": "2026-08-12T12:37:37.459Z", + "occurredAt": "2026-08-16T11:05:23.005Z", "sourceEventId": "run-000001:runtime:1", "runId": "run-000001", "entityType": "run", @@ -321,6 +335,9 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" }, { @@ -330,6 +347,9 @@ "status": "running", "depth": 2, "edgeLabel": "continued", + "parentIds": [ + "branch:branch-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" }, { @@ -339,10 +359,14 @@ "status": "running", "depth": 3, "edgeLabel": "attached", + "parentIds": [ + "turn:turn-000002" + ], "runner": "pi", "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "details": { "title": "run run-000001", @@ -359,14 +383,6 @@ "label": "output tokens", "value": "0" }, - { - "label": "token measurement", - "value": "unknown" - }, - { - "label": "cost measurement", - "value": "unknown" - }, { "label": "model", "value": "fixture/deterministic" @@ -387,17 +403,17 @@ "run.runner": { "available": false, "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "reason": "Finish or cancel the active run first" }, "run.model": { "available": false, "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "reason": "Finish or cancel the active run first" }, "run.effort": { "available": false, "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "reason": "Finish or cancel the active run first" }, "conversation.branch": { "available": false, @@ -530,7 +546,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } }, @@ -568,6 +584,7 @@ "revision": 3, "kind": "run.requested", "payload": { + "runId": "run-000001", "text": "W6 active streaming", "admission": { "version": 1, @@ -590,6 +607,7 @@ }, "harness": "pi" }, + "interactions": {}, "model": "fixture/deterministic", "runner": "pi" }, @@ -624,10 +642,10 @@ "provider": "agent-runtime" }, "admissionStatus": "admitted", - "requestDigest": "3c7b454961e6fcf430e049567bd0f429517909faa89f8a05f3446046f7a55e39", + "requestDigest": "bc4e17167150bc55a408399a442e5b4e763ef9838eba4a6a291b2732a1c9e7f3", "capabilitiesDigest": "93c4c21593f9e2fa1fe68ce73da84f48c173f1e13269633678b512a16a81a001", "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671", - "digest": "e0e2a2c27f92cbb77fc1449a50044630d9b08bfb42f04aaf06f2d8a12a32875f" + "digest": "e4207f4069c98e6debfab39041a4feb28975bf9ebaee947ff8eaff3b5e20c47b" }, "status": "admitted" } @@ -640,10 +658,10 @@ "value": { "kind": "effect.upserted", "effect": { - "id": "effect-op-terminal-000002-0a838249b94207b11007ccae", + "id": "effect-op-terminal-000002-221214e8dc3317bcb681a929", "operationId": "op-terminal-000002", "effectKind": "run.execute", - "requestDigest": "0a838249b94207b11007ccae8a84f3baa7e3b080a7f8fd0a909dbab19fa7e6a1", + "requestDigest": "221214e8dc3317bcb681a929a14c7405e4e06d08e9bbba09c7a20cf138d54c87", "kind": "send", "status": "pending", "attempt": 1, @@ -658,16 +676,17 @@ "revision": 5, "kind": "run.provider.event", "payload": { + "runId": "run-000001", "source": { "eventId": "run-000001:runtime:1", "providerSequence": 1, - "occurredAt": "2026-08-12T12:37:37.459Z", + "occurredAt": "2026-08-16T11:05:23.005Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "unknown": { "type": "backend_start", "task": { - "id": "turn-db68a0cb-0c7c-4589-8347-fb36879b38e3", + "id": "turn-00c1be56-e524-49c2-9635-c92d7fae52aa", "intent": "W6 active streaming", "metadata": { "identity": { @@ -677,11 +696,11 @@ } }, "session": { - "id": "590229a9-1f09-4c04-ba48-6a314df1a398", + "id": "2e852f40-2813-4ba4-9e23-78f2991e7c5a", "backend": "executor", "status": "active", - "createdAt": "2026-08-12T12:37:37.459Z", - "updatedAt": "2026-08-12T12:37:37.459Z", + "createdAt": "2026-08-16T11:05:23.005Z", + "updatedAt": "2026-08-16T11:05:23.005Z", "metadata": "[unavailable]" }, "backend": "router", @@ -703,16 +722,16 @@ }, "execution": { "kind": "request", - "id": "turn-db68a0cb-0c7c-4589-8347-fb36879b38e3" + "id": "turn-00c1be56-e524-49c2-9635-c92d7fae52aa" }, "materializer": "router-prompt-model" }, "executionBindings": [ { "status": "known", - "attemptId": "turn-db68a0cb-0c7c-4589-8347-fb36879b38e3:attempt:5654b7d0-b218-4427-95c6-361d6dd76cc8", - "materializationReceiptDigest": "sha256:dad1132b1c7dd7c6fe79575ac4fc5da786d05d0b35d84d97698e1d43c21ba925", - "bindingDigest": "sha256:3ec6106218348b41e13fc244f20dbeae88da12fec1825bfb19090e1a343467c1", + "attemptId": "turn-00c1be56-e524-49c2-9635-c92d7fae52aa:attempt:c94a79bb-1272-44ba-ace0-f6fa63c8ad8a", + "materializationReceiptDigest": "sha256:bdae6c111d17379adb467471e373ca52efbafa678fa43bc4cd3bcb66d79cb91b", + "bindingDigest": "sha256:dbfb11afb6d471167ed41e0d48a5a4399546209487e8f350b2f38870c0455b37", "descriptor": { "kind": "router-request", "transport": "http", @@ -724,10 +743,10 @@ "timeoutMs": null }, "timing": { - "startedAt": "2026-08-12T12:37:37.455Z" + "startedAt": "2026-08-16T11:05:23.001Z" } }, - "timestamp": "2026-08-12T12:37:37.459Z" + "timestamp": "2026-08-16T11:05:23.005Z" } } } diff --git a/artifacts/verification/w6/states/active-streaming.png b/artifacts/verification/w6/states/active-streaming.png index e6f36a7..cfc8177 100644 Binary files a/artifacts/verification/w6/states/active-streaming.png and b/artifacts/verification/w6/states/active-streaming.png differ diff --git a/artifacts/verification/w6/states/active-streaming.txt b/artifacts/verification/w6/states/active-streaming.txt index 5aab693..c72c61a 100644 --- a/artifacts/verification/w6/states/active-streaming.txt +++ b/artifacts/verification/w6/states/active-streaming.txt @@ -16,9 +16,9 @@ - - +──────────────────────────────────────────────────────────────────────────────── › queue #1 -profile Braid starter working · Ctrl+C cancel · Enter queues +─────────────── type / for commands · Alt+Enter newline · paste ──────────────── +profile Braid starter · pi · via not connected Ctrl+C cancel diff --git a/artifacts/verification/w6/states/analysis.json b/artifacts/verification/w6/states/analysis.json index 0b99f85..30455c0 100644 --- a/artifacts/verification/w6/states/analysis.json +++ b/artifacts/verification/w6/states/analysis.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": " activity · analyses › /ask · frozen question · complete · 1/1\n────────────────────────────────────────────────────────────────────────────────\n source: da8ae2345b2c17a…7c9190eb · frozen\n analyst: profile-trace-reviewer · completed\n ── question\n Where did this run waste time, and what should change?\n ── findings\n 1. [1] Profile inspection and route resolution ran serially although their\n inputs were independent. · medium · confidence 0.94\n ↳ [1] profile.inspect completed before runtime.route started · event\n event-runtime-r…te-start\n 2. [2] The answer claimed success before the packaged terminal check completed.\n · high · confidence 0.88\n ↳ [2] package check started; no terminal result was recorded · event\n event-package-c…ck-start\n ── next\n next: ask a narrower question, or use /compare against another\n frozen run\n ── model use\n Model calls were not reported.\n ── run receipt\n source complete: yes\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab filter: analys…\n", + "frame": " analyses › /ask · frozen question Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n source: da8ae2345b2c17a…7c9190eb · frozen\n analyst: profile-trace-reviewer · completed\n ── question\n Where did this run waste time, and what should change?\n ── findings\n 1. [1] Profile inspection and route resolution ran serially although their\n inputs were independent. · medium · confidence 0.94\n ↳ [1] profile.inspect completed before runtime.route started · event\n event-runtime-r…te-start\n 2. [2] The answer claimed success before the packaged terminal check completed.\n · high · confidence 0.88\n ↳ [2] package check started; no terminal result was recorded · event\n event-package-c…ck-start\n ── next\n next: ask a narrower question, or use /compare against another\n frozen run\n ── run receipt\n source complete: yes\n findings: 2\n analysis cost: $0.0048\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab: analyses\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -214,9 +222,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [ { "entityType": "analysis", @@ -235,8 +247,6 @@ "↳ [2] package check started; no terminal result was recorded · event event-package-c…ck-start", "── next", "next: ask a narrower question, or use /compare against another frozen run", - "── model use", - "Model calls were not reported.", "── run receipt", "source complete: yes", "findings: 2", @@ -262,19 +272,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -404,7 +411,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/analysis.png b/artifacts/verification/w6/states/analysis.png index a8955e1..b8fad3a 100644 Binary files a/artifacts/verification/w6/states/analysis.png and b/artifacts/verification/w6/states/analysis.png differ diff --git a/artifacts/verification/w6/states/analysis.txt b/artifacts/verification/w6/states/analysis.txt index 1fdf902..12e8df9 100644 --- a/artifacts/verification/w6/states/analysis.txt +++ b/artifacts/verification/w6/states/analysis.txt @@ -1,4 +1,4 @@ - activity · analyses › /ask · frozen question · complete · 1/1 + analyses › /ask · frozen question Braid starter · pi · fixture/deterministic ──────────────────────────────────────────────────────────────────────────────── source: da8ae2345b2c17a…7c9190eb · frozen analyst: profile-trace-reviewer · completed @@ -16,9 +16,9 @@ ── next next: ask a narrower question, or use /compare against another frozen run - ── model use - Model calls were not reported. ── run receipt source complete: yes + findings: 2 + analysis cost: $0.0048 ──────────────────────────────────────────────────────────────────────────────── - ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab filter: analys… + ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/2 · tab: analyses diff --git a/artifacts/verification/w6/states/automation.json b/artifacts/verification/w6/states/automation.json index e691d9d..c287236 100644 --- a/artifacts/verification/w6/states/automation.json +++ b/artifacts/verification/w6/states/automation.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n\n automation rules\n ──────────────────────────────────────────────────────────────────────\n No saved automation rules\n ──────────────────────────────────────────────────────────────────────\n create from a pending request with Alt+A · esc close\n\n\n\n\n\n\n\n\n\nNo automation rules\n", + "frame": "\n\n\n\n\n\n\n\n\n automation rules\n ──────────────────────────────────────────────────────────────────────\n No saved automation rules\n ──────────────────────────────────────────────────────────────────────\n create from a pending request with Alt+A · ←/esc close\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -359,7 +368,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/automation.png b/artifacts/verification/w6/states/automation.png index 5e922ce..f708fb9 100644 Binary files a/artifacts/verification/w6/states/automation.png and b/artifacts/verification/w6/states/automation.png differ diff --git a/artifacts/verification/w6/states/automation.txt b/artifacts/verification/w6/states/automation.txt index 4bc0c2a..b465ae4 100644 --- a/artifacts/verification/w6/states/automation.txt +++ b/artifacts/verification/w6/states/automation.txt @@ -1,5 +1,5 @@ - Ask Braid starter anything. + @@ -11,14 +11,4 @@ ────────────────────────────────────────────────────────────────────── No saved automation rules ────────────────────────────────────────────────────────────────────── - create from a pending request with Alt+A · esc close - - - - - - - - - -No automation rules + create from a pending request with Alt+A · ←/esc close diff --git a/artifacts/verification/w6/states/command-palette-120.json b/artifacts/verification/w6/states/command-palette-120.json new file mode 100644 index 0000000..4fd6c95 --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-120.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "command-palette-120", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" + }, + "dimensions": { + "columns": 120, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " Commands\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n>\n→ /new Create an empty conversation\n /open Search and open conversations\n /profile Inspect, select, import, or edit a profile\n /connection Inspect and manage connections\n /runner Set the runner for this branch\n /model Set the model for this branch\n /effort Set reasoning effort for this branch\n /branch Create a branch at a message boundary\n (1/29)\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n type to filter · enter to choose · ←/esc close\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/command-palette-120.png b/artifacts/verification/w6/states/command-palette-120.png new file mode 100644 index 0000000..85b3885 Binary files /dev/null and b/artifacts/verification/w6/states/command-palette-120.png differ diff --git a/artifacts/verification/w6/states/command-palette-120.txt b/artifacts/verification/w6/states/command-palette-120.txt new file mode 100644 index 0000000..558cc1d --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-120.txt @@ -0,0 +1,14 @@ + Commands +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +> +→ /new Create an empty conversation + /open Search and open conversations + /profile Inspect, select, import, or edit a profile + /connection Inspect and manage connections + /runner Set the runner for this branch + /model Set the model for this branch + /effort Set reasoning effort for this branch + /branch Create a branch at a message boundary + (1/29) +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + type to filter · enter to choose · ←/esc close diff --git a/artifacts/verification/w6/states/command-palette-40.json b/artifacts/verification/w6/states/command-palette-40.json new file mode 100644 index 0000000..c15be1b --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-40.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "command-palette-40", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" + }, + "dimensions": { + "columns": 40, + "rows": 12 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " Commands\n────────────────────────────────────────\n>\n→ /new\n /open\n /profile\n /connection\n /runner\n /model\n (1/29)\n────────────────────────────────────────\n enter choose · ←/esc close\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/command-palette-40.png b/artifacts/verification/w6/states/command-palette-40.png new file mode 100644 index 0000000..f1eebab Binary files /dev/null and b/artifacts/verification/w6/states/command-palette-40.png differ diff --git a/artifacts/verification/w6/states/command-palette-40.txt b/artifacts/verification/w6/states/command-palette-40.txt new file mode 100644 index 0000000..6781fac --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-40.txt @@ -0,0 +1,12 @@ + Commands +──────────────────────────────────────── +> +→ /new + /open + /profile + /connection + /runner + /model + (1/29) +──────────────────────────────────────── + enter choose · ←/esc close diff --git a/artifacts/verification/w6/states/command-palette-80.json b/artifacts/verification/w6/states/command-palette-80.json new file mode 100644 index 0000000..6889abd --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-80.json @@ -0,0 +1,376 @@ +{ + "schemaVersion": 2, + "state": "command-palette-80", + "source": { + "binary": "packed real binary from clean npm install", + "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" + }, + "dimensions": { + "columns": 80, + "rows": 24 + }, + "terminal": "node-pty/xterm-256color", + "provenance": { + "renderer": { + "package": "@earendil-works/pi-tui@0.84.2", + "pty": "node-pty@1.1.0", + "emulator": "@xterm/headless@6.0.0", + "node": "v24.13.0", + "terminal": "xterm-256color" + }, + "raster": { + "agg": "agg 1.9.0", + "imagemagick": "Version: ImageMagick 6.9.12-98 Q16 x86_64 18038 https://legacy.imagemagick.org", + "fontFamily": "DejaVu Sans Mono", + "font": "DejaVu Sans Mono | Book | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "colorMode": "sRGB 8-bit" + } + }, + "capturePhase": "atomic-signal-frame", + "captureRevision": 1, + "frame": " Commands\n────────────────────────────────────────────────────────────────────────────────\n>\n→ /new Create an empty conversation\n /open Search and open conversations\n /profile Inspect, select, import, or edit a profile\n /connection Inspect and manage connections\n /runner Set the runner for this branch\n /model Set the model for this branch\n /effort Set reasoning effort for this branch\n /branch Create a branch at a message boundary\n (1/29)\n────────────────────────────────────────────────────────────────────────────────\n type to filter · enter to choose · ←/esc close\n", + "packedState": { + "schemaVersion": 2, + "capturePhase": "atomic-signal-frame", + "state": { + "schemaVersion": 2, + "revision": 1, + "sequence": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "conversationId": "conv-1", + "branchId": "branch-1", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "profile": { + "name": "Braid starter", + "description": "A portable starter profile for the Braid terminal", + "harness": "pi", + "model": { + "default": "fixture/deterministic", + "provider": "fixture", + "reasoningEffort": "none" + } + }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, + "draft": "", + "messages": [], + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "queue": [], + "activeRunId": null, + "lastError": null + }, + "view": { + "revision": 1, + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", + "profileName": "Braid starter", + "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "runOverrides": {}, + "connection": "deterministic fixture", + "conversationId": "conv-1", + "conversationTitle": "New conversation", + "conversations": [ + { + "id": "conv-1", + "title": "New conversation", + "branchId": "branch-1", + "archived": false, + "active": true, + "updatedAt": "2026-08-01T00:00:00.000Z" + } + ], + "branch": "branch-1", + "status": "empty", + "statusText": "ready for a message", + "queueCount": 0, + "queue": [], + "messages": [], + "hiddenMessageCount": 0, + "runs": [], + "sessionUsage": { + "turns": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "analyses": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "delegated": { + "sourceCount": 0, + "input": 0, + "output": 0, + "tokenStatus": "unknown", + "costStatus": "unknown", + "unknownTokenSources": 0, + "unknownCostSources": 0, + "callStatus": "unknown", + "latencyStatus": "unknown", + "unknownCallSources": 0, + "unknownLatencySources": 0 + }, + "attribution": "complete" + }, + "environments": [], + "interactions": [], + "activity": [], + "graph": [ + { + "id": "conv-1", + "type": "conversation", + "title": "New conversation", + "status": "complete", + "depth": 0, + "edgeLabel": "attached", + "startedAt": "2026-08-01T00:00:00.000Z" + }, + { + "id": "branch-1", + "type": "branch", + "title": "Main", + "status": "complete", + "depth": 1, + "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], + "startedAt": "2026-08-01T00:00:00.000Z" + } + ], + "graphQuery": "", + "entityDetails": [], + "capabilities": { + "conversation.create": { + "available": true, + "source": "application" + }, + "conversation.open": { + "available": true, + "source": "application" + }, + "run.runner": { + "available": true, + "source": "application" + }, + "run.model": { + "available": true, + "source": "application" + }, + "run.effort": { + "available": true, + "source": "application" + }, + "conversation.branch": { + "available": true, + "source": "application" + }, + "conversation.clone": { + "available": true, + "source": "application" + }, + "conversation.fork": { + "available": true, + "source": "application" + }, + "analysis.ask": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.recipe": { + "available": false, + "source": "application", + "reason": "Complete or fail a run before analyzing it" + }, + "analysis.compare": { + "available": false, + "source": "application", + "reason": "Two completed or failed runs are required for comparison" + }, + "interaction.respond": { + "available": false, + "source": "runtime", + "reason": "Interaction response is not exposed by the current runtime adapter" + }, + "interaction.automation": { + "available": false, + "source": "application", + "reason": "Interaction automation requires the shared response contract" + }, + "export.create": { + "available": true, + "source": "application" + }, + "application.quit": { + "available": true, + "source": "local" + }, + "help.read": { + "available": true, + "source": "local" + }, + "profile.select": { + "available": true, + "source": "application" + }, + "connection.select": { + "available": true, + "source": "application" + }, + "settings.open": { + "available": true, + "source": "application" + }, + "activity.read": { + "available": true, + "source": "local" + }, + "graph.read": { + "available": true, + "source": "local" + }, + "details.read": { + "available": true, + "source": "local" + }, + "draft.write": { + "available": true, + "source": "application" + }, + "run.send": { + "available": true, + "source": "provider" + }, + "run.cancel": { + "available": false, + "source": "runtime", + "reason": "There is no active run to cancel" + }, + "run.queue": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.steer": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.detach": { + "available": false, + "source": "provider", + "reason": "There is no active run" + }, + "run.reconnect": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconnect" + }, + "run.reconcile": { + "available": false, + "source": "provider", + "reason": "There is no detached run to reconcile" + } + }, + "draft": "", + "selectedSurface": "transcript", + "appearance": { + "color": "256", + "highContrast": false, + "reducedMotion": false + } + }, + "events": [ + { + "sequence": 1, + "revision": 1, + "kind": "workspace.opened", + "payload": { + "value": { + "kind": "workspace.opened", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" + } + } + } + ] + } +} diff --git a/artifacts/verification/w6/states/command-palette-80.png b/artifacts/verification/w6/states/command-palette-80.png new file mode 100644 index 0000000..680e8f4 Binary files /dev/null and b/artifacts/verification/w6/states/command-palette-80.png differ diff --git a/artifacts/verification/w6/states/command-palette-80.txt b/artifacts/verification/w6/states/command-palette-80.txt new file mode 100644 index 0000000..a130612 --- /dev/null +++ b/artifacts/verification/w6/states/command-palette-80.txt @@ -0,0 +1,14 @@ + Commands +──────────────────────────────────────────────────────────────────────────────── +> +→ /new Create an empty conversation + /open Search and open conversations + /profile Inspect, select, import, or edit a profile + /connection Inspect and manage connections + /runner Set the runner for this branch + /model Set the model for this branch + /effort Set reasoning effort for this branch + /branch Create a branch at a message boundary + (1/29) +──────────────────────────────────────────────────────────────────────────────── + type to filter · enter to choose · ←/esc close diff --git a/artifacts/verification/w6/states/comparison.json b/artifacts/verification/w6/states/comparison.json index a50e2be..b696bb6 100644 --- a/artifacts/verification/w6/states/comparison.json +++ b/artifacts/verification/w6/states/comparison.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": " activity · analyses › /compare · frozen runs · complete · 1/1\n────────────────────────────────────────────────────────────────────────────────\n saved result: replayed from the local journal\n baseline run: run-route-serial\n baseline source: 9ee7d219c8b0092…8c519924\n baseline outcome: completed · cost: 0.014 · source: frozen run field\n run.cost_usd\n candidate run: run-route-parallel\n candidate source: 0328e6889082b14…86b0ff3d\n candidate outcome: completed · cost: 0.009 · source: frozen run field\n run.cost_usd\n sample: 1 paired · 0 baseline-only · 0 candidate-only\n ! One pair is descriptive; it cannot support a reliable general conclusion.\n captured fields · baseline | candidate | availability\n run.status: completed | completed | none\n run.cost_usd: 0.014 | 0.009 | none\n run.wall_time_ms: 18200 | 12100 | none\n run.input_tokens: 7420 | 6910 | none\n paired measurements · candidate minus baseline where applicable\n paired.correctness.b01: 0\n paired.correctness.b10: 0\n paired.correctness.mcnemar.b: 0\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab filter: analys…\n", + "frame": " analyses › /compare · frozen runs Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n saved result: replayed from the local journal\n baseline run: run-route-serial\n baseline source: 9ee7d219c8b0092…8c519924\n baseline outcome: completed · cost: 0.014 · source: frozen run field\n run.cost_usd\n candidate run: run-route-parallel\n candidate source: 0328e6889082b14…86b0ff3d\n candidate outcome: completed · cost: 0.009 · source: frozen run field\n run.cost_usd\n sample: 1 paired · 0 baseline-only · 0 candidate-only\n ! One pair is descriptive; it cannot support a reliable general conclusion.\n captured fields · baseline | candidate | availability\n run.status: completed | completed | none\n run.cost_usd: 0.014 | 0.009 | none\n run.wall_time_ms: 18200 | 12100 | none\n run.input_tokens: 7420 | 6910 | none\n paired measurements · candidate minus baseline where applicable\n paired.correctness.b01: 0\n paired.correctness.b10: 0\n paired.correctness.mcnemar.b: 0\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab: analyses\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -211,9 +219,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [ { "entityType": "analysis", @@ -304,19 +316,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -446,7 +455,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/comparison.png b/artifacts/verification/w6/states/comparison.png index b31c3d2..4fa1535 100644 Binary files a/artifacts/verification/w6/states/comparison.png and b/artifacts/verification/w6/states/comparison.png differ diff --git a/artifacts/verification/w6/states/comparison.txt b/artifacts/verification/w6/states/comparison.txt index 8979e9b..bf7e7e1 100644 --- a/artifacts/verification/w6/states/comparison.txt +++ b/artifacts/verification/w6/states/comparison.txt @@ -1,4 +1,4 @@ - activity · analyses › /compare · frozen runs · complete · 1/1 + analyses › /compare · frozen runs Braid starter · pi · fixture/deterministic ──────────────────────────────────────────────────────────────────────────────── saved result: replayed from the local journal baseline run: run-route-serial @@ -21,4 +21,4 @@ paired.correctness.b10: 0 paired.correctness.mcnemar.b: 0 ──────────────────────────────────────────────────────────────────────────────── - ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab filter: analys… + ↑↓ previous/next · PgUp/PgDn page · ←/esc back · page 1/4 · tab: analyses diff --git a/artifacts/verification/w6/states/empty.json b/artifacts/verification/w6/states/empty.json index 4663069..45eb041 100644 --- a/artifacts/verification/w6/states/empty.json +++ b/artifacts/verification/w6/states/empty.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n›\n\n\nprofile Braid starter · pi / deterministic Ctrl+P commands\n", + "frame": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n Ask Braid starter anything.\n\n────────────────────────────────────────────────────────────────────────────────\n› new message\n\n\n─────────────── type / for commands · Alt+Enter newline · paste ────────────────\nprofile Braid starter · pi · via local\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -358,7 +367,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/empty.png b/artifacts/verification/w6/states/empty.png index b93509e..5351b0b 100644 Binary files a/artifacts/verification/w6/states/empty.png and b/artifacts/verification/w6/states/empty.png differ diff --git a/artifacts/verification/w6/states/empty.txt b/artifacts/verification/w6/states/empty.txt index fdd18e5..f188c65 100644 --- a/artifacts/verification/w6/states/empty.txt +++ b/artifacts/verification/w6/states/empty.txt @@ -1,7 +1,4 @@ - Ask Braid starter anything. - - @@ -17,8 +14,11 @@ + Ask Braid starter anything. -› +──────────────────────────────────────────────────────────────────────────────── +› new message -profile Braid starter · pi / deterministic Ctrl+P commands +─────────────── type / for commands · Alt+Enter newline · paste ──────────────── +profile Braid starter · pi · via local diff --git a/artifacts/verification/w6/states/failure-or-reconnect.json b/artifacts/verification/w6/states/failure-or-reconnect.json index a9be76c..fda7982 100644 --- a/artifacts/verification/w6/states/failure-or-reconnect.json +++ b/artifacts/verification/w6/states/failure-or-reconnect.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 8, - "frame": " › W6 failure state\n\n error · unknown\n RUNTIME_BACKEND_ERROR\n failed\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n›\n\n\nprofile Braid starter · pi / deterministic failed · /activity details · /new\n", + "frame": " › W6 failure state\n\n error · unknown\n RUNTIME_BACKEND_ERROR\n failed\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n› new message\n\n\n─────────────── type / for commands · Alt+Enter newline · paste ────────────────\nprofile Braid starter · pi · via local failed · /activity details · /new\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 8, "sequence": 8, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [ { @@ -98,8 +105,14 @@ "turnId": "turn-000002", "operationId": "op-terminal-000002", "status": "failed", + "startedAt": "2026-08-01T00:00:00.000Z", + "updatedAt": "2026-08-01T00:00:00.000Z", + "terminalAt": "2026-08-01T00:00:00.000Z", + "durationMs": 0, "inputTokens": 0, "outputTokens": 0, + "tokensKnown": false, + "usdKnown": false, "tokenStatus": "unknown", "costStatus": "unknown", "model": "fixture/deterministic", @@ -161,12 +174,13 @@ }, "view": { "revision": 8, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -221,6 +235,8 @@ { "id": "run-000001", "turnId": "turn-000002", + "conversationId": "conv-1", + "branchId": "branch-1", "operationId": "op-terminal-000002", "status": "failed", "profileName": "Braid starter", @@ -233,7 +249,8 @@ "output": 0, "tokenStatus": "unknown", "costStatus": "unknown", - "model": "fixture/deterministic" + "model": "fixture/deterministic", + "elapsedMs": 0 }, "runner": "pi", "provider": "agent-runtime", @@ -292,6 +309,7 @@ "title": "run run-000001", "status": "failed", "detail": "RUNTIME_FINAL_ERROR", + "elapsedMs": 0, "startedAt": "2026-08-01T00:00:00.000Z", "occurredAt": "2026-08-01T00:00:00.000Z", "runId": "run-000001", @@ -315,7 +333,7 @@ "title": "event backend_start", "status": "failed", "detail": "sequence 1", - "occurredAt": "2026-08-12T12:37:54.196Z", + "occurredAt": "2026-08-16T11:05:44.117Z", "sourceEventId": "run-000001:runtime:1", "runId": "run-000001", "entityType": "run", @@ -327,7 +345,7 @@ "title": "error", "status": "failed", "detail": "RUNTIME_BACKEND_ERROR", - "occurredAt": "2026-08-12T12:37:54.216Z", + "occurredAt": "2026-08-16T11:05:44.126Z", "sourceEventId": "run-000001:runtime:2", "runId": "run-000001", "entityType": "run", @@ -351,6 +369,9 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" }, { @@ -360,6 +381,9 @@ "status": "failed", "depth": 2, "edgeLabel": "continued", + "parentIds": [ + "branch:branch-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" }, { @@ -369,10 +393,15 @@ "status": "failed", "depth": 3, "edgeLabel": "attached", + "parentIds": [ + "turn:turn-000002" + ], "runner": "pi", + "elapsedMs": 0, "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "details": { "title": "run run-000001", @@ -389,14 +418,6 @@ "label": "output tokens", "value": "0" }, - { - "label": "token measurement", - "value": "unknown" - }, - { - "label": "cost measurement", - "value": "unknown" - }, { "label": "model", "value": "fixture/deterministic" @@ -413,19 +434,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -553,7 +571,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } }, @@ -591,6 +609,7 @@ "revision": 3, "kind": "run.requested", "payload": { + "runId": "run-000001", "text": "W6 failure state", "admission": { "version": 1, @@ -613,6 +632,7 @@ }, "harness": "pi" }, + "interactions": {}, "model": "fixture/deterministic", "runner": "pi" }, @@ -647,10 +667,10 @@ "provider": "agent-runtime" }, "admissionStatus": "admitted", - "requestDigest": "f080a8e87a51f6c8d898986826372cdf1767d2eb5101fad7cfb5df90caf5eabb", + "requestDigest": "aa53ce68f4954eb1bed81136389562901fa25272dd2225eb95bb1d7db499436d", "capabilitiesDigest": "93c4c21593f9e2fa1fe68ce73da84f48c173f1e13269633678b512a16a81a001", "materializationDigest": "46c7162244a3aa789bd2703e2805a3faa0da1a87d83d97484fa26294a0b00671", - "digest": "b069aefd4ed0b6d1f93b2f14f40d280b3e60c5d45b63cb76294571f32be2444e" + "digest": "64cf1e2fa64af9b21412dd3b9ebc4a2e7d2837e57d55ca090fe080435a9020b9" }, "status": "admitted" } @@ -663,10 +683,10 @@ "value": { "kind": "effect.upserted", "effect": { - "id": "effect-op-terminal-000002-cf99cee4622510fbb428f652", + "id": "effect-op-terminal-000002-9ffdb9513363cf2bbcf05ab0", "operationId": "op-terminal-000002", "effectKind": "run.execute", - "requestDigest": "cf99cee4622510fbb428f6528d8bf27f2101fdaf5757203e738e46db132e8ca6", + "requestDigest": "9ffdb9513363cf2bbcf05ab0c3ee45fc895dc29003d622df749e5fa199e5e9b6", "kind": "send", "status": "pending", "attempt": 1, @@ -681,16 +701,17 @@ "revision": 5, "kind": "run.provider.event", "payload": { + "runId": "run-000001", "source": { "eventId": "run-000001:runtime:1", "providerSequence": 1, - "occurredAt": "2026-08-12T12:37:54.196Z", + "occurredAt": "2026-08-16T11:05:44.117Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "unknown": { "type": "backend_start", "task": { - "id": "turn-1139ed2e-9b39-4c46-995c-10007663d712", + "id": "turn-560229eb-c5c8-41cc-ae83-a4bd8c46ebad", "intent": "W6 failure state", "metadata": { "identity": { @@ -700,11 +721,11 @@ } }, "session": { - "id": "badfe62c-65d2-4dc1-87ac-ac9276afc5e5", + "id": "e545428d-4105-4773-88d8-1eb36c26f90f", "backend": "executor", "status": "active", - "createdAt": "2026-08-12T12:37:54.196Z", - "updatedAt": "2026-08-12T12:37:54.196Z", + "createdAt": "2026-08-16T11:05:44.117Z", + "updatedAt": "2026-08-16T11:05:44.117Z", "metadata": "[unavailable]" }, "backend": "router", @@ -726,16 +747,16 @@ }, "execution": { "kind": "request", - "id": "turn-1139ed2e-9b39-4c46-995c-10007663d712" + "id": "turn-560229eb-c5c8-41cc-ae83-a4bd8c46ebad" }, "materializer": "router-prompt-model" }, "executionBindings": [ { "status": "known", - "attemptId": "turn-1139ed2e-9b39-4c46-995c-10007663d712:attempt:87b9a1e3-6bae-4569-9634-2a92fd9dd874", - "materializationReceiptDigest": "sha256:901b372639b868da687202e07ac113cc883a6518f868e1db3b4975fc58657f76", - "bindingDigest": "sha256:ffd96086c5ed9c2a2b810e793bb42c06e25cedafe8e1b75879cb80887661a659", + "attemptId": "turn-560229eb-c5c8-41cc-ae83-a4bd8c46ebad:attempt:9b5c7880-8346-4b79-a339-e27567430d22", + "materializationReceiptDigest": "sha256:4cee36369ee5cd7d6867ee462a20e76c8edb1f54e1dad043b8cbec34af35603d", + "bindingDigest": "sha256:a93cc37b04fa2b3da7f09db554744f4564f0b82f94f43a54d2510f8bd046a850", "descriptor": { "kind": "router-request", "transport": "http", @@ -747,10 +768,10 @@ "timeoutMs": null }, "timing": { - "startedAt": "2026-08-12T12:37:54.194Z" + "startedAt": "2026-08-16T11:05:44.113Z" } }, - "timestamp": "2026-08-12T12:37:54.196Z" + "timestamp": "2026-08-16T11:05:44.117Z" } } }, @@ -759,10 +780,11 @@ "revision": 6, "kind": "run.error", "payload": { + "runId": "run-000001", "source": { "eventId": "run-000001:runtime:2", "providerSequence": 2, - "occurredAt": "2026-08-12T12:37:54.216Z", + "occurredAt": "2026-08-16T11:05:44.126Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "error": { @@ -777,10 +799,11 @@ "revision": 7, "kind": "run.finished", "payload": { + "runId": "run-000001", "source": { "eventId": "run-000001:runtime:3", "providerSequence": 3, - "occurredAt": "2026-08-12T12:37:54.221Z", + "occurredAt": "2026-08-16T11:05:44.131Z", "receivedAt": "2026-08-01T00:00:00.000Z" }, "status": "failed", @@ -804,10 +827,10 @@ "value": { "kind": "effect.upserted", "effect": { - "id": "effect-op-terminal-000002-cf99cee4622510fbb428f652", + "id": "effect-op-terminal-000002-9ffdb9513363cf2bbcf05ab0", "operationId": "op-terminal-000002", "effectKind": "run.execute", - "requestDigest": "cf99cee4622510fbb428f6528d8bf27f2101fdaf5757203e738e46db132e8ca6", + "requestDigest": "9ffdb9513363cf2bbcf05ab0c3ee45fc895dc29003d622df749e5fa199e5e9b6", "kind": "send", "status": "failed", "attempt": 1, diff --git a/artifacts/verification/w6/states/failure-or-reconnect.png b/artifacts/verification/w6/states/failure-or-reconnect.png index b1adc61..58df31a 100644 Binary files a/artifacts/verification/w6/states/failure-or-reconnect.png and b/artifacts/verification/w6/states/failure-or-reconnect.png differ diff --git a/artifacts/verification/w6/states/failure-or-reconnect.txt b/artifacts/verification/w6/states/failure-or-reconnect.txt index 4f589ca..9d1e4e4 100644 --- a/artifacts/verification/w6/states/failure-or-reconnect.txt +++ b/artifacts/verification/w6/states/failure-or-reconnect.txt @@ -16,9 +16,9 @@ +──────────────────────────────────────────────────────────────────────────────── +› new message -› - - -profile Braid starter · pi / deterministic failed · /activity details · /new +─────────────── type / for commands · Alt+Enter newline · paste ──────────────── +profile Braid starter · pi · via local failed · /activity details · /new diff --git a/artifacts/verification/w6/states/fork-preview.json b/artifacts/verification/w6/states/fork-preview.json index e0bff81..a34aba9 100644 --- a/artifacts/verification/w6/states/fork-preview.json +++ b/artifacts/verification/w6/states/fork-preview.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -391,7 +400,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/fork-preview.png b/artifacts/verification/w6/states/fork-preview.png index ae93f8e..732ed69 100644 Binary files a/artifacts/verification/w6/states/fork-preview.png and b/artifacts/verification/w6/states/fork-preview.png differ diff --git a/artifacts/verification/w6/states/graph-or-analysis.json b/artifacts/verification/w6/states/graph-or-analysis.json index f20e5f2..4cc6a50 100644 --- a/artifacts/verification/w6/states/graph-or-analysis.json +++ b/artifacts/verification/w6/states/graph-or-analysis.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": " conversation graph · 2 Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n ✓ New conversation · conversation · complete · ─attached→\n › └ ✓ Main · branch · complete · current · ─attached→\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter/→ open · ←/esc close\n", + "frame": " conversation graph · 2 Braid starter · pi · fixture/deterministic\n────────────────────────────────────────────────────────────────────────────────\n ✓ ▾ New conversation · conversation · complete · ─attached→\n › └ ✓ Main · branch · complete · current · ─attached→\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter/→ open · ←/esc close · space collapse · w waiting\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -343,7 +352,7 @@ } }, "draft": "", - "selectedSurface": "transcript", + "selectedSurface": "graph", "appearance": { "color": "256", "highContrast": false, @@ -358,7 +367,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/graph-or-analysis.png b/artifacts/verification/w6/states/graph-or-analysis.png index 0e6302d..d574b81 100644 Binary files a/artifacts/verification/w6/states/graph-or-analysis.png and b/artifacts/verification/w6/states/graph-or-analysis.png differ diff --git a/artifacts/verification/w6/states/graph-or-analysis.txt b/artifacts/verification/w6/states/graph-or-analysis.txt index 273207a..d12fb91 100644 --- a/artifacts/verification/w6/states/graph-or-analysis.txt +++ b/artifacts/verification/w6/states/graph-or-analysis.txt @@ -1,6 +1,6 @@ - conversation graph · 2 Braid starter · pi · fixture/deterministic + conversation graph · 2 Braid starter · pi · fixture/deterministic ──────────────────────────────────────────────────────────────────────────────── - ✓ New conversation · conversation · complete · ─attached→ + ✓ ▾ New conversation · conversation · complete · ─attached→ › └ ✓ Main · branch · complete · current · ─attached→ @@ -21,4 +21,4 @@ ──────────────────────────────────────────────────────────────────────────────── - ↑↓ move · enter/→ open · ←/esc close + ↑↓ move · enter/→ open · ←/esc close · space collapse · w waiting diff --git a/artifacts/verification/w6/states/interaction.json b/artifacts/verification/w6/states/interaction.json index 5539211..36b2b7c 100644 --- a/artifacts/verification/w6/states/interaction.json +++ b/artifacts/verification/w6/states/interaction.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": " permission Braid starter @ pi · run fixture-run-1 · timeout unknown\n────────────────────────────────────────────────────────────────────────────────\n Allow the fixture tool to inspect the selected file?\n src/app/application.ts · read-only\n The fixture requests a bounded read for a real interaction preview.\n export class BraidApplication {\n\n › 1. Approve\n Continue this request\n 2. Reject\n 3. Cancel\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter confirm · 1-3 quick select · esc cancel · alt+a automate\n", + "frame": " permission Braid starter · pi\n────────────────────────────────────────────────────────────────────────────────\n Allow the fixture tool to inspect the selected file?\n src/app/application.ts · read-only\n The fixture requests a bounded read for a real interaction preview.\n export class BraidApplication {\n\n › 1. Approve\n Continue this request\n 2. Reject\n 3. Cancel\n\n\n\n\n\n\n\n\n\n\n\n────────────────────────────────────────────────────────────────────────────────\n ↑↓ move · enter confirm · 1-3 quick select · ←/esc cancel · alt+a automate\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -238,9 +246,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -252,19 +264,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -393,7 +402,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/interaction.png b/artifacts/verification/w6/states/interaction.png index 6937c6a..4ffd5f3 100644 Binary files a/artifacts/verification/w6/states/interaction.png and b/artifacts/verification/w6/states/interaction.png differ diff --git a/artifacts/verification/w6/states/interaction.txt b/artifacts/verification/w6/states/interaction.txt index 77ca918..0408f1e 100644 --- a/artifacts/verification/w6/states/interaction.txt +++ b/artifacts/verification/w6/states/interaction.txt @@ -1,4 +1,4 @@ - permission Braid starter @ pi · run fixture-run-1 · timeout unknown + permission Braid starter · pi ──────────────────────────────────────────────────────────────────────────────── Allow the fixture tool to inspect the selected file? src/app/application.ts · read-only @@ -21,4 +21,4 @@ ──────────────────────────────────────────────────────────────────────────────── - ↑↓ move · enter confirm · 1-3 quick select · esc cancel · alt+a automate + ↑↓ move · enter confirm · 1-3 quick select · ←/esc cancel · alt+a automate diff --git a/artifacts/verification/w6/states/narrow.json b/artifacts/verification/w6/states/narrow.json index 5d3bf4f..59d9464 100644 --- a/artifacts/verification/w6/states/narrow.json +++ b/artifacts/verification/w6/states/narrow.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 40, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "\n Ask Braid starter anything.\n\n\n\n\n\n\n›\n\n\nBraid starter Ctrl+P commands\n", + "frame": "\n\n\n\n Ask Braid starter anything.\n\n────────────────────────────────────────\n› new message\n\n\n type / for commands · Alt+Enter newlin…\nBraid starter\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -358,7 +367,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/narrow.png b/artifacts/verification/w6/states/narrow.png index 442393c..a7b4c6c 100644 Binary files a/artifacts/verification/w6/states/narrow.png and b/artifacts/verification/w6/states/narrow.png differ diff --git a/artifacts/verification/w6/states/narrow.txt b/artifacts/verification/w6/states/narrow.txt index 913d8d5..436119d 100644 --- a/artifacts/verification/w6/states/narrow.txt +++ b/artifacts/verification/w6/states/narrow.txt @@ -1,12 +1,12 @@ - Ask Braid starter anything. - - + Ask Braid starter anything. -› +──────────────────────────────────────── +› new message -Braid starter Ctrl+P commands + type / for commands · Alt+Enter newlin… +Braid starter diff --git a/artifacts/verification/w6/states/profile.json b/artifacts/verification/w6/states/profile.json index 903ced5..6de005b 100644 --- a/artifacts/verification/w6/states/profile.json +++ b/artifacts/verification/w6/states/profile.json @@ -4,8 +4,8 @@ "source": { "binary": "packed real binary from clean npm install", "binarySha256": "141288e0fe917635d723b4b70d464dc49baff14cc356f54de1d3f8faa5d8254f", - "tarball": "tangle-network-braid-0.1.1.tgz", - "tarballSha256": "9bbd7b5b9f78b3d202dbb09d4375970edfd4965ffc5ad94b04bd7fa6ce01c4c7" + "tarball": "tangle-network-braid-0.2.0.tgz", + "tarballSha256": "50e56ffd482a1cdd0ecd6c2a9630a4d9171fe8bacee35cfdbc58977695e815b1" }, "dimensions": { "columns": 80, @@ -14,7 +14,7 @@ "terminal": "node-pty/xterm-256color", "provenance": { "renderer": { - "package": "@earendil-works/pi-tui@0.84.1", + "package": "@earendil-works/pi-tui@0.84.2", "pty": "node-pty@1.1.0", "emulator": "@xterm/headless@6.0.0", "node": "v24.13.0", @@ -30,7 +30,7 @@ }, "capturePhase": "atomic-signal-frame", "captureRevision": 1, - "frame": "\n Ask Braid starter anything.\n\n\n\n\n Active profile · Braid starter\n source Braid starter · trusted · read-only\n runner pi · model fixture/deterministic\n thinking none · max output provider default\n digest sha256:44ed91c9…8cfa0a14\n profiles\n ──────────────────────────────────────────────────────────────────────\n >\n → ✓ Braid starter trusted · read-only · Braid starte\n ──────────────────────────────────────────────────────────────────────\n enter · ^V valid · ^S save · esc\n\n\n\n\n\n\nprofile Braid starter · pi / deterministic Ctrl+P commands\n", + "frame": "\n\n\n\n\n\n\n\n\n Braid starter\n trusted · read-only\n runner pi · model fixture/deterministic\n thinking none · limits provider default\n digest sha256:44ed91c9…8cfa0a14\n ←/esc close\n", "packedState": { "schemaVersion": 2, "capturePhase": "atomic-signal-frame", @@ -38,7 +38,7 @@ "schemaVersion": 2, "revision": 1, "sequence": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "conversationId": "conv-1", "branchId": "branch-1", "conversations": [ @@ -60,6 +60,13 @@ "reasoningEffort": "none" } }, + "runConfiguration": { + "profileName": "Braid starter", + "runner": "pi", + "model": "fixture/deterministic", + "effort": "none", + "overrides": {} + }, "draft": "", "messages": [], "runs": [], @@ -113,12 +120,13 @@ }, "view": { "revision": 1, - "workspace": "/home/drew/code/braid-transcript-first-wt", + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816", "profileName": "Braid starter", "profileDigest": "44ed91c937fd4079fe01c49f6bc76413656886e5aa8b2351590d3a888cfa0a14", "runner": "pi", "model": "fixture/deterministic", "effort": "none", + "runOverrides": {}, "connection": "deterministic fixture", "conversationId": "conv-1", "conversationTitle": "New conversation", @@ -202,9 +210,13 @@ "status": "complete", "depth": 1, "edgeLabel": "attached", + "parentIds": [ + "conversation:conv-1" + ], "startedAt": "2026-08-01T00:00:00.000Z" } ], + "graphQuery": "", "entityDetails": [], "capabilities": { "conversation.create": { @@ -216,19 +228,16 @@ "source": "application" }, "run.runner": { - "available": false, - "source": "application", - "reason": "Runner overrides require the current profile compatibility helpers" + "available": true, + "source": "application" }, "run.model": { - "available": false, - "source": "application", - "reason": "Model overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "run.effort": { - "available": false, - "source": "application", - "reason": "Effort overrides require provider capabilities from the current connection" + "available": true, + "source": "application" }, "conversation.branch": { "available": true, @@ -358,7 +367,7 @@ "payload": { "value": { "kind": "workspace.opened", - "workspace": "/home/drew/code/braid-transcript-first-wt" + "workspace": "/home/drew/code/.worktrees/braid-exact-interactive-20260816" } } } diff --git a/artifacts/verification/w6/states/profile.png b/artifacts/verification/w6/states/profile.png index 1491f88..f6aee72 100644 Binary files a/artifacts/verification/w6/states/profile.png and b/artifacts/verification/w6/states/profile.png differ diff --git a/artifacts/verification/w6/states/profile.txt b/artifacts/verification/w6/states/profile.txt index 2e6be89..30b0cd7 100644 --- a/artifacts/verification/w6/states/profile.txt +++ b/artifacts/verification/w6/states/profile.txt @@ -1,24 +1,15 @@ - Ask Braid starter anything. - Active profile · Braid starter - source Braid starter · trusted · read-only - runner pi · model fixture/deterministic - thinking none · max output provider default - digest sha256:44ed91c9…8cfa0a14 - profiles - ────────────────────────────────────────────────────────────────────── - > - → ✓ Braid starter trusted · read-only · Braid starte - ────────────────────────────────────────────────────────────────────── - enter · ^V valid · ^S save · esc - - -profile Braid starter · pi / deterministic Ctrl+P commands + Braid starter + trusted · read-only + runner pi · model fixture/deterministic + thinking none · limits provider default + digest sha256:44ed91c9…8cfa0a14 + ←/esc close diff --git a/docs/01-product-contract.md b/docs/01-product-contract.md index 14b0c31..d82cb66 100644 --- a/docs/01-product-contract.md +++ b/docs/01-product-contract.md @@ -14,7 +14,7 @@ The runner defines which coding program executes one run. Braid defines how the user sees, directs, forks, analyzes, approves, and audits that work. -The `AgentProfile` is portable intent, while an admitted run stores an immutable receipt for the effective runner, model, reasoning effort, maximum output, connection, and execution environment. +The `AgentProfile` is portable intent, while an admitted run stores an immutable receipt for the effective runner, model, reasoning effort, visible, reasoning, and total output limits, connection, and execution environment. Changing the selected profile for a future run never rewrites the receipt or identity of an existing run. @@ -84,7 +84,7 @@ They need analyses to remain separate from the conversation they analyze unless 1. The user starts Braid in a project directory. 2. Braid discovers or prompts for a CLI Bridge connection. 3. The user selects an existing profile. -4. Braid shows the effective runner, model, reasoning effort, maximum output, connection, execution location, and any unsupported profile dimensions before sending. +4. Braid shows the effective runner, model, reasoning effort, configured visible, reasoning, and total output limits, connection, execution location, and any unsupported profile dimensions before sending. 5. CLI Bridge materializes the exact profile and starts the selected local runner. 6. Braid streams normalized events, records the materialization receipt, and reconnects from the last event identifier after a transport loss. 7. Explicit cancellation waits for a terminal cancellation result instead of treating a closed stream as success. @@ -157,7 +157,9 @@ Activity also keeps direct turn usage, analysis usage, and runtime-worker usage ### Fast default, full depth on demand -The default composer keeps profile, connection, runner, model, effort, branch, run state, and cost visible without occupying the transcript. +The default composer keeps profile, runner, model, backend, effort, run state, and measured cost visible without occupying the transcript. + +Wide terminals add configured output limits and measured sandbox facts only when complete atomic groups fit. Advanced profile fields, event details, receipts, traces, and environment operations live behind focused overlays. diff --git a/docs/02-experience-specification.md b/docs/02-experience-specification.md index e9f5dc0..2976178 100644 --- a/docs/02-experience-specification.md +++ b/docs/02-experience-specification.md @@ -10,7 +10,7 @@ Configuration, event detail, graphs, and supervision appear only when requested No screen uses decorative cards, repeated labels, or explanatory copy that restates visible controls. -When a run is active or selected, its immutable execution receipt is the source for displayed runner, model, reasoning, output limit, connection, and environment values. +When a run is active or selected, its immutable execution receipt is the source for displayed runner, model, reasoning, visible, reasoning, and total output limits, connection, and environment values. The pending profile is shown only when no run receipt exists for the focused context. @@ -72,7 +72,7 @@ If a connection is unavailable, the conversation opens offline and retains full ## Main shell -### Standard layout from 80 to 119 columns +### Standard layout from 60 to 99 columns ```text user Explain the failing integration test. @@ -86,30 +86,37 @@ If a connection is unavailable, the conversation opens offline and retains full › _ - profile Release engineer · pi / glm-5.2 · Local CLI Bridge Ctrl+P commands + profile Release engineer · pi / glm-5.2 · via Local CLI Bridge + in 1.2k · out 567 · $0.0312 · latency 120ms ``` The main shell has no persistent header. The transcript begins at the top of the viewport and remains visually primary. -One quiet line below the composer identifies the profile, runner, model, and connection. +The calm context rail uses one compact identity/status row from 60 through 99 columns. + +At 100 columns and above, the first row labels the profile, harness, model, and backend. + +It adds a distinct connection, configured reasoning and caps, known sandbox facts, and measured values when they fit. Remote or sandbox execution appears there only when location changes the user's mental model. -The composer has no border during normal input and keeps at least three usable rows. +The composer has separators above and below normal input and keeps at least three usable rows. -Autocomplete adds one divider between input and results. +Autocomplete appears below the composer separator without adding another outer panel. The composer grows to at most 40% of terminal height, then scrolls internally. -During work, the context line gives priority to state, cancel, queue, and steer controls. +During work, the context rail gives priority to state, cancel, queue, and steer controls. At rest, it gives priority to execution identity and measured usage. Transient confirmations temporarily replace the context line so they remain visible at 40 columns. -The context line omits unavailable values instead of displaying placeholders. +The context rail omits unavailable values instead of displaying placeholders. + +At widths below 60, it keeps only the profile and status. The activity view keeps direct turn totals, analysis totals, and worker-tree totals separate. @@ -117,7 +124,7 @@ Each visible total identifies reported values, estimates, or observed minimums w ### Usage and execution inspector -The wide context line shows direct conversation tokens and cost when measured values exist. +The wide context rail shows sandbox host, machine, verified region, complete resource groups, and direct token, cost, or latency measurements when known. It labels estimates and observed minimums explicitly. @@ -145,7 +152,7 @@ The physical machine IP, effective resource allocation, and per-sandbox CPU, RAM Headless state retains complete measurement status, including unavailable fields, for automation and audit. -### Wide layout at 120 columns and above +### Wide layout at 100 columns and above ```text user Fix the replay race and run the focused tests. │ live work @@ -156,18 +163,19 @@ Headless state retains complete measurement status, including unavailable fields › _ - profile reviewer · codex / openai/gpt-5.6 · sandbox · xhigh working · Ctrl+C cancel + profile reviewer · harness codex · model openai/gpt-5.6 · backend Sandbox · think xhigh + working · Ctrl+C cancel ``` -The right pane is hidden by default, including while a run is active. +The main shell has no activity pane, including while a run is active. -F2 toggles one live-work pane that contains only active, waiting, detached, or reconnecting work. +F2 opens the focused activity browser for active and historical work. -Completed history remains available through `/activity` instead of accumulating in the pane. +The activity browser uses one list/detail surface and keeps direct turns, analyses, and workers distinct. Graph, details, and workspace views use focused overlays. -The pane never reduces the transcript below 72 columns; below that boundary it becomes an overlay. +No secondary surface reduces the transcript below its minimum width because secondary surfaces replace the shell. ### Narrow layout below 80 columns @@ -312,7 +320,7 @@ Destructive or externally consequential commands show the resolved target before | `PageUp` / `PageDown` | Scroll the focused transcript or list by one viewport | | `Home` / `End` | Move within the editor or to list boundaries according to focus | | `Alt+Up` / `Alt+Down` | Navigate adjacent branches or graph nodes | -| `F2` | Toggle the wide activity pane | +| `F2` | Open the focused activity browser | | `?` | Open contextual help outside the composer, or type a question mark inside it | Keybindings are remappable from named actions rather than raw handler code. @@ -544,7 +552,7 @@ The browser defaults to all activity and uses `Tab` to cycle through runs, analy It lists an explicit root run binding, workers, status, current action, elapsed time, token use, cost, latency, last event, and log tail when reported. -Each activity row retains its source kind, profile digest, runner, model, reasoning effort, output limit, connection, provider session, and environment when the record reports them. +Each activity row retains its source kind, profile digest, runner, model, reasoning effort, visible, reasoning, and total output limits, connection, provider session, and environment when the record reports them. Direct turns, analyses, and workers keep their own usage totals and are never merged into a single spend number. diff --git a/docs/03-architecture.md b/docs/03-architecture.md index ea659b7..7d585f6 100644 --- a/docs/03-architecture.md +++ b/docs/03-architecture.md @@ -12,7 +12,7 @@ The presentation layer uses one immutable execution-target projection for the fo That projection reads the exact run receipt when a run exists and falls back to the pending profile only before admission. -This keeps profile identity, run identity, connection, environment, and output limits coherent without a shared god object or duplicated formatting logic. +This keeps profile identity, run identity, connection, environment, and visible, reasoning, and total output limits coherent without a shared god object or duplicated formatting logic. ## System context @@ -329,7 +329,7 @@ A sequence gap pauses reduction for that run and requests replay from the last c If the provider cannot fill the gap, the run becomes incomplete with an explicit missing range and the transcript is never presented as complete. -The run receipt is projected independently from the event document so the terminal and headless views can identify the exact profile, runner, model, effort, output limit, connection, and environment for each run. +The run receipt is projected independently from the event document so the terminal and headless views can identify the exact profile, runner, model, effort, visible, reasoning, and total output limits, connection, and environment for each run. Analysis events retain their own analysis run identity and source digest, while runtime worker events retain their supervisor and parent-worker identities. diff --git a/docs/04-runtime-contracts.md b/docs/04-runtime-contracts.md index c8a3e9c..7eeba80 100644 --- a/docs/04-runtime-contracts.md +++ b/docs/04-runtime-contracts.md @@ -10,30 +10,32 @@ When a current package blocks a real Braid flow, Braid records the unavailable a ## Evidence baseline -The following published versions were queried from npm and their installed declarations were inspected directly on 2026-08-14. +The following published versions were queried from npm and their installed declarations and implementations were inspected directly on 2026-08-15. | Package | Installed version | Braid boundary | | --- | ---: | --- | -| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.52.0` | Canonical profile, capabilities, environment, stream, portable context, and interaction contracts | -| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.134.4` | Sole execution layer; exact executor, retained-run, environment-provider, and terminal-monitor exports | -| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.145.11` | Run records, judges, trace analysts, comparisons, and feedback trajectories | -| `@tangle-network/agent-provider-cli-bridge` | `0.7.1` | CLI Bridge environment adapter with live streaming, replay, retry-safe turns, retained control, and explicit cancel | -| `@tangle-network/agent-provider-tangle` | `0.10.0` | Tangle environment adapter over the sandbox client | -| `@tangle-network/sandbox` | `0.26.1` | Tangle cloud client used by the provider | +| [`@tangle-network/agent-interface`](https://github.com/tangle-network/agent-sdk/tree/main/packages/agent-interface) | `0.53.0` | Canonical profile, capabilities, environment, stream, portable context, and interaction contracts | +| [`@tangle-network/agent-runtime`](https://github.com/tangle-network/agent-runtime) | `0.135.3` | Sole execution layer; exact executor, retained-run, environment-provider, and terminal-monitor exports | +| [`@tangle-network/agent-eval`](https://github.com/tangle-network/agent-eval) | `0.145.15` | Run records, judges, trace analysts, comparisons, and feedback trajectories | +| `@tangle-network/agent-provider-cli-bridge` | `0.7.3` | CLI Bridge environment adapter with live streaming, replay, retry-safe turns, retained control, and explicit cancel | +| `@tangle-network/agent-provider-tangle` | `0.11.2` | Tangle environment adapter over the sandbox client | +| `@tangle-network/sandbox` | `0.27.0` | Tangle cloud client used by the provider | -The installed runtime publishes `agent-eval >=0.145.11 <0.146.0`, `agent-interface >=0.52.0 <0.53.0`, and `sandbox >=0.26.1 <0.27.0` as peer ranges. +The installed runtime publishes `agent-eval >=0.145.15 <0.146.0`, `agent-interface >=0.53.0 <0.54.0`, and `sandbox >=0.27.0 <0.28.0` as peer ranges. -Braid exercises runtime `0.134.4` with interface `0.52.0`, eval `0.145.11`, CLI Bridge adapter `0.7.1`, Tangle adapter `0.10.0`, and sandbox `0.26.1`. +Braid exercises runtime `0.135.3` with interface `0.53.0`, eval `0.145.15`, CLI Bridge adapter `0.7.3`, Tangle adapter `0.11.2`, and sandbox `0.27.0`. The lockfile pins the registry integrity for every installed package. -`pnpm peers check` reports no peer dependency issues for this package graph. +`pnpm peers check` reports no peer dependency issues for this installed set. -The dependency graph resolves one `agent-interface` `0.52.0` instance without a workspace override. +The Braid root and Runtime peer cohort resolve `agent-interface` `0.53.0` without a workspace override. -[Agent-runtime issue 803](https://github.com/tangle-network/agent-runtime/issues/803) records the interface peer mismatch fixed in Runtime `0.132.11`. +The current provider packages retain nested `agent-interface` `0.54.0` copies for their own peer cohort. -[Agent-runtime issue 737](https://github.com/tangle-network/agent-runtime/issues/737) records the Sandbox peer mismatch fixed in Runtime `0.132.11`. +Historical snapshot: [Agent-runtime issue 803](https://github.com/tangle-network/agent-runtime/issues/803) records the interface peer mismatch fixed in Runtime `0.132.11`. + +Historical snapshot: [Agent-runtime issue 737](https://github.com/tangle-network/agent-runtime/issues/737) records the Sandbox peer mismatch fixed in Runtime `0.132.11`. Braid imports only the canonical root `agent-interface` entry point behind two local modules. @@ -47,9 +49,15 @@ Braid composes the current provider packages through `agent-runtime` and keeps a The CLI Bridge and Tangle providers remain transport implementations rather than alternate application shells. -The current interface exposes optional interaction-response methods, but neither installed provider declaration exposes that operation and the runtime turn API does not add one. +Interface `0.53.0` adds the requested interaction map to `AgentTurnInput` and the retained environment input. + +The map is not a capability declaration. + +Braid derives it only after exact per-run environment capabilities are admitted, and sends an explicit empty map when response idempotency is absent or unknown. + +The current runtime and installed provider implementations do not preserve that field through the retained dispatch boundary. -Braid therefore renders interactions but disables response actions for those providers until a real run proves support. +Braid therefore keeps the retained interactive path unverified until Runtime and the provider adapters forward the field end to end. At implementation start and before every release, rerun `npm view @tangle-network/ version` and inspect the installed declarations because these packages change frequently. @@ -118,6 +126,10 @@ Method presence remains a second defensive check because a false capability must `streamAgentTurn` accepts only a Runtime-owned executor paired with one exact `AgentProfile`. +Historical snapshot: its published `0.135.0` API did not accept a per-turn interaction posture. + +Braid therefore does not claim interaction delivery for the ephemeral `AgentRuntimeExecutionPort` path. + It normalizes the terminal result into `RuntimeStreamEvent` and guarantees one final event. The current runtime event union includes lifecycle, text delta, reasoning delta, tool call, tool result, LLM call, artifact, proposal, error, and final events. @@ -142,7 +154,7 @@ A partial or inconsistent positive-input split must set `cacheBreakdownKnown: fa Braid rejects unknown fields and inconsistent totals under token-bearing record names. -Runtime `0.132.12` currently buffers executor output until the executor settles. +Historical snapshot: Runtime `0.132.12` buffered executor output until the executor settled. Braid therefore receives terminal text, tool calls, usage, and result evidence but cannot render provider text deltas live through this path. @@ -168,7 +180,7 @@ The capability is a typed Runtime-executor tag, not a provider-specific callback When the tag is present, `/cancel` calls the public Runtime `Executor.teardown('infinity')` operation and waits for its result before committing control state. -The installed Runtime `0.132.12` bridge executor implements that operation by posting `POST /v1/runs/:id/cancel` and waiting for a terminal bridge snapshot. +Historical snapshot: the installed Runtime `0.132.12` bridge executor implemented that operation by posting `POST /v1/runs/:id/cancel` and waiting for a terminal bridge snapshot. `destroyed: true` becomes an accepted cancellation, while `destroyed: false`, a thrown error, or a control deadline becomes unknown. @@ -190,7 +202,7 @@ The upstream Runtime change required to expand this support is recorded below wi Title: `Expose typed, signal-aware provider cancellation acknowledgement from Executor` -Runtime `0.132.12` exposes `Executor.teardown(grace): Promise<{ destroyed: boolean }>` in its published declaration bundle. +Historical snapshot: Runtime `0.132.12` exposed `Executor.teardown(grace): Promise<{ destroyed: boolean }>` in its published declaration bundle. The bridge executor's `teardown('infinity')` posts `POST /v1/runs/:id/cancel` and waits for a terminal bridge snapshot in `dist/supervisor-BI6Z-8Yi.js:6973-6977,7529-7570`. @@ -313,7 +325,7 @@ These behaviors may be suitable for isolated benchmark automation under an expli ## Existing CLI Bridge provider contract -The published `@tangle-network/agent-provider-cli-bridge@0.6.0` resolves the bridge model from a turn override, provider default, or profile harness and model. +The published `@tangle-network/agent-provider-cli-bridge@0.7.3` resolves the bridge model from a turn override, provider default, or profile harness and model. It sends stable `executionId` values as bridge run identifiers when they satisfy the bridge identifier rules. @@ -329,11 +341,17 @@ Stopping a retained stream reader detaches the reader without cancelling the bri Explicit exact cancellation remains separate from reader detach and binds to the server-issued request digest. +Its public turn type accepts requested interactions through Interface `0.53.0`. + +Its published `toChatCompletionsBody` implementation does not copy `turn.interactions` into the bridge request. + +The Braid retained CLI Bridge boundary test catches this loss at the HTTP provider boundary. + It exposes no generalized interaction response. ## Existing Tangle provider contract -The published `@tangle-network/agent-provider-tangle@0.6.3` wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`. +The published `@tangle-network/agent-provider-tangle@0.11.2` wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`. Its default document is an upper bound, not a claim about one client or deployment. @@ -355,6 +373,12 @@ It requires an inline profile rather than a profile reference. It currently exposes no generalized interaction response through the environment or session adapter. +The Braid retained Tangle adapter supplies the requested map to Runtime's retained turn input. + +Historical snapshot: Runtime `0.135.0` dropped that field in its retained-turn copier before the Tangle provider received it. + +The Braid retained Tangle boundary test catches this loss at the provider dispatch boundary. + Its capability claims must still be proven against a real current Tangle deployment because a TypeScript adapter cannot prove server support or deployment policy. ## Current usage and execution observation contract @@ -423,10 +447,19 @@ The following upstream issues own missing shared contracts: - [Sandbox issue 5277](https://github.com/tangle-network/agent-dev-container/issues/5277) requires valid Sandbox keys to authorize internal model-key provisioning. - [Sandbox issue 5278](https://github.com/tangle-network/agent-dev-container/issues/5278) requires the npm `latest` tag to satisfy current Runtime peers. - [Runtime issue 808](https://github.com/tangle-network/agent-runtime/issues/808) requires permanent provisioning rejections to fail without the ten-minute retry. + - [Runtime issue 763](https://github.com/tangle-network/agent-runtime/issues/763) requests one stable execution tree with complete usage provenance. - [Agent SDK issue 136](https://github.com/tangle-network/agent-sdk/issues/136) requests normalized provider observations and account usage. - [Sandbox issue 5076](https://github.com/tangle-network/agent-dev-container/issues/5076) requests resolved placement, effective resources, and per-sandbox billing. +The current interaction work has three additional published-package blockers. + +- Runtime `0.135.3` must preserve `AgentTurnInput.interactions` in its retained-turn copier. +- CLI Bridge `0.7.3` must forward `turn.interactions` into its chat-completions request. +- The Runtime, Knowledge, and Profile Materialize peer cohort must accept Interface `0.53.0`. + +Braid does not cast around these gaps or duplicate provider dispatch. + ## Existing `agent-eval` contract `defineAgentEval` records the scenario, agent, judge, execution result, evidence, score, and cost for each run. @@ -437,7 +470,7 @@ Paired comparisons retain outcomes and costs across two frozen candidates. The current DSPy RLM engine accepts a caller-owned model function, stable public call reference, and execution recorder instead of a provider URL or credential. Braid invokes this engine through its bundled `uv` binary and an isolated managed Python 3.12 runtime. -The invocation pins `agent-eval-rpc[dspy]` to `0.144.11` and fixes the dependency resolution cutoff. +The invocation pins `agent-eval-rpc[dspy]` to `0.145.15` and fixes the dependency resolution cutoff. Braid never sends model credentials to the managed Python process. Braid gives each analyst invocation one explicit runtime transport attempt by default so its recorded usage and cost cannot hide additional paid retries. @@ -449,7 +482,7 @@ For CLI Bridge, Braid uses Runtime's `startRetainedRun` with the selected provid Braid returns normalized output, measured token usage, priced cost, terminal status, and finite redacted execution evidence to `agent-eval`. -The callback rejects multimodal and request-level thinking controls because runtime `0.132.12` does not expose those fields on this exact turn input. +Historical snapshot: the callback rejected multimodal and request-level thinking controls because Runtime `0.132.12` did not expose those fields on that exact turn input. Reasoning remains an `AgentProfile` setting, and unsupported callback shapes fail before a provider call rather than being silently dropped. diff --git a/docs/10-upstream-strategy.md b/docs/10-upstream-strategy.md index badf072..17bf8dd 100644 --- a/docs/10-upstream-strategy.md +++ b/docs/10-upstream-strategy.md @@ -41,7 +41,7 @@ The deciding question was how much source can be reused while preserving `AgentP | [Hermes Agent terminal app](https://github.com/NousResearch/hermes-agent/tree/main/ui-tui) | `697f2896bf948731eb6fcb93caa7264478590843` | MIT | No size claim used | Client/runtime and workflow reference | | [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | `47f943859bef60e4160492346772ded9b24f765a`; root `0.1.0-rc.5` | MIT | No size claim used | Web, headless, subagent, job, workflow, and event-log reference | | [Cordis](https://github.com/cordiverse/cordis) | `8cc9e33fab69e2d0476d126baaf2acb24e6a6ab4`; core `4.0.0-rc.8` | MIT | No size claim used | Rejected application-framework candidate | -| [`agent-runtime` terminal monitor](https://github.com/tangle-network/agent-runtime/tree/main/src/tui) | repository `9c18cb48`; npm `0.132.11` | Project license | No refreshed size claim | Runtime-owned supervisor source, not app base | +| [`agent-runtime` terminal monitor](https://github.com/tangle-network/agent-runtime/tree/main/src/tui) | Historical snapshot: repository `9c18cb48`; npm `0.132.11` | Project license | No refreshed size claim | Runtime-owned supervisor source, not app base | The count commands selected the named TypeScript or Rust files from sparse clones and used `wc -l`. @@ -199,7 +199,7 @@ The current Braid event journal, activity graph, and immutable view models satis ## Runtime monitor -`agent-runtime` package version `0.132.12` exports a diagnostic terminal module and `agent-runtime-top` binary. +Historical snapshot: `agent-runtime` package version `0.132.12` exported a diagnostic terminal module and `agent-runtime-top` binary. The module understands runtime-owned supervisor files and shows worker state, spend, tokens, latency, logs, steering, shell action, and cancellation controls. diff --git a/package.json b/package.json index 40deb78..050d209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/braid", - "version": "0.1.3", + "version": "0.2.0", "description": "A universal terminal interface for portable agent profiles", "type": "module", "bin": { @@ -71,9 +71,10 @@ "test:live:bridge": "node scripts/live-bridge.mjs", "test:live:bridge:release": "node scripts/live-bridge-release.mjs", "test:live:bridge:matrix": "node scripts/live-bridge/matrix.mjs", - "test:live:required:self": "pnpm run build && node --test scripts/live-required.test.mjs", + "test:live:required:self": "pnpm run build && node --test scripts/live-required.test.mjs test/tangle-live07-wiring.test.mjs", "test:live:tangle": "node scripts/live-required.mjs live-tangle", "test:live:tangle:sandbox:stress": "pnpm run build && node scripts/live-required/tangle-sandbox-braid-soak.mjs", + "test:live:tangle:sandbox:interactive": "pnpm run build && node scripts/live-required/tangle-sandbox-braid-interactive.mjs", "test:live:tangle:sandbox:execution": "pnpm run build && node scripts/live-required/tangle-sandbox-braid-execution-soak.mjs", "test:live:supervisor": "node scripts/live-required.mjs live-supervisor", "test:live:analysis": "node scripts/live-required.mjs live-analysis", @@ -99,12 +100,12 @@ "@dataiku/uv": "0.12.0", "@earendil-works/pi-tui": "0.84.2", "@napi-rs/keyring": "1.3.0", - "@tangle-network/agent-eval": "0.145.11", - "@tangle-network/agent-interface": "0.52.0", - "@tangle-network/agent-provider-cli-bridge": "0.7.1", - "@tangle-network/agent-provider-tangle": "0.10.0", - "@tangle-network/agent-runtime": "0.134.5", - "@tangle-network/sandbox": "0.26.1", + "@tangle-network/agent-eval": "0.149.0", + "@tangle-network/agent-interface": "1.3.0", + "@tangle-network/agent-provider-cli-bridge": "0.9.4", + "@tangle-network/agent-provider-tangle": "0.13.0", + "@tangle-network/agent-runtime": "0.142.3", + "@tangle-network/sandbox": "0.30.1", "better-sqlite3-multiple-ciphers": "13.0.3", "chalk": "6.0.0", "koffi": "3.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e683986..a5ab110 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,23 +22,23 @@ importers: specifier: 1.3.0 version: 1.3.0 '@tangle-network/agent-eval': - specifier: 0.145.11 - version: 0.145.11 + specifier: 0.149.0 + version: 0.149.0 '@tangle-network/agent-interface': - specifier: 0.52.0 - version: 0.52.0 + specifier: 1.3.0 + version: 1.3.0 '@tangle-network/agent-provider-cli-bridge': - specifier: 0.7.1 - version: 0.7.1 + specifier: 0.9.4 + version: 0.9.4 '@tangle-network/agent-provider-tangle': - specifier: 0.10.0 - version: 0.10.0(@tangle-network/sandbox@0.26.1) + specifier: 0.13.0 + version: 0.13.0(@tangle-network/sandbox@0.30.1) '@tangle-network/agent-runtime': - specifier: 0.134.5 - version: 0.134.5(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)(@tangle-network/sandbox@0.26.1) + specifier: 0.142.3 + version: 0.142.3(@tangle-network/agent-eval@0.149.0)(@tangle-network/agent-interface@1.3.0)(@tangle-network/sandbox@0.30.1) '@tangle-network/sandbox': - specifier: 0.26.1 - version: 0.26.1 + specifier: 0.30.1 + version: 0.30.1 better-sqlite3-multiple-ciphers: specifier: 13.0.3 version: 13.0.3 @@ -78,6 +78,7 @@ packages: '@biomejs/biome@2.5.7': resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==} engines: {node: '>=14.21.3'} + hasBin: true '@biomejs/cli-darwin-arm64@2.5.7': resolution: {integrity: sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==} @@ -337,8 +338,8 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@2.1.0': - resolution: {integrity: sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} engines: {node: '>=20'} peerDependencies: hono: 4.13.0 @@ -503,46 +504,51 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@tangle-network/agent-core@0.8.0': - resolution: {integrity: sha512-RH7RsFg/n/UOCQL9dvfod6m512oLtlVTk5IpPPttdr3HbcS3FAwDBaImljzX/WMyNQCHpBkC/GiBzUCI2XVtdQ==} + '@tangle-network/agent-core@0.9.4': + resolution: {integrity: sha512-2iSPCHPKI/9A4t1XXPTbpaJTzf3BkwNzXOJfb3M7AMjpIGR1SKNiRHKpDTTEUVbfx4tRny5qDDMhCnFmKgPsHA==} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.30.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true - '@tangle-network/agent-eval@0.145.11': - resolution: {integrity: sha512-CGC9BCCKNrhS84uZIQDaCCZmpPr4j1JJ7ZW/QmOir78h5psETh9Q/DF2tl79FXsXBVgyZrWWSQa7DNN+Xg4Ijg==} + '@tangle-network/agent-eval@0.149.0': + resolution: {integrity: sha512-zCU+hhfAhQOPOBCHCErQfFaKTEMdtUWlUR/ZfHqQw6gPPBhHk/+VaFiP4reCca3RfBkUnJtTQyoocqOH+L449w==} engines: {node: '>=20'} hasBin: true - '@tangle-network/agent-interface@0.52.0': - resolution: {integrity: sha512-jISw0y/VqQxeyuI7N5zsEb2+AMAYoIkPCtDVbWwZ9n2KL45rur//0ih72xPo3M+fiE0eUj9weTAYg4mzZckiHg==} + '@tangle-network/agent-interface@1.3.0': + resolution: {integrity: sha512-FsuSUkWyEvpGIjp9ENX3yrcXD8G7QhnVIoepknRYaPaaCaDPlHJzF483mBNix1GZ2bTU3aCr2KgHDSUdcjn1kg==} - '@tangle-network/agent-knowledge@7.2.6': - resolution: {integrity: sha512-0JKCWGWfevzSIt2WALhQ6RSk1grn0wB1Yj8vVMhW5XNcMQthmJ4lQRX3pBlk1cPg5yhuk48H9u4iY2YuoKnRKg==} + '@tangle-network/agent-knowledge@8.0.10': + resolution: {integrity: sha512-QOEIXt1t8iCjSNX7do6wqqUoZ8SQYjg5wIgX+Yi4877tnwEHZAQ41g6ibmtoYfPebBFtos2EU4jctJv7oK6T7w==} engines: {node: '>=20.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.11 <0.146.0' - '@tangle-network/agent-interface': '>=0.52.0 <0.53.0' + '@tangle-network/agent-eval': '>=0.149.0 <0.150.0' + '@tangle-network/agent-interface': ^1.1.0 - '@tangle-network/agent-profile-materialize@0.14.2': - resolution: {integrity: sha512-TuT8VIaQDEd8pvCDLEkaGWZ0aCA6e0YAUlRDeGQYNjLEqJYfQLePddqjNmf9FIgE3MepCbj/pBJEHIHzcp/C5g==} + '@tangle-network/agent-profile-materialize@0.16.0': + resolution: {integrity: sha512-jNBkA91yTYtmdtWxqvGI9wgKsykp9fiRbWmgvlm7Kimi0Fjz7MiAXZnX1RW5yaqLSdPvzKVBOycVXjqk1/dSmQ==} peerDependencies: - '@tangle-network/agent-interface': '>=0.47.0 <0.53.0' + '@tangle-network/agent-interface': ^1.0.0 - '@tangle-network/agent-provider-cli-bridge@0.7.1': - resolution: {integrity: sha512-pjZDdm5bs5TaBFXdLJeRkueI4oUPyA1M/CuICAO/SIOT4aZTD1Fv2V/S5lmZDmUEfy3h0ezAabwsPfsVj3jAQQ==} + '@tangle-network/agent-provider-cli-bridge@0.9.4': + resolution: {integrity: sha512-QxpeSO0ZH1wya7z+v5FXw4vQLUHNWiwoelYnBeDFB+tzuFjhyMJRhAb86sleeBLK6ojfKJGNGArVCeQeWcgU3A==} - '@tangle-network/agent-provider-tangle@0.10.0': - resolution: {integrity: sha512-rMQ3hIVo2CKUtKJg9b5zlMXbcGv+bI9ywcOKoNqJLymHbCa8aHhT/TQu02ztppPC/2vNLXLNRU4qyDqM+k0crA==} + '@tangle-network/agent-provider-tangle@0.13.0': + resolution: {integrity: sha512-SV8/1Op8qYcXKX+P6700UzhsKrW2yq8Py2p5NwoW6rQObBimIkKVPDcMi7fJkGi+/2CTfxGMCbSJPW+pL4d/OA==} peerDependencies: - '@tangle-network/sandbox': '>=0.23.0 <1.0.0' + '@tangle-network/sandbox': '>=0.30.1 <1.0.0' - '@tangle-network/agent-runtime@0.134.5': - resolution: {integrity: sha512-bEUUmPSj2UDXX4v7W8vUOWmUA875LMeivIUQPXY+loiL5aHnKJUTRAKgOf9QpbgW4xMVOYHxHHem7HRcoLONjQ==} + '@tangle-network/agent-runtime@0.142.3': + resolution: {integrity: sha512-5Sm5dG+TunyY6N+CdNFPs4mYjT39wdtRMfXB8N7DjAnn2YGsepPFvwEGDItaFdABcppZ2aqxJqrm6dc7njBnzA==} engines: {node: '>=22.13.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.145.11 <0.146.0' - '@tangle-network/agent-interface': '>=0.52.0 <0.53.0' - '@tangle-network/sandbox': '>=0.26.1 <0.27.0' + '@tangle-network/agent-eval': '>=0.149.0 <0.150.0' + '@tangle-network/agent-interface': ^1.1.0 + '@tangle-network/sandbox': '>=0.29.0 <0.31.0' peerDependenciesMeta: '@tangle-network/sandbox': optional: true @@ -550,11 +556,11 @@ packages: '@tangle-network/agent-trace-contract@1.0.2': resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} - '@tangle-network/sandbox@0.26.1': - resolution: {integrity: sha512-FuDlGUoPa2PWYma9CGPHFapffY+r8N9UKeBJ3QZgqhqEvZqHx1XViIc69332pW634UAnj+RhWpF8ZhxQFYG7fg==} + '@tangle-network/sandbox@0.30.1': + resolution: {integrity: sha512-EuftQgxTRktWScjIU+BQTPcQNgXb4Yt8YCGA28yLt1uTKJI3NvUemJ28Z58W230Tsl43VOv/KTpURKBph3/+tA==} peerDependencies: '@mastra/core': ^1.36.0 - '@modelcontextprotocol/sdk': ^1.29.0 + '@modelcontextprotocol/sdk': ^1.30.0 ai: ^6.0.175 openai: ^6.36.0 viem: ^2.0.0 @@ -573,6 +579,12 @@ packages: '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -758,6 +770,7 @@ packages: esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} + hasBin: true events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -814,8 +827,8 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-addon-api@8.9.1: - resolution: {integrity: sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} engines: {node: ^18 || ^20 || >= 21} node-pty@1.1.0: @@ -862,6 +875,7 @@ packages: typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} + hasBin: true undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -1034,7 +1048,7 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@hono/node-server@2.1.0(hono@4.13.0)': + '@hono/node-server@2.1.1(hono@4.13.0)': dependencies: hono: 4.13.0 @@ -1136,78 +1150,88 @@ snapshots: '@noble/hashes@1.8.0': {} - '@tangle-network/agent-core@0.8.0': + '@tangle-network/agent-core@0.9.4': dependencies: - '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-interface': 1.3.0 zod: 4.4.3 - '@tangle-network/agent-eval@0.145.11': + '@tangle-network/agent-eval@0.149.0': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) - '@hono/node-server': 2.1.0(hono@4.13.0) - '@tangle-network/agent-core': 0.8.0 - '@tangle-network/agent-interface': 0.52.0 + '@hono/node-server': 2.1.1(hono@4.13.0) + '@tangle-network/agent-core': 0.9.4 + '@tangle-network/agent-interface': 1.3.0 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.13.0 linear-sum-assignment: 1.0.9 re2js: 2.8.6 zod: 4.4.3 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' - '@tangle-network/agent-interface@0.52.0': + '@tangle-network/agent-interface@1.3.0': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@7.2.6(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)': + '@tangle-network/agent-knowledge@8.0.10(@tangle-network/agent-eval@0.149.0)(@tangle-network/agent-interface@1.3.0)': dependencies: - '@tangle-network/agent-eval': 0.145.11 - '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-eval': 0.149.0 + '@tangle-network/agent-interface': 1.3.0 + '@types/proper-lockfile': 4.1.4 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.14.2(@tangle-network/agent-interface@0.52.0)': + '@tangle-network/agent-profile-materialize@0.16.0(@tangle-network/agent-interface@1.3.0)': dependencies: - '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-interface': 1.3.0 - '@tangle-network/agent-provider-cli-bridge@0.7.1': + '@tangle-network/agent-provider-cli-bridge@0.9.4': dependencies: - '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-interface': 1.3.0 undici: 7.29.0 - '@tangle-network/agent-provider-tangle@0.10.0(@tangle-network/sandbox@0.26.1)': + '@tangle-network/agent-provider-tangle@0.13.0(@tangle-network/sandbox@0.30.1)': dependencies: - '@tangle-network/agent-interface': 0.52.0 - '@tangle-network/sandbox': 0.26.1 + '@tangle-network/agent-interface': 1.3.0 + '@tangle-network/sandbox': 0.30.1 - '@tangle-network/agent-runtime@0.134.5(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0)(@tangle-network/sandbox@0.26.1)': + '@tangle-network/agent-runtime@0.142.3(@tangle-network/agent-eval@0.149.0)(@tangle-network/agent-interface@1.3.0)(@tangle-network/sandbox@0.30.1)': dependencies: - '@tangle-network/agent-core': 0.8.0 - '@tangle-network/agent-eval': 0.145.11 - '@tangle-network/agent-interface': 0.52.0 - '@tangle-network/agent-knowledge': 7.2.6(@tangle-network/agent-eval@0.145.11)(@tangle-network/agent-interface@0.52.0) - '@tangle-network/agent-profile-materialize': 0.14.2(@tangle-network/agent-interface@0.52.0) + '@tangle-network/agent-core': 0.9.4 + '@tangle-network/agent-eval': 0.149.0 + '@tangle-network/agent-interface': 1.3.0 + '@tangle-network/agent-knowledge': 8.0.10(@tangle-network/agent-eval@0.149.0)(@tangle-network/agent-interface@1.3.0) + '@tangle-network/agent-profile-materialize': 0.16.0(@tangle-network/agent-interface@1.3.0) '@tangle-network/agent-trace-contract': 1.0.2 tar-stream: 3.2.0 optionalDependencies: - '@tangle-network/sandbox': 0.26.1 + '@tangle-network/sandbox': 0.30.1 transitivePeerDependencies: + - '@modelcontextprotocol/sdk' - bare-abort-controller - bare-buffer - react-native-b4a '@tangle-network/agent-trace-contract@1.0.2': {} - '@tangle-network/sandbox@0.26.1': + '@tangle-network/sandbox@0.30.1': dependencies: - '@tangle-network/agent-core': 0.8.0 - '@tangle-network/agent-interface': 0.52.0 + '@tangle-network/agent-core': 0.9.4 + '@tangle-network/agent-interface': 1.3.0 zod: 4.4.3 '@types/node@26.2.0': dependencies: undici-types: 8.3.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + + '@types/retry@0.12.5': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1303,7 +1327,7 @@ snapshots: better-sqlite3-multiple-ciphers@13.0.3: dependencies: - node-addon-api: 8.9.1 + node-addon-api: 8.9.2 binary-search@1.3.6: {} @@ -1416,7 +1440,7 @@ snapshots: node-addon-api@7.1.1: {} - node-addon-api@8.9.1: {} + node-addon-api@8.9.2: {} node-pty@1.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d3c466..9b6574f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,14 +9,14 @@ allowBuilds: node-pty: true ignoreScripts: false minimumReleaseAgeExclude: - - '@tangle-network/agent-runtime@0.126.0 || 0.128.0 || 0.131.0 || 0.131.1 || 0.131.2 || 0.131.3 || 0.131.4 || 0.131.5 || 0.131.6 || 0.131.7 || 0.132.0 || 0.132.4 || 0.132.6 || 0.132.9 || 0.132.10 || 0.132.11 || 0.132.12 || 0.133.6 || 0.134.2 || 0.134.4 || 0.134.5' - - '@tangle-network/agent-eval@0.143.0 || 0.144.0 || 0.144.1 || 0.144.3 || 0.144.4 || 0.144.6 || 0.144.8 || 0.144.10 || 0.144.11 || 0.144.13 || 0.145.0 || 0.145.1 || 0.145.2 || 0.145.3 || 0.145.9 || 0.145.10 || 0.145.11' - - '@tangle-network/agent-interface@0.46.0 || 0.46.1 || 0.47.0 || 0.51.0 || 0.52.0' - - '@tangle-network/agent-knowledge@7.0.8 || 7.1.2 || 7.1.3 || 7.2.0 || 7.2.2 || 7.2.3 || 7.2.4 || 7.2.6' - - '@tangle-network/agent-profile-materialize@0.10.2 || 0.13.1 || 0.14.0 || 0.14.2' - - '@tangle-network/agent-provider-cli-bridge@0.4.2 || 0.4.3 || 0.5.0 || 0.6.0 || 0.6.4 || 0.7.1' - - '@tangle-network/agent-provider-tangle@0.6.0 || 0.6.1 || 0.6.2 || 0.6.3 || 0.7.0 || 0.9.0 || 0.10.0' - - '@tangle-network/sandbox@0.17.3 || 0.18.0 || 0.19.2 || 0.19.3 || 0.19.4 || 0.19.5 || 0.19.6 || 0.21.0 || 0.21.1 || 0.26.1' + - '@tangle-network/agent-runtime@0.126.0 || 0.128.0 || 0.131.0 || 0.131.1 || 0.131.2 || 0.131.3 || 0.131.4 || 0.131.5 || 0.131.6 || 0.131.7 || 0.132.0 || 0.132.4 || 0.132.6 || 0.132.9 || 0.132.10 || 0.132.11 || 0.132.12 || 0.133.6 || 0.134.2 || 0.134.4 || 0.134.5 || 0.134.9 || 0.135.0 || 0.135.3 || 0.142.0 || 0.142.1 || 0.142.3' + - '@tangle-network/agent-eval@0.143.0 || 0.144.0 || 0.144.1 || 0.144.3 || 0.144.4 || 0.144.6 || 0.144.8 || 0.144.10 || 0.144.11 || 0.144.13 || 0.145.0 || 0.145.1 || 0.145.2 || 0.145.3 || 0.145.9 || 0.145.10 || 0.145.11 || 0.145.14 || 0.145.15 || 0.145.21 || 0.149.0' + - '@tangle-network/agent-interface@0.46.0 || 0.46.1 || 0.47.0 || 0.51.0 || 0.52.0 || 0.53.0 || 0.54.0 || 1.0.0 || 1.1.0 || 1.3.0' + - '@tangle-network/agent-knowledge@7.0.8 || 7.1.2 || 7.1.3 || 7.2.0 || 7.2.2 || 7.2.3 || 7.2.4 || 7.2.6 || 8.0.0 || 8.0.1 || 8.0.5 || 8.0.10' + - '@tangle-network/agent-profile-materialize@0.10.2 || 0.13.1 || 0.14.0 || 0.14.2 || 0.15.1 || 0.15.2' + - '@tangle-network/agent-provider-cli-bridge@0.4.2 || 0.4.3 || 0.5.0 || 0.6.0 || 0.6.4 || 0.7.1 || 0.7.2 || 0.7.3 || 0.7.6 || 0.8.0 || 0.8.1 || 0.9.0 || 0.9.2 || 0.9.3 || 0.9.4' + - '@tangle-network/agent-provider-tangle@0.13.0' + - '@tangle-network/sandbox@0.30.1' - hono@4.13.0 - '@esbuild/aix-ppc64@0.28.2' - '@esbuild/android-arm64@0.28.2' @@ -45,5 +45,5 @@ minimumReleaseAgeExclude: - '@esbuild/win32-ia32@0.28.2' - '@esbuild/win32-x64@0.28.2' - esbuild@0.28.2 - - '@tangle-network/agent-core@0.5.4 || 0.6.0 || 0.6.1 || 0.7.1 || 0.8.0' + - '@tangle-network/agent-core@0.5.4 || 0.6.0 || 0.6.1 || 0.7.1 || 0.8.0 || 0.8.1 || 0.9.0 || 0.9.4' - '@earendil-works/pi-tui@0.84.2' diff --git a/scripts/capture-product-demo.mjs b/scripts/capture-product-demo.mjs deleted file mode 100644 index c3a88a4..0000000 --- a/scripts/capture-product-demo.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const COLUMNS = 120 -const ROWS = 18 -const CONNECTION_ID = 'connection-local-cli-bridge' - -function pause(milliseconds) { - return new Promise((resolve) => setTimeout(resolve, milliseconds)) -} - -function rebaseEvents(events) { - const offset = events[0]?.[0] ?? 0 - return events.map(([timestamp, direction, data]) => [ - Number((timestamp - offset + 0.01).toFixed(6)), - direction, - data, - ]) -} - -async function typeText(terminal, value, delayMs = 20) { - for (const character of value) { - terminal.input(character) - await pause(delayMs) - } -} - -export async function captureProductDemo({ spawnTerminal, normalized, castFor }) { - const temporaryRoot = await mkdtemp(join(tmpdir(), 'braid-product-demo-')) - const workspace = join(temporaryRoot, 'agent-sdk') - await mkdir(workspace) - const terminal = await spawnTerminal( - 'product-demo', - COLUMNS, - ROWS, - { BRAID_FIXTURE_CHUNK_DELAY_MS: '300' }, - 'product-demo', - ['--connection', CONNECTION_ID, '--workspace', workspace], - ) - try { - await terminal.waitForInterface() - await terminal.waitFor(() => { - const screen = normalized(terminal.screen()) - return ( - screen.includes('profile Release engineer') && - screen.includes('pi / openai-codex/gpt-5.6-luna') && - screen.includes('Local CLI Bridge') && - screen.includes('Ctrl+P commands') - ) - }, 'complete product configuration') - await pause(700) - - await typeText(terminal, '/profile', 30) - terminal.input('\r') - await terminal.waitFor(() => { - const screen = normalized(terminal.screen()) - return ( - screen.includes('Active profile · Release engineer') && - screen.includes('runner pi · model openai-codex/gpt-5.6-luna') - ) - }, 'AgentProfile panel') - terminal.resize(COLUMNS - 1, ROWS) - await terminal.waitForStable('pre-demo redraw') - const demoStartIndex = terminal.events.length - terminal.resize(COLUMNS, ROWS) - await terminal.waitForStable('demo redraw') - await pause(1_000) - terminal.input('\u001b') - await terminal.waitFor( - () => !normalized(terminal.screen()).includes('Active profile · Release engineer'), - 'AgentProfile panel close', - ) - await pause(350) - - const prompt = 'Which coding agent will handle my next task, and where will it run?' - await typeText(terminal, prompt) - terminal.input('\r') - await terminal.waitFor( - () => normalized(terminal.screen()).includes('working'), - 'active run', - 15_000, - ) - terminal.input('\u001bOQ') - await terminal.waitFor( - () => normalized(terminal.screen()).includes('live work'), - 'live-work pane', - ) - await pause(500) - await terminal.waitFor( - () => normalized(terminal.screen()).includes('Pi will run the task'), - 'routed response', - 15_000, - ) - await terminal.waitFor( - () => { - const screen = normalized(terminal.screen()) - return ( - screen.includes('pi / openai-codex/gpt-5.6-luna') && !screen.includes('Ctrl+C cancel') - ) - }, - 'completed run', - 15_000, - ) - terminal.input('\u001bOQ') - await terminal.waitFor( - () => !normalized(terminal.screen()).includes('live work'), - 'live-work pane close', - ) - await terminal.waitForStable('product screenshot') - const screenshot = terminal.snapshot() - await pause(900) - - await typeText(terminal, '/activity', 30) - terminal.input('\r') - await terminal.waitFor( - () => normalized(terminal.screen()).includes('activity'), - 'retained activity', - ) - await pause(900) - terminal.input('\u001b[D') - await pause(350) - - await typeText(terminal, '/ask What should this agent improve next?', 18) - terminal.input('\r') - await terminal.waitFor( - () => normalized(terminal.screen()).includes('/ask · frozen question'), - 'trace analysis', - ) - await terminal.waitForStable('product demo final frame') - await pause(1_000) - const demo = terminal.snapshot() - await terminal.closeNormally() - - return { - columns: COLUMNS, - rows: ROWS, - finalScreen: screenshot.screen, - frameCast: castFor( - terminal, - terminal.events.slice(0, screenshot.eventCount), - 'Braid product screenshot', - ), - demoCast: castFor( - terminal, - rebaseEvents(terminal.events.slice(demoStartIndex, demo.eventCount)), - 'Braid product demo', - ), - steps: [ - 'Inspect AgentProfile', - 'Run through Local CLI Bridge', - 'Inspect live work and the retained run', - 'Analyze the frozen trace with /ask', - 'Review the analysis list and details together', - ], - } - } finally { - await terminal.dispose() - await rm(temporaryRoot, { force: true, recursive: true }) - } -} diff --git a/scripts/capture-visual-definitions.mjs b/scripts/capture-visual-definitions.mjs index 002b3f5..e0c0c66 100644 --- a/scripts/capture-visual-definitions.mjs +++ b/scripts/capture-visual-definitions.mjs @@ -1,5 +1,6 @@ export function createStateDefinitions(normalized) { return [ + ...[40, 80, 120].map((columns) => commandPaletteDefinition(columns, normalized)), { name: 'empty', columns: 80, @@ -142,7 +143,9 @@ export function createStateDefinitions(normalized) { terminal.input('/profile') terminal.input('\r') await terminal.waitFor( - () => normalized(terminal.screen()).includes('Active profile'), + () => + normalized(terminal.screen()).includes('Braid starter') && + normalized(terminal.screen()).includes('trusted · read-only'), 'profile editor', ) const { point, record } = await terminal.captureState() @@ -176,3 +179,24 @@ export function createStateDefinitions(normalized) { }, ] } + +function commandPaletteDefinition(columns, normalized) { + return { + name: `command-palette-${columns}`, + columns, + rows: columns === 40 ? 12 : 24, + run: async (terminal) => { + await terminal.waitForStable('before command palette') + terminal.input('\u0010') + await terminal.waitFor( + () => + normalized(terminal.screen()).includes('Commands') && + normalized(terminal.screen()).includes('←/esc close'), + `full-screen command palette screen=${normalized(terminal.screen())}`, + ) + const { point, record } = await terminal.captureState() + await terminal.closeNormally() + return { point, record } + }, + } +} diff --git a/scripts/capture-visual.mjs b/scripts/capture-visual.mjs index b9744ff..79668f2 100644 --- a/scripts/capture-visual.mjs +++ b/scripts/capture-visual.mjs @@ -7,7 +7,6 @@ import { join } from 'node:path' import { promisify } from 'node:util' import xterm from '@xterm/headless' import * as pty from 'node-pty' -import { captureProductDemo } from './capture-product-demo.mjs' import { createStateDefinitions } from './capture-visual-definitions.mjs' import { captureProvenance, @@ -74,7 +73,15 @@ function normalized(screen) { const STATE_DEFINITIONS = createStateDefinitions(normalized) -async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixture, cliArgs = []) { +async function spawnTerminal( + name, + columns, + rows, + extraEnvironment = {}, + uiFixture, + cliArgs = [], + { fixture = true } = {}, +) { const emulator = new XtermTerminal({ cols: columns, rows, @@ -85,7 +92,9 @@ async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixtu delete environment.NO_COLOR delete environment.FORCE_COLOR const recordPath = join(rawRoot, `${name}-${randomUUID()}.json`) - const args = [binary, '--fixture', 'deterministic', '--record-state', recordPath] + const args = [binary] + if (fixture) args.push('--fixture', 'deterministic') + args.push('--record-state', recordPath) if (uiFixture) args.push('--ui-fixture', uiFixture) args.push(...cliArgs) const session = pty.spawn(process.execPath, args, { @@ -164,10 +173,16 @@ async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixtu await waitForStable(`${name} escape layer ${layer + 1}`) } input('\u0003') - await waitFor( - () => normalized(screen).toLowerCase().includes('ctrl+c again to quit'), - `${name} safe exit prompt`, - ) + try { + await waitFor( + () => output.toLowerCase().includes('ctrl+c again to quit'), + `${name} safe exit prompt`, + 2_000, + ) + } catch { + await closeWithSignal() + return + } input('\u0003') const event = await waitForExit('normally') if (event.exitCode !== 0) throw new Error(`${name} exited ${event.exitCode}`) @@ -196,8 +211,13 @@ async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixtu } const waitForStable = (label = 'settled terminal frame') => waitFor(() => pendingWrites === 0 && performance.now() - lastOutputAt >= 75, `${name} ${label}`) + const fullInterfaceMarker = uiFixture === 'interaction' ? '↑↓ move' : 'type / for commands' const waitForInterface = () => - waitFor(() => output.includes('\u001b]0;Braid —'), `${name} full interface handoff`) + waitFor( + () => output.includes('\u001b[?1049h') && output.includes(fullInterfaceMarker), + `${name} full interface handoff`, + 15_000, + ) const captureState = async () => { await waitForStable() const point = snapshot() @@ -235,7 +255,7 @@ async function spawnTerminal(name, columns, rows, extraEnvironment = {}, uiFixtu } } -function castFor(result, events, title) { +function castFor(result, events, title, command = 'packed braid --fixture deterministic') { const lastEventAt = events.at(-1)?.[0] ?? 0 // agg samples a percentage position before applying an output event that lands // exactly at the cast duration. A later no-op makes every split final write @@ -248,7 +268,7 @@ function castFor(result, events, title) { timestamp: Math.floor(Date.now() / 1_000), duration: settledEvents.at(-1)[0], idle_time_limit: 1, - command: 'packed braid --fixture deterministic', + command, title, env: { TERM: 'xterm-256color' }, stdin: true, @@ -279,7 +299,7 @@ async function plainFrame() { '/bin/sh', [ '-c', - `{ printf '%s\\n' 'W6 plain proof'; } | exec ${shellArgument(binary)} --plain --fixture deterministic --no-color > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}`, + `{ printf '%s\\n' 'W6 plain proof'; sleep 4; } | exec ${shellArgument(binary)} --plain --fixture deterministic --no-color > ${shellArgument(stdoutPath)} 2> ${shellArgument(stderrPath)}`, ], { cwd: repository, @@ -297,7 +317,7 @@ async function plainFrame() { session.on('error', reject) session.on('close', (code) => resolve({ exitCode: code })) }) - const timeout = setTimeout(() => session.kill(), 5_000) + const timeout = setTimeout(() => session.kill(), 10_000) const [exit] = await Promise.all([ exited, readFifo(stdoutPath, output), @@ -471,7 +491,7 @@ try { throw new Error('comparison capture did not contain a saved comparison result') if ( definition.name === 'profile' && - !normalized(result.point.screen).includes('Active profile') + !normalized(result.point.screen).includes('trusted · read-only') ) throw new Error('profile capture did not contain the active profile editor') const stateRootName = definition.name.replaceAll('/', '-') @@ -558,26 +578,6 @@ try { await writeCastGif(join(rawRoot, 'automation-frame.cast'), automationGif) artifacts.push(await artifactFor(automationGif, 'automation-flow', 80, 24, 'automation')) - const productDemo = await captureProductDemo({ spawnTerminal, normalized, castFor }) - const productDemoCast = join(rawRoot, 'braid-demo.cast') - const productFrameCast = join(rawRoot, 'braid-demo-frame.cast') - const productText = join(outputRoot, 'braid.txt') - const productPng = join(outputRoot, 'braid.png') - const productGif = join(outputRoot, 'braid.gif') - const productRasterGif = join(rawRoot, 'braid-demo-frame.gif') - await writeFile(productDemoCast, productDemo.demoCast) - await writeFile(productFrameCast, productDemo.frameCast) - await writeFile(productText, productDemo.finalScreen) - await writeRaster(productFrameCast, productPng, productRasterGif) - await writeCastGif(productDemoCast, productGif, { loop: true }) - const productArtifacts = [ - await artifactFor(productDemoCast, 'product-asciicast', productDemo.columns, productDemo.rows), - await artifactFor(productText, 'product-frame', productDemo.columns, productDemo.rows), - await artifactFor(productPng, 'product-png', productDemo.columns, productDemo.rows), - await artifactFor(productGif, 'product-flow', productDemo.columns, productDemo.rows), - ] - artifacts.push(...productArtifacts) - const keyboardFlow = await transcriptKeyboardCapture() const keyboardCast = join(rawRoot, 'transcript-keyboard.cast') const keyboardGif = join(outputRoot, '80x24-transcript-keyboard.gif') @@ -602,7 +602,8 @@ try { binarySha256: await sha256(binary), tarball: packed.tarballName, tarballSha256: packed.tarballSha256, - fixture: 'deterministic', + stateFixture: 'deterministic', + liveDemoCommand: 'pnpm capture:demo:live', terminal: 'node-pty/xterm-256color', node: process.version, provenance, @@ -610,10 +611,6 @@ try { steps: keyboardFlow.steps, artifacts: keyboardArtifacts.map((artifact) => artifact.path), }, - productDemo: { - steps: productDemo.steps, - artifacts: productArtifacts.map((artifact) => artifact.path), - }, states: stateManifests, artifacts, }, diff --git a/scripts/live-bridge/bridge.mjs b/scripts/live-bridge/bridge.mjs index 3a4b659..bb3ef19 100644 --- a/scripts/live-bridge/bridge.mjs +++ b/scripts/live-bridge/bridge.mjs @@ -11,7 +11,7 @@ import { } from './endpoint.mjs' import { LiveBridgeError } from './errors.mjs' import { managedSpawn, sleep, terminateProcess } from './process.mjs' -import { redactString } from './redaction.mjs' +import { redactString, withoutBraidLiveSecrets } from './redaction.mjs' const requiredRuntimeCapabilities = Object.freeze({ profileMaterialization: 'cli-bridge.profile-materialization.v2', @@ -27,7 +27,7 @@ export function bridgeLaunchEnvironment( endpoint, { environment = process.env, platform = process.platform } = {}, ) { - const childEnv = { ...environment } + const childEnv = withoutBraidLiveSecrets(environment) if (childEnv.BRIDGE_BACKENDS === undefined) { childEnv.BRIDGE_BACKENDS = [...new Set(definitions.map(({ backend }) => backend))].join(',') } diff --git a/scripts/live-bridge/constants.mjs b/scripts/live-bridge/constants.mjs index d93dff8..fb7117c 100644 --- a/scripts/live-bridge/constants.mjs +++ b/scripts/live-bridge/constants.mjs @@ -44,12 +44,6 @@ export const targetDefinitions = Object.freeze([ modelId: 'opencode/zai-coding-plan/glm-5.2', backend: 'opencode', }, - { - key: 'pi-glm-5.2', - label: 'GLM 5.2 through Pi', - modelId: 'pi/tangle-router/glm-5.2', - backend: 'pi', - }, ]) export const livePrompts = Object.freeze({ diff --git a/scripts/live-bridge/main.mjs b/scripts/live-bridge/main.mjs index 7bbb322..1692f94 100644 --- a/scripts/live-bridge/main.mjs +++ b/scripts/live-bridge/main.mjs @@ -118,11 +118,14 @@ export async function main({ requireCompleteReleaseProof = false } = {}) { } const bridgeEvidence = {} evidence.bridge = bridgeEvidence - const launchDefinitions = requireCompleteReleaseProof + const discoverFromHealth = policy.source === 'default' || requireCompleteReleaseProof + const launchDefinitions = discoverFromHealth ? [ ...policy.definitions, { key: 'release-pi', label: 'release Pi runner', backend: 'pi' }, - { key: 'release-codex', label: 'release Codex runner', backend: 'codex' }, + ...(requireCompleteReleaseProof + ? [{ key: 'release-codex', label: 'release Codex runner', backend: 'codex' }] + : []), ] : policy.definitions const bridge = await discoverBridge( @@ -130,15 +133,15 @@ export async function main({ requireCompleteReleaseProof = false } = {}) { token, bridgeEvidence, repository, - requireCompleteReleaseProof ? [] : policy.definitions, + discoverFromHealth ? [] : policy.definitions, { launchDefinitions, - requireDefinitions: !requireCompleteReleaseProof, + requireDefinitions: !discoverFromHealth, }, ) bridgeCleanup = bridge.cleanup let selected = bridge.selected - if (requireCompleteReleaseProof) { + if (discoverFromHealth) { const releaseDefinitions = releaseTargetDefinitions( policy.definitions, bridgeEvidence.models, @@ -160,7 +163,7 @@ export async function main({ requireCompleteReleaseProof = false } = {}) { ) evidence.targetPolicy = targetPolicyEvidence({ ...policy, - source: `${policy.source}:release-runners`, + source: `${policy.source}:${requireCompleteReleaseProof ? 'release-runners' : 'health-discovered'}`, definitions: releaseDefinitions, }) } diff --git a/scripts/live-bridge/matrix.mjs b/scripts/live-bridge/matrix.mjs index 6ec4ea5..a378572 100644 --- a/scripts/live-bridge/matrix.mjs +++ b/scripts/live-bridge/matrix.mjs @@ -70,20 +70,56 @@ async function withFakeBridge( } async function runTargetPolicyMatrix() { - const both = defaultTargetPolicy.definitions - const exactGlm = both[0].modelId - const exactPiGlm = both[1].modelId + const opencode = defaultTargetPolicy.definitions[0] + const piFlash = { + key: 'pi-deepseek-v4-flash', + label: 'DeepSeek V4 Flash through Pi', + modelId: 'pi/deepseek/deepseek-v4-flash', + backend: 'pi', + } + const piPro = { + key: 'pi-deepseek-v4-pro', + label: 'DeepSeek V4 Pro through Pi', + modelId: 'pi/deepseek/deepseek-v4-pro', + backend: 'pi', + } + const both = [opencode, piFlash] + const exactGlm = opencode.modelId + const exactPiModel = piFlash.modelId const readyBackends = [ { name: 'opencode', state: 'ready' }, { name: 'pi', state: 'ready' }, { name: 'claude-code', state: 'starting' }, ] - await withFakeBridge([exactGlm, exactPiGlm], readyBackends, async (endpoint) => { + const dynamicHealth = { + ok: true, + status: 200, + body: { status: 'ok', backends: readyBackends }, + } + const dynamicModels = { + ok: true, + body: { data: [{ id: exactGlm }, { id: piFlash.modelId }, { id: piPro.modelId }] }, + } + assert.deepEqual( + releaseTargetDefinitions(defaultTargetPolicy.definitions, dynamicModels, dynamicHealth).map( + ({ modelId }) => modelId, + ), + [exactGlm, piFlash.modelId], + ) + assert.deepEqual( + releaseTargetDefinitions( + defaultTargetPolicy.definitions, + { ...dynamicModels, body: { data: [{ id: piPro.modelId }, { id: piFlash.modelId }] } }, + { ...dynamicHealth, body: { status: 'ok', backends: [{ name: 'pi', state: 'ready' }] } }, + ).map(({ modelId }) => modelId), + [piPro.modelId], + ) + await withFakeBridge([exactGlm, exactPiModel], readyBackends, async (endpoint) => { const evidence = {} const result = await discoverBridge(endpoint, undefined, evidence, process.cwd(), both) assert.deepEqual( result.selected.map(({ modelId }) => modelId), - [exactGlm, exactPiGlm], + [exactGlm, exactPiModel], ) }) await withFakeBridge([exactGlm], readyBackends, async (endpoint) => { @@ -94,7 +130,7 @@ async function runTargetPolicyMatrix() { ) assert.deepEqual( evidence.missingTargets.map(({ modelId }) => modelId), - [exactPiGlm], + [exactPiModel], ) }) await withFakeBridge( @@ -110,7 +146,7 @@ async function runTargetPolicyMatrix() { { modelsStatus: 503 }, ) await withFakeBridge( - [exactGlm, exactPiGlm], + [exactGlm, exactPiModel], readyBackends, async (endpoint) => { const evidence = {} @@ -121,7 +157,7 @@ async function runTargetPolicyMatrix() { }, { capabilities: {} }, ) - await withFakeBridge([exactPiGlm], readyBackends, async (endpoint) => { + await withFakeBridge([exactPiModel], readyBackends, async (endpoint) => { const evidence = {} await assert.rejects( discoverBridge(endpoint, undefined, evidence, process.cwd(), both), @@ -133,7 +169,7 @@ async function runTargetPolicyMatrix() { ) }) await withFakeBridge( - [exactGlm, exactPiGlm], + [exactGlm, exactPiModel], [{ name: 'opencode', state: 'ready' }], async (endpoint) => { const evidence = {} @@ -177,13 +213,13 @@ async function runTargetPolicyMatrix() { const releaseModels = { ok: true, body: { - data: [{ id: exactGlm }, { id: exactPiGlm }, { id: 'codex/default' }], + data: [{ id: exactGlm }, { id: exactPiModel }, { id: 'codex/default' }], }, } const releaseDefinitions = releaseTargetDefinitions(both, releaseModels, releaseHealth) assert.deepEqual( releaseDefinitions.map(({ modelId }) => modelId), - [exactGlm, exactPiGlm, 'codex/default'], + [exactGlm, exactPiModel, 'codex/default'], ) assert.equal(releaseDefinitions[2].bridgeModelId, 'codex/default') const releaseEvidence = {} @@ -197,14 +233,20 @@ async function runTargetPolicyMatrix() { releaseTargets.map(({ modelId, bridgeModelId }) => ({ modelId, bridgeModelId })), [ { modelId: exactGlm, bridgeModelId: exactGlm }, - { modelId: exactPiGlm, bridgeModelId: exactPiGlm }, + { modelId: exactPiModel, bridgeModelId: exactPiModel }, { modelId: 'codex/default', bridgeModelId: 'codex/default' }, ], ) } async function runConfigurationMatrix() { - const [glm, piGlm] = defaultTargetPolicy.definitions + const glm = defaultTargetPolicy.definitions[0] + const piGlm = { + key: 'pi-deepseek-v4-flash', + label: 'DeepSeek V4 Flash through Pi', + modelId: 'pi/deepseek/deepseek-v4-flash', + backend: 'pi', + } assert.deepEqual(profileForBridgeTarget(glm), { name: `Braid live ${glm.modelId}`, description: 'Opt-in packed CLI Bridge smoke profile', @@ -218,8 +260,8 @@ async function runConfigurationMatrix() { version: '0.1.0', harness: 'pi', model: { - provider: 'tangle-router', - default: 'glm-5.2', + provider: 'deepseek', + default: 'deepseek-v4-flash', reasoningEffort: 'none', }, }) @@ -279,8 +321,8 @@ async function runConfigurationMatrix() { try { const written = await writeTargetConfig(root, endpoint, piGlm, undefined) assert.deepEqual(JSON.parse(await readFile(written.profilePath, 'utf8')), written.profile) - assert.equal(written.profile.model.default, 'glm-5.2') - assert.equal(written.profile.model.provider, 'tangle-router') + assert.equal(written.profile.model.default, 'deepseek-v4-flash') + assert.equal(written.profile.model.provider, 'deepseek') const credential = await writeTargetConfig(root, endpoint, glm, { recordRef: createLiveCredentialId('11111111-1111-1111-1111-111111111111'), }) diff --git a/scripts/live-bridge/redaction.mjs b/scripts/live-bridge/redaction.mjs index fc22cb7..b5f1c39 100644 --- a/scripts/live-bridge/redaction.mjs +++ b/scripts/live-bridge/redaction.mjs @@ -1,3 +1,5 @@ +import { collectCredentialSecrets } from '../release/redaction.mjs' + const secretKeys = new Set([ 'authorization', 'bearer', @@ -19,19 +21,36 @@ const bridgeSecretEnvironmentKeys = Object.freeze([ 'BRIDGE_BEARER', ]) +const braidLiveSecretEnvironmentKeys = Object.freeze([ + 'BRAID_ANALYSIS_AUTH', + 'BRAID_ANALYSIS_API_KEY', + 'BRAID_ANALYSIS_BEARER', + 'BRAID_CLI_BRIDGE_AUTH', + 'BRAID_CLI_BRIDGE_API_KEY', + 'BRAID_CLI_BRIDGE_BEARER', + 'BRAID_TANGLE_AUTH', + 'BRAID_TANGLE_API_KEY', + 'BRAID_TANGLE_BEARER', + 'BRAID_TANGLE_CREDENTIAL_REF', + 'BRAID_TANGLE_SANDBOX_AUTH', + 'BRAID_TANGLE_SANDBOX_API_KEY', + 'BRAID_TANGLE_SANDBOX_BEARER', + 'BRAID_TANGLE_SANDBOX_CLEANUP_API_KEY', + 'BRAID_TANGLE_SANDBOX_CREDENTIAL_REF', + 'BRAID_TANGLE_SANDBOX_MODEL_API_KEY', + 'TANGLE_API_KEY', +]) + function isSecretKey(key) { const normalized = key.replaceAll(/[-_]/gu, '').toLowerCase() return secretKeys.has(normalized) } export function secretValues(environment = process.env) { - return [ - ...new Set( - bridgeSecretEnvironmentKeys - .map((key) => environment?.[key]) - .filter((value) => typeof value === 'string' && value.length > 0), - ), - ] + const explicitSecrets = [...bridgeSecretEnvironmentKeys, ...braidLiveSecretEnvironmentKeys] + .map((key) => environment?.[key]) + .filter((value) => typeof value === 'string' && value.length > 0) + return collectCredentialSecrets(environment, explicitSecrets) } export function withoutBridgeSecrets(environment = process.env) { @@ -40,6 +59,12 @@ export function withoutBridgeSecrets(environment = process.env) { return childEnvironment } +export function withoutBraidLiveSecrets(environment = process.env) { + const childEnvironment = { ...environment } + for (const key of braidLiveSecretEnvironmentKeys) delete childEnvironment[key] + return childEnvironment +} + export function redactString(value, secrets = secretValues()) { const secretList = [...new Set(secrets)].filter( (secret) => typeof secret === 'string' && secret.length > 0, diff --git a/scripts/live-bridge/release-proof-validation.test.mjs b/scripts/live-bridge/release-proof-validation.test.mjs index 753729c..1dc410c 100644 --- a/scripts/live-bridge/release-proof-validation.test.mjs +++ b/scripts/live-bridge/release-proof-validation.test.mjs @@ -1,7 +1,13 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { bridgeLaunchEnvironment } from './bridge.mjs' import { StreamingRedactor } from './capture.mjs' -import { evidenceValue, withoutBridgeSecrets } from './redaction.mjs' +import { + evidenceValue, + secretValues, + withoutBraidLiveSecrets, + withoutBridgeSecrets, +} from './redaction.mjs' import { assertContextTransfer, assertObservedUsage, @@ -11,7 +17,7 @@ import { assertUniqueRunIds, terminalReceipts, } from './release-proof-validation.mjs' -import { assertTargetSemantics } from './target-actions.mjs' +import { assertTargetSemantics, verifyInteraction } from './target-actions.mjs' const target = { key: 'pi-test', @@ -161,6 +167,36 @@ test('usage, replay, and cancellation unavailable states cannot pass strict conf ) }) +test('interaction conformance ignores a pending interaction from another run', async () => { + const result = { targetKey: 'pi-test', requests: [] } + const session = { + send() { + assert.fail('foreign interaction must not be answered') + }, + } + await verifyInteraction( + session, + result, + { interactions: { available: true } }, + { + type: 'state', + view: { + capabilities: { 'interaction.respond': true }, + interactions: [ + { + runId: 'foreign-run', + interactionId: 'foreign-interaction', + }, + ], + }, + state: { runs: [] }, + }, + { runId: 'target-run' }, + ) + assert.equal(result.interaction.status, 'advertised-but-not-emitted') + assert.equal(result.requests.length, 0) +}) + test('restart proof rejects process, timing, reconnect, replay, and terminal false positives', () => { const identity = { id: 'run-1', @@ -540,3 +576,60 @@ test('release operations reject reused run IDs and redact bridge bearers', () => ) assert.equal(withoutBridgeSecrets({ BRIDGE_BEARER: secret }).BRIDGE_BEARER, undefined) }) + +test('started CLI Bridge keeps local subscription settings but strips Braid live credentials', () => { + const environment = { + PATH: '/usr/bin', + TANGLE_API_KEY: 'tangle-secret-canary', + BRAID_TANGLE_SANDBOX_API_KEY: 'sandbox-secret-canary', + BRAID_TANGLE_SANDBOX_CLEANUP_API_KEY: 'cleanup-secret-canary', + BRAID_TANGLE_CREDENTIAL_REF: 'credential-ref-canary', + BRAID_CLI_BRIDGE_AUTH: 'braid-bridge-secret-canary', + BRIDGE_BEARER: 'bridge-server-secret-canary', + OPENAI_API_KEY: 'local-subscription-canary', + PI_CODING_AGENT_DIR: '/tmp/pi-agent', + } + const child = bridgeLaunchEnvironment([{ backend: 'pi' }], 'http://127.0.0.1:3344', { + environment, + platform: 'linux', + }) + + for (const key of [ + 'TANGLE_API_KEY', + 'BRAID_TANGLE_SANDBOX_API_KEY', + 'BRAID_TANGLE_SANDBOX_CLEANUP_API_KEY', + 'BRAID_TANGLE_CREDENTIAL_REF', + 'BRAID_CLI_BRIDGE_AUTH', + ]) { + assert.equal(child[key], undefined, `${key} must not enter the Bridge process`) + } + assert.equal(child.BRIDGE_BEARER, environment.BRIDGE_BEARER) + assert.equal(child.OPENAI_API_KEY, environment.OPENAI_API_KEY) + assert.equal(child.PI_CODING_AGENT_DIR, environment.PI_CODING_AGENT_DIR) + assert.equal(child.BRIDGE_BACKENDS, 'pi') + assert.equal(child.BRIDGE_JAIL_MODE, 'fs-jail') +}) + +test('Braid live credential filtering does not remove local CLI subscription settings', () => { + const child = withoutBraidLiveSecrets({ + TANGLE_API_KEY: 'tangle-secret-canary', + BRAID_TANGLE_API_KEY: 'tangle-secret-canary', + ANTHROPIC_API_KEY: 'local-subscription-canary', + CODEX_HOME: '/tmp/codex', + }) + assert.equal(child.TANGLE_API_KEY, undefined) + assert.equal(child.BRAID_TANGLE_API_KEY, undefined) + assert.equal(child.ANTHROPIC_API_KEY, 'local-subscription-canary') + assert.equal(child.CODEX_HOME, '/tmp/codex') +}) + +test('captured Bridge evidence redacts preserved local subscription credentials', () => { + const secret = 'local-subscription-secret-canary' + const evidence = evidenceValue( + { stderr: `provider startup printed ${secret}` }, + '', + 0, + secretValues({ OPENAI_API_KEY: secret }), + ) + assert.doesNotMatch(JSON.stringify(evidence), new RegExp(secret, 'u')) +}) diff --git a/scripts/live-bridge/target-actions.mjs b/scripts/live-bridge/target-actions.mjs index e4d5422..a3c1ac1 100644 --- a/scripts/live-bridge/target-actions.mjs +++ b/scripts/live-bridge/target-actions.mjs @@ -7,6 +7,7 @@ import { capabilityAdvertised, capabilityAvailability, exactMarker, + interactionFromResponse, requestBase, responseForRequest, runFromState, @@ -304,9 +305,9 @@ export async function verifyInteraction( result, providerCapabilities, terminal, - { operationPrefix = 'live' } = {}, + { operationPrefix = 'live', runId } = {}, ) { - const interaction = terminal.view?.interactions?.[0] + const interaction = interactionFromResponse(terminal, runId) const interactionCapability = terminal.view?.capabilities?.['interaction.respond'] const advertisedByBraid = capabilityAdvertised(interactionCapability) const availability = capabilityAvailability(providerCapabilities.interactions, advertisedByBraid) diff --git a/scripts/live-bridge/target-flow.mjs b/scripts/live-bridge/target-flow.mjs index d02fda7..7c1aa36 100644 --- a/scripts/live-bridge/target-flow.mjs +++ b/scripts/live-bridge/target-flow.mjs @@ -17,7 +17,12 @@ import { runFromState, stateForRun, } from './protocol.mjs' -import { evidenceValue, redactString, withoutBridgeSecrets } from './redaction.mjs' +import { + evidenceValue, + redactString, + withoutBraidLiveSecrets, + withoutBridgeSecrets, +} from './redaction.mjs' import { assertObservedUsage, assertTargetRunIdentity, @@ -47,7 +52,7 @@ export async function executeTarget( const config = await writeTargetConfig(root, endpoint, target, credential) const statePath = join(config.workspace, 'braid.sqlite') const env = { - ...withoutBridgeSecrets(), + ...withoutBridgeSecrets(withoutBraidLiveSecrets()), NO_COLOR: '1', NODE_NO_WARNINGS: '1', BRAID_STATE_PATH: statePath, @@ -98,7 +103,10 @@ export async function executeTarget( operationPrefix, timeoutMs, }) - await verifyInteraction(session, result, providerCapabilities, terminal, { operationPrefix }) + await verifyInteraction(session, result, providerCapabilities, terminal, { + operationPrefix, + runId, + }) assertTargetSemantics(result, { strict }) if (strict) { const targetProof = assertTargetRunIdentity(finalRun, target) @@ -232,7 +240,7 @@ function targetResult(config, target, providerCapabilities, credential, operatio function operationEnvironment(config) { return { - ...withoutBridgeSecrets(), + ...withoutBridgeSecrets(withoutBraidLiveSecrets()), NO_COLOR: '1', NODE_NO_WARNINGS: '1', BRAID_STATE_PATH: join(config.workspace, 'braid.sqlite'), diff --git a/scripts/live-core/discovery.mjs b/scripts/live-core/discovery.mjs index 47904a2..ed7aa3b 100644 --- a/scripts/live-core/discovery.mjs +++ b/scripts/live-core/discovery.mjs @@ -1,12 +1,6 @@ import assert from 'node:assert/strict' const DEFAULT_ENDPOINT = 'http://127.0.0.1:3344' -const PI_PREFERENCES = [ - 'pi/tangle-router/glm-5.2', - 'pi/openai-codex/gpt-5.6-luna', - 'pi/openai-codex/gpt-5.6-sol', - 'pi/openai-codex/gpt-5.5', -] const CODEX_PREFERENCES = ['codex/default', 'codex/gpt-5.6', 'codex/gpt-5.5'] const KNOWN_UNAVAILABLE_MODELS = new Map([ [ @@ -68,9 +62,9 @@ export function chooseRunnerTargets(health, catalog) { : [] const backends = [...new Set(models.map((model) => model.backend))].sort() const preferred = [] - const pi = modelFor(models, 'pi', PI_PREFERENCES) + const pi = modelFor(models, 'pi', []) const codex = modelFor(models, 'codex', CODEX_PREFERENCES) - if (pi) preferred.push({ runner: 'pi', model: pi.id, selection: 'preferred-pi' }) + if (pi) preferred.push({ runner: 'pi', model: pi.id, selection: 'health-discovered-pi' }) if (codex) preferred.push({ runner: 'codex', model: codex.id, selection: 'preferred-codex' }) const selected = diff --git a/scripts/live-demo.mjs b/scripts/live-demo.mjs index 5f71f3b..8d9c5a5 100644 --- a/scripts/live-demo.mjs +++ b/scripts/live-demo.mjs @@ -15,6 +15,7 @@ import { safeManifestAnalysis, } from './live-demo/manifest.mjs' import { assertPublicCapture } from './live-demo/public-safety.mjs' +import { releaseTargetDefinitions } from './live-bridge/bridge.mjs' import { castFor, createCapturedTerminal, @@ -26,6 +27,7 @@ import { } from './live-demo/terminal.mjs' import { createLiveDemoWorkspace, + liveDemoProfileForRoute, LIVE_DEMO_ANALYST_PROFILE, LIVE_DEMO_PROFILE, LIVE_DEMO_PROMPT, @@ -35,16 +37,14 @@ import { installPackedBraid } from './packed-binary.mjs' const run = promisify(execFile) const repository = new URL('../', import.meta.url).pathname.replace(/\/$/u, '') -const endpoint = process.env.BRAID_LIVE_DEMO_ENDPOINT ?? 'http://127.0.0.1:3344' +const endpoint = process.env.BRAID_LIVE_DEMO_ENDPOINT ?? 'http://127.0.0.1:3345' const outputRoot = process.env.BRAID_LIVE_DEMO_OUTPUT ? process.env.BRAID_LIVE_DEMO_OUTPUT : join(repository, 'artifacts', 'demo') const packageProofPath = process.env.BRAID_LIVE_DEMO_PACKAGE_PROOF ? process.env.BRAID_LIVE_DEMO_PACKAGE_PROOF : join(repository, 'artifacts', 'verification', 'w6', 'package-proof.json') -const route = `${LIVE_DEMO_PROFILE.harness}/${LIVE_DEMO_PROFILE.model.default}` -const analysisRoute = `${LIVE_DEMO_ANALYST_PROFILE.harness}/${LIVE_DEMO_ANALYST_PROFILE.model.default}` -const columns = 120 +const columns = 160 const rows = 30 function sha256(value) { @@ -73,13 +73,13 @@ async function bridgeProof(baseUrl) { const backend = health.backends?.find((candidate) => candidate.name === LIVE_DEMO_PROFILE.harness) assert.equal(health.status, 'ok', 'CLI Bridge is not healthy') assert.equal(backend?.state, 'ready', `${LIVE_DEMO_PROFILE.harness} is not ready in CLI Bridge`) - for (const requiredRoute of [route, analysisRoute]) { - assert.ok( - models.data?.some((candidate) => candidate.id === requiredRoute), - `${requiredRoute} is not advertised by CLI Bridge`, - ) - } - return { health, backend } + const [target] = releaseTargetDefinitions( + [], + { ok: true, body: models }, + { body: health }, + ).filter((candidate) => candidate.backend === LIVE_DEMO_PROFILE.harness) + assert.ok(target, 'CLI Bridge does not advertise a ready Pi model') + return { health, backend, target } } function latestCompletedRun(record) { @@ -184,6 +184,9 @@ async function main() { ]) const sourceCommit = commitResult.stdout.trim() const packageProof = JSON.parse(packageProofBytes.toString('utf8')) + const route = bridge.target.modelId + const profile = liveDemoProfileForRoute(route) + const analystProfile = liveDemoProfileForRoute(route, LIVE_DEMO_ANALYST_PROFILE) const analysisRuntime = { manager: 'bundled uv', pythonVersion: '3.12', @@ -202,7 +205,10 @@ async function main() { tarball: packed.tarballName, tarballSha256: packed.tarballSha256, }) - const { workspace, profilePath } = await createLiveDemoWorkspace(temporaryRoot) + const { workspace, profilePath } = await createLiveDemoWorkspace(temporaryRoot, { + profile, + analystProfile, + }) const keyFile = join(temporaryRoot, 'database.key') const recordPath = join(temporaryRoot, 'live-demo-state.json') await writeFile(keyFile, randomBytes(32).toString('hex'), { mode: 0o600 }) @@ -211,16 +217,21 @@ async function main() { workspace, keyFile, baseUrl, - LIVE_DEMO_PROFILE.harness, + profile.harness, route, profilePath, ) - assert.equal(setup.config.profile.name, LIVE_DEMO_PROFILE.name) - assert.equal(setup.config.profile.harness, LIVE_DEMO_PROFILE.harness) - assert.equal(setup.config.profile.model.default, LIVE_DEMO_PROFILE.model.default) + assert.equal(setup.config.profile.name, profile.name) + assert.equal(setup.config.profile.harness, profile.harness) + assert.equal(setup.config.profile.model.default, profile.model.default) + assert.equal( + setup.config.profile.model.maxVisibleOutputTokens, + profile.model.maxVisibleOutputTokens, + ) + assert.equal(setup.config.profile.model.maxReasoningTokens, profile.model.maxReasoningTokens) assert.equal( - setup.config.profile.model.metadata.maxTokens, - LIVE_DEMO_PROFILE.model.metadata.maxTokens, + setup.config.profile.model.maxTotalOutputTokens, + profile.model.maxTotalOutputTokens, ) terminal = await createCapturedTerminal({ @@ -248,10 +259,10 @@ async function main() { }) await terminal.waitForScreen( (screen) => - screen.includes(`profile ${LIVE_DEMO_PROFILE.name}`) && - screen.includes(LIVE_DEMO_PROFILE.harness) && - screen.includes(LIVE_DEMO_PROFILE.model.default) && - screen.includes('Local CLI Bridge'), + screen.includes(profile.name) && + screen.includes(profile.harness) && + screen.includes(profile.model.default) && + screen.includes('CLI Bridge'), 'real AgentProfile route', 60_000, ) @@ -261,16 +272,16 @@ async function main() { terminal.input('\r') await terminal.waitForScreen( (screen) => - screen.includes('Active profile · Product engineer') && + screen.includes(profile.name) && screen.includes( - `thinking high · max output ${LIVE_DEMO_PROFILE.model.metadata.maxTokens.toLocaleString('en-US')} tokens`, + `thinking high · limits visible ${profile.model.maxVisibleOutputTokens.toLocaleString('en-US')} · reasoning ${profile.model.maxReasoningTokens.toLocaleString('en-US')} · total ${profile.model.maxTotalOutputTokens.toLocaleString('en-US')}`, ), 'AgentProfile details', ) await pause(900) terminal.input('\u001b') await terminal.waitForScreen( - (screen) => !screen.includes('Active profile · Product engineer'), + (screen) => !screen.includes(`runner pi · model ${profile.model.default}`), 'AgentProfile close', ) await pause(300) @@ -289,7 +300,7 @@ async function main() { transcript.assistantMessages.length > 0, 'The coding turn returned no visible assistant result', ) - assert.equal(coding.run.runner, LIVE_DEMO_PROFILE.harness) + assert.equal(coding.run.runner, profile.harness) assert.equal(coding.run.model, route) const workspaceProof = await verifyWorkspace(workspace) await terminal.waitForStable('completed coding turn') @@ -309,31 +320,25 @@ async function main() { await typeText(terminal, '/profile', 24) terminal.input('\r') await terminal.waitForScreen( - (screen) => - screen.includes('Active profile · Product engineer') && - screen.includes(LIVE_DEMO_ANALYST_PROFILE.name), + (screen) => screen.includes(profile.name) && screen.includes(analystProfile.name), 'trace analyst profile choice', ) - await typeText(terminal, LIVE_DEMO_ANALYST_PROFILE.name, 24) + await typeText(terminal, analystProfile.name, 24) await terminal.waitForScreen( - (screen) => - screen.includes(LIVE_DEMO_ANALYST_PROFILE.name) && - !screen.includes('CLI Bridge · claude-code · hai'), + (screen) => screen.includes(analystProfile.name), 'filtered trace analyst profile', ) terminal.input('\r') await terminal.waitForScreen( - (screen) => screen.includes(`Selected ${LIVE_DEMO_ANALYST_PROFILE.name} · next runs use it`), + (screen) => screen.includes(`Selected ${analystProfile.name} · next runs use it`), 'trace analyst profile selection', ) terminal.input('\u001b') await terminal.waitForScreen( (screen) => - !screen.includes(`Selected ${LIVE_DEMO_ANALYST_PROFILE.name} · next runs use it`) && - screen.includes(`profile ${LIVE_DEMO_ANALYST_PROFILE.name}`) && - screen.includes( - `${LIVE_DEMO_ANALYST_PROFILE.harness} / ${LIVE_DEMO_ANALYST_PROFILE.model.default}`, - ), + !screen.includes(`Selected ${analystProfile.name} · next runs use it`) && + screen.includes(analystProfile.name) && + screen.includes(`${analystProfile.harness} · ${analystProfile.model.default}`), 'trace analyst active route', ) await pause(500) @@ -425,22 +430,26 @@ async function main() { route: { connection: 'Local CLI Bridge', endpoint: baseUrl, - runner: LIVE_DEMO_PROFILE.harness, + runner: profile.harness, runnerVersion: bridge.backend.version, - provider: LIVE_DEMO_PROFILE.model.provider ?? null, - model: LIVE_DEMO_PROFILE.model.default, + provider: profile.model.provider ?? null, + model: profile.model.default, }, profile: { - name: LIVE_DEMO_PROFILE.name, - reasoningEffort: LIVE_DEMO_PROFILE.model.reasoningEffort, - maxOutputTokens: LIVE_DEMO_PROFILE.model.metadata.maxTokens, + name: profile.name, + reasoningEffort: profile.model.reasoningEffort, + maxVisibleOutputTokens: profile.model.maxVisibleOutputTokens, + maxReasoningTokens: profile.model.maxReasoningTokens, + maxTotalOutputTokens: profile.model.maxTotalOutputTokens, }, analysisProfile: { - name: LIVE_DEMO_ANALYST_PROFILE.name, - runner: LIVE_DEMO_ANALYST_PROFILE.harness, - model: LIVE_DEMO_ANALYST_PROFILE.model.default, - reasoningEffort: LIVE_DEMO_ANALYST_PROFILE.model.reasoningEffort, - maxOutputTokens: LIVE_DEMO_ANALYST_PROFILE.model.metadata.maxTokens, + name: analystProfile.name, + runner: analystProfile.harness, + model: analystProfile.model.default, + reasoningEffort: analystProfile.model.reasoningEffort, + maxVisibleOutputTokens: analystProfile.model.maxVisibleOutputTokens, + maxReasoningTokens: analystProfile.model.maxReasoningTokens, + maxTotalOutputTokens: analystProfile.model.maxTotalOutputTokens, }, task: { prompt: LIVE_DEMO_PROMPT, diff --git a/scripts/live-demo.test.mjs b/scripts/live-demo.test.mjs index 7a1b9de..09455c5 100644 --- a/scripts/live-demo.test.mjs +++ b/scripts/live-demo.test.mjs @@ -21,7 +21,11 @@ import { terminalPageProgress, visibleModelCallNumbers, } from './live-demo/terminal.mjs' -import { LIVE_DEMO_ANALYST_PROFILE, LIVE_DEMO_PROFILE } from './live-demo/workspace.mjs' +import { + liveDemoProfileForRoute, + LIVE_DEMO_ANALYST_PROFILE, + LIVE_DEMO_PROFILE, +} from './live-demo/workspace.mjs' function isAlive(pid) { try { @@ -75,7 +79,7 @@ test('analysis pagination reaches real page bounds and records visible calls', ( test('live demo timeline starts on the configured screen and preserves captured bytes', () => { const events = [ [0.4, 'o', '\u001b[2J'], - [0.8, 'o', 'Product engineer · runner pi'], + [0.8, 'o', 'Release engineer · runner pi'], [1.5, 'i', '/'], [1.51, 'o', '/'], ] @@ -92,7 +96,7 @@ test('live demo timeline starts on the configured screen and preserves captured const cast = castFor( { columns: 120, rows: 30, events }, 'Braid live demo', - 'braid --profile Product-engineer', + 'braid --profile Release-engineer', ) const [headerLine, ...eventLines] = cast.trim().split('\n') const header = JSON.parse(headerLine) @@ -109,16 +113,26 @@ test('live demo timeline starts on the configured screen and preserves captured }) test('live demo profile leaves native tool policy with the selected harness', () => { - assert.equal(LIVE_DEMO_PROFILE.harness, 'claude-code') - assert.equal(LIVE_DEMO_PROFILE.model.default, 'opus') + assert.equal(LIVE_DEMO_PROFILE.harness, 'pi') + assert.equal(LIVE_DEMO_PROFILE.model.default, 'openai-codex/gpt-5.6-luna') + assert.equal(LIVE_DEMO_PROFILE.model.provider, 'openai-codex') assert.equal('tools' in LIVE_DEMO_PROFILE, false) assert.equal('permissions' in LIVE_DEMO_PROFILE, false) - assert.equal(LIVE_DEMO_ANALYST_PROFILE.harness, 'claude-code') - assert.equal(LIVE_DEMO_ANALYST_PROFILE.model.default, 'sonnet') + assert.equal(LIVE_DEMO_ANALYST_PROFILE.harness, 'pi') + assert.equal(LIVE_DEMO_ANALYST_PROFILE.model.default, 'openai-codex/gpt-5.6-luna') + assert.equal(LIVE_DEMO_ANALYST_PROFILE.model.provider, 'openai-codex') assert.equal('tools' in LIVE_DEMO_ANALYST_PROFILE, false) assert.equal('permissions' in LIVE_DEMO_ANALYST_PROFILE, false) }) +test('live demo profiles follow the advertised Pi route without a model fallback', () => { + const profile = liveDemoProfileForRoute('pi/deepseek/deepseek-v4-flash') + assert.equal(profile.harness, 'pi') + assert.equal(profile.model.provider, 'deepseek') + assert.equal(profile.model.default, 'deepseek-v4-flash') + assert.throws(() => liveDemoProfileForRoute('codex/default'), /must start with pi\//u) +}) + test('live demo failures preserve the sanitized Braid diagnostic', () => { assert.equal( terminalFailureDetail({ @@ -323,6 +337,22 @@ test('public capture rejects the credential patterns mirrored from the sanitizer assert.throws(() => assertPublicCapture(value), /Public capture contains/iu) }) } + for (const value of ['fixture/deterministic', 'backend fixture', 'deterministic state capture']) { + await t.test(`offline marker ${value}`, () => { + assert.throws( + () => assertPublicCapture(value), + /Public capture contains|expected to not match/iu, + ) + }) + } + for (const value of ['unknown', 'latency unknown', 'Model calls were not reported']) { + await t.test(`unmeasured marker ${value}`, () => { + assert.throws( + () => assertPublicCapture(value), + /Public capture contains|expected to not match/iu, + ) + }) + } assert.doesNotThrow(() => assertPublicCapture('public demo output with no credentials')) }) diff --git a/scripts/live-demo/public-safety.mjs b/scripts/live-demo/public-safety.mjs index 3336122..130512c 100644 --- a/scripts/live-demo/public-safety.mjs +++ b/scripts/live-demo/public-safety.mjs @@ -18,5 +18,7 @@ export function assertPublicCapture(value) { for (const [label, pattern] of PUBLIC_CREDENTIAL_PATTERNS) { assert.doesNotMatch(value, pattern, `Public capture contains a ${label}`) } - assert.doesNotMatch(value, /fixture\/deterministic|repeatable demo data/iu) + assert.doesNotMatch(value, /\b(?:fixture|deterministic)\b/iu) + assert.doesNotMatch(value, /backend\s+fixture/iu) + assert.doesNotMatch(value, /\b(?:unknown|not reported)\b/iu) } diff --git a/scripts/live-demo/workspace.mjs b/scripts/live-demo/workspace.mjs index a103a59..5f3fbe3 100644 --- a/scripts/live-demo/workspace.mjs +++ b/scripts/live-demo/workspace.mjs @@ -2,13 +2,16 @@ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' export const LIVE_DEMO_PROFILE = Object.freeze({ - name: 'Product engineer', + name: 'Release engineer', description: 'Implements one bounded change and proves it with tests.', - harness: 'claude-code', + harness: 'pi', model: { - default: 'opus', + default: 'openai-codex/gpt-5.6-luna', + provider: 'openai-codex', reasoningEffort: 'high', - metadata: { maxTokens: 32768 }, + maxVisibleOutputTokens: 32768, + maxReasoningTokens: 65536, + maxTotalOutputTokens: 98304, }, prompt: { instructions: [ @@ -22,11 +25,14 @@ export const LIVE_DEMO_PROFILE = Object.freeze({ export const LIVE_DEMO_ANALYST_PROFILE = Object.freeze({ name: 'Trace analyst', description: 'Reviews a completed run and cites the retained execution evidence.', - harness: 'claude-code', + harness: 'pi', model: { - default: 'sonnet', + default: 'openai-codex/gpt-5.6-luna', + provider: 'openai-codex', reasoningEffort: 'high', - metadata: { maxTokens: 16384 }, + maxVisibleOutputTokens: 16384, + maxReasoningTokens: 32768, + maxTotalOutputTokens: 49152, }, prompt: { instructions: [ @@ -42,7 +48,33 @@ export const LIVE_DEMO_PROMPT = export const LIVE_DEMO_QUESTION = 'What changed, what was verified, and what should I review?' -export async function createLiveDemoWorkspace(root) { +/** Build the public demo profile from the route the live bridge actually advertises. */ +export function liveDemoProfileForRoute(route, baseProfile = LIVE_DEMO_PROFILE) { + const parts = route.split('/') + const runner = parts.shift() + if ( + runner !== baseProfile.harness || + parts.length === 0 || + parts.some((part) => part.length === 0) + ) + throw new Error(`Live demo route must start with ${baseProfile.harness}/: ${route}`) + const provider = parts.length > 1 ? parts.shift() : undefined + const model = parts.join('/') + if (model.length === 0) throw new Error(`Live demo route has no model: ${route}`) + return Object.freeze({ + ...baseProfile, + model: Object.freeze({ + ...baseProfile.model, + default: model, + ...(provider === undefined ? { provider: undefined } : { provider }), + }), + }) +} + +export async function createLiveDemoWorkspace( + root, + { profile = LIVE_DEMO_PROFILE, analystProfile = LIVE_DEMO_ANALYST_PROFILE } = {}, +) { const workspace = join(root, 'braid-demo') const sourceRoot = join(workspace, 'src') const testRoot = join(workspace, 'test') @@ -92,12 +124,10 @@ export async function createLiveDemoWorkspace(root) { ].join('\n'), { mode: 0o600 }, ), - writeFile(profilePath, `${JSON.stringify(LIVE_DEMO_PROFILE, null, 2)}\n`, { mode: 0o600 }), - writeFile( - join(profileRoot, 'profile.json'), - `${JSON.stringify(LIVE_DEMO_ANALYST_PROFILE, null, 2)}\n`, - { mode: 0o600 }, - ), + writeFile(profilePath, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 }), + writeFile(join(profileRoot, 'profile.json'), `${JSON.stringify(analystProfile, null, 2)}\n`, { + mode: 0o600, + }), ]) return { workspace, profilePath } } diff --git a/scripts/live-required.test.mjs b/scripts/live-required.test.mjs index fe324b4..64fbcf3 100644 --- a/scripts/live-required.test.mjs +++ b/scripts/live-required.test.mjs @@ -29,6 +29,10 @@ import { } from './live-required/headless.mjs' import { runMatrixAdapter, runSandbox } from './live-required/tangle.mjs' import { executionLatencyDistribution } from './live-required/tangle-sandbox-braid-execution-soak.mjs' +import { + errorDetails, + MissingIntegrationError, +} from './live-required/tangle-sandbox-braid-stress-support.mjs' import { assertExactResumeEvidence, cancellationReplayDetected, @@ -647,6 +651,19 @@ test('nested credential fields and error text are redacted before public output' assert.match(nestedMessage, /\[REDACTED\]/u) }) +test('retained Sandbox error details are safe for direct proof output', () => { + const secret = 'retained-sandbox-error-secret-canary-4f8c' + const rawDetails = errorDetails( + new MissingIntegrationError('provider rejected the request', { + authorization: secret, + nested: { token: secret }, + }), + ) + const output = safeJson({ status: 'failed', failure: rawDetails }, { TANGLE_API_KEY: secret }) + assert.doesNotMatch(output, new RegExp(secret, 'u')) + assert.match(output, /\[REDACTED\]/u) +}) + test('configured supervisor failures stay unavailable and redact environment credentials', async () => { const root = await mkdtemp(join(tmpdir(), 'braid-live-required-supervisor-test-')) const missingRoot = join(root, 'missing-runtime-root') diff --git a/scripts/live-required/contracts.mjs b/scripts/live-required/contracts.mjs index c58121e..8c3f170 100644 --- a/scripts/live-required/contracts.mjs +++ b/scripts/live-required/contracts.mjs @@ -13,6 +13,7 @@ export const PUBLIC_EVIDENCE_SCHEMA = 'braid.live-required.evidence.v1' export const PROOF_OPERATIONS = Object.freeze({ tangleInference: 'tangle.inference.turn', tangleSandbox: 'tangle.sandbox.turn', + tangleSandboxInteractive: 'tangle.sandbox.interactive', traceAnalysis: 'trace.analysis.ask-promote', supervisor: 'supervisor.snapshot-reconnect-steer', }) @@ -33,6 +34,22 @@ const PROOF_OPERATION_CHECKS = Object.freeze({ 'cancel-retry-conflict', 'exact-resource-cleanup', ]), + [PROOF_OPERATIONS.tangleSandboxInteractive]: Object.freeze([ + 'packed-binary', + 'interactive-command', + 'input', + 'detach', + 'reconnect', + 'terminal-resize', + 'same-local-run', + 'same-provider-control-ref', + 'sandbox-observed-before-stop', + 'stop-through-braid', + 'sandbox-observed-stopped', + 'exact-resource-cleanup', + 'process-exited-before-cleanup', + 'process-group-exited-before-cleanup', + ]), [PROOF_OPERATIONS.traceAnalysis]: Object.freeze([ 'source-frozen', 'cited-finding', @@ -55,6 +72,16 @@ const PROOF_OPERATION_FACT_KEYS = Object.freeze({ 'exactResource', 'activeResourceDelta', ]), + [PROOF_OPERATIONS.tangleSandboxInteractive]: Object.freeze([ + 'environmentId', + 'localRunId', + 'stoppedStatus', + 'cloudControl', + 'exactResource', + 'processExitedBeforeWorkspaceCleanup', + 'terminalResize', + 'processGroupExitedBeforeWorkspaceCleanup', + ]), [PROOF_OPERATIONS.traceAnalysis]: Object.freeze([ 'analysisId', 'findingCount', @@ -268,6 +295,10 @@ function validRequiredString(value, label) { throw new Error(`${label} must be a non-empty string`) } +function record(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + function validateCloudControl(value) { if (value === null) return if (typeof value !== 'object' || Array.isArray(value)) @@ -336,7 +367,13 @@ function validateProofFacts(operation, status, facts) { throw new Error('Live proof usage costUsd must be a non-negative finite number') continue } - if (key === 'promoted' || key === 'cancellationAvailable') { + if ( + key === 'promoted' || + key === 'cancellationAvailable' || + key === 'processExitedBeforeWorkspaceCleanup' || + key === 'terminalResize' || + key === 'processGroupExitedBeforeWorkspaceCleanup' + ) { if (typeof value !== 'boolean') throw new Error(`Live proof ${key} must be boolean`) continue } @@ -412,6 +449,59 @@ function validatePassedTangleSandboxReceipt(receipt) { throw new Error('Passed Tangle Sandbox proof requires redacted observations') } +function validatePassedTangleSandboxInteractiveReceipt(receipt) { + if (receipt.run.ids.length !== 1) + throw new Error('Passed Tangle interactive proof requires one local run ID') + validRequiredString(receipt.run.environmentId, 'Passed Tangle interactive local environmentId') + + const requiredConnectionFields = ['endpoint', 'connectionId', 'connectionKind', 'model', 'runner'] + for (const field of requiredConnectionFields) + validRequiredString(receipt.connection[field], `Passed Tangle interactive connection.${field}`) + if (receipt.connection.connectionKind !== 'tangle-sandbox') + throw new Error('Passed Tangle interactive proof requires a tangle-sandbox connection') + if (receipt.connection.runner !== 'pi') + throw new Error('Passed Tangle interactive proof requires the native Pi harness') + if (receipt.connection.credentialConfigured !== true) + throw new Error('Passed Tangle interactive proof requires configured credentials') + + if (receipt.facts.localRunId !== receipt.run.ids[0]) + throw new Error('Passed Tangle interactive local run identity is inconsistent') + if (receipt.facts.environmentId !== receipt.run.environmentId) + throw new Error('Passed Tangle interactive environment identity is inconsistent') + if (!['aborted', 'cancelled'].includes(receipt.facts.stoppedStatus)) + throw new Error('Passed Tangle interactive proof requires a terminal stopped status') + const cloudControl = receipt.facts.cloudControl + if (cloudControl === null) + throw new Error('Passed Tangle interactive proof requires exact cloud control identity') + for (const field of CLOUD_CONTROL_KEYS) + validRequiredString(cloudControl[field], `Passed Tangle interactive cloudControl.${field}`) + if (cloudControl.provider !== 'tangle-sandbox') + throw new Error('Passed Tangle interactive cloud control identity has the wrong provider') + if (!/^sha256:[0-9a-f]{64}$/u.test(cloudControl.requestDigest)) + throw new Error('Passed Tangle interactive cloud control requestDigest is not a SHA-256 digest') + if (receipt.facts.exactResource !== true) + throw new Error('Passed Tangle interactive proof requires exact resource cleanup') + if (receipt.facts.processExitedBeforeWorkspaceCleanup !== true) + throw new Error('Passed Tangle interactive proof requires process exit before cleanup') + if (receipt.facts.terminalResize !== true) + throw new Error('Passed Tangle interactive proof requires terminal resize evidence') + if (receipt.facts.processGroupExitedBeforeWorkspaceCleanup !== true) + throw new Error('Passed Tangle interactive proof requires process-group exit before cleanup') + if (!record(receipt.observations) || Object.keys(receipt.observations).length === 0) + throw new Error('Passed Tangle interactive proof requires redacted observations') + for (const field of [ + 'checks', + 'configuration', + 'run', + 'sandbox', + 'identityContinuity', + 'processCleanup', + ]) { + if (!record(receipt.observations[field])) + throw new Error(`Passed Tangle interactive proof requires observations.${field}`) + } +} + export function proofInvocation(scope) { if (typeof scope !== 'string' || scope.trim().length === 0) throw new Error('Live proof scope must be a non-empty string') @@ -479,6 +569,11 @@ export function assertProofReceipt(receipt, { invocationId, operation } = {}) { throw new Error('Passed live proof must include every check for the named operation') if (receipt.operation === PROOF_OPERATIONS.tangleSandbox && receipt.status === 'passed') validatePassedTangleSandboxReceipt(receipt) + if ( + receipt.operation === PROOF_OPERATIONS.tangleSandboxInteractive && + receipt.status === 'passed' + ) + validatePassedTangleSandboxInteractiveReceipt(receipt) return receipt } diff --git a/scripts/live-required/headless.mjs b/scripts/live-required/headless.mjs index 61a6b3f..18991d5 100644 --- a/scripts/live-required/headless.mjs +++ b/scripts/live-required/headless.mjs @@ -449,12 +449,14 @@ export async function initializedSession(binary, config, fixture) { } export async function closeSession(session) { + let closed try { if (!session.closed) await rpcRequest(session, 'shutdown', {}, `op-live-required-shutdown-${randomUUID()}`) } finally { - await session.close().catch(() => undefined) + closed = await session.close().catch(() => undefined) } + return closed } export async function rpcState(session, projection = 'full') { diff --git a/scripts/live-required/tangle-sandbox-braid-interactive.mjs b/scripts/live-required/tangle-sandbox-braid-interactive.mjs new file mode 100644 index 0000000..18d4d84 --- /dev/null +++ b/scripts/live-required/tangle-sandbox-braid-interactive.mjs @@ -0,0 +1,991 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { + AgentExactRunControlRefSchema, + AgentInteractiveSessionRefSchema, +} from '@tangle-network/agent-interface' +import { Sandbox } from '@tangle-network/sandbox' +import xterm from '@xterm/headless' +import * as pty from 'node-pty' +import { sleep } from '../live-bridge/process.mjs' +import { waitForTreeGone } from '../live-bridge/process-tree.mjs' +import { runFromState, stateForRun } from '../live-bridge/protocol.mjs' +import { installPackedBraid } from '../packed-binary.mjs' +import { connectionConfiguration } from './configuration.mjs' +import { + EXIT_CODES, + PROOF_OPERATIONS, + proofInvocation, + proofReceipt, + protectedUnavailable, + safeJson, + safeMessage, + scalarMeasurement, +} from './contracts.mjs' +import { + closeSession, + configEvidence, + initializedSession, + prepareProductionWorkspace, + rpcRequest, + rpcState, +} from './headless.mjs' +import { + assertVerifiedProcessCleanup, + cleanupRetainedResourceByControlRef, + observeRetainedResource, +} from './tangle-sandbox-braid-stress.mjs' + +const scriptPath = fileURLToPath(import.meta.url) +const repository = resolve(dirname(scriptPath), '../..') +const DEFAULT_TIMEOUT_MS = 180_000 +const DEFAULT_IDLE_TTL_SECONDS = 1_800 +const DEFAULT_PROCESS_EXIT_TIMEOUT_MS = 10_000 +const DETACH = '\u001d' +const RUN_STATUS_AFTER_STOP = new Set(['aborted', 'cancelled']) +const CONTROL_REF_FIELDS = Object.freeze([ + 'provider', + 'environmentId', + 'sessionId', + 'executionId', + 'runId', + 'requestDigest', +]) +const INTERACTIVE_PROOF_CHECKS = Object.freeze([ + 'packed-binary', + 'interactive-command', + 'input', + 'detach', + 'reconnect', + 'terminal-resize', + 'same-local-run', + 'same-provider-control-ref', + 'sandbox-observed-before-stop', + 'stop-through-braid', + 'sandbox-observed-stopped', + 'exact-resource-cleanup', + 'process-exited-before-cleanup', + 'process-group-exited-before-cleanup', +]) +const SECRET_ENVIRONMENT_NAMES = [ + 'BRAID_TANGLE_SANDBOX_AUTH', + 'BRAID_TANGLE_SANDBOX_API_KEY', + 'BRAID_TANGLE_SANDBOX_BEARER', + 'BRAID_TANGLE_SANDBOX_CLEANUP_API_KEY', + 'TANGLE_API_KEY', +] + +function argument(name, argv = process.argv) { + const index = argv.indexOf(`--${name}`) + return index >= 0 && index + 1 < argv.length ? argv[index + 1] : undefined +} + +function positiveEnvironment(environment, name, fallback) { + const value = Number(environment[name]) + return Number.isFinite(value) && value > 0 ? value : fallback +} + +function configurationEnvironment(environment) { + if ( + !environment.BRAID_TANGLE_SANDBOX_CREDENTIAL_REF && + !environment.BRAID_TANGLE_SANDBOX_AUTH && + !environment.BRAID_TANGLE_SANDBOX_API_KEY && + !environment.BRAID_TANGLE_SANDBOX_BEARER && + environment.TANGLE_API_KEY + ) { + return { ...environment, BRAID_TANGLE_SANDBOX_API_KEY: environment.TANGLE_API_KEY } + } + return environment +} + +function sanitizedEnvironment(environment) { + const child = { ...environment } + for (const name of SECRET_ENVIRONMENT_NAMES) delete child[name] + return child +} + +export function sandboxConfiguration(environment) { + return connectionConfiguration(configurationEnvironment(environment), { + prefix: 'BRAID_TANGLE_SANDBOX', + kind: 'tangle-sandbox', + endpointNames: ['BRAID_TANGLE_ENDPOINT'], + modelNames: ['BRAID_TANGLE_MODEL'], + runnerNames: ['BRAID_TANGLE_RUNNER'], + providerNames: ['BRAID_TANGLE_SANDBOX_PROVIDER'], + fallbackEndpoint: 'https://sandbox.tangle.tools', + fallbackModel: 'tangle-router/glm-5.2', + fallbackRunner: 'pi', + fallbackProvider: 'tangle', + }) +} + +export function interactiveProofCommandSequence(markers) { + const input = markers.input ?? markers.inputSeed?.toUpperCase() + const reconnect = markers.reconnect ?? markers.reconnectSeed?.toUpperCase() + return [ + `/interactive Reply with exactly the uppercase version of ${markers.outputSeed}.`, + input, + DETACH, + '/help', + '/attach', + reconnect, + DETACH, + '/help', + ] +} + +function occurrences(value, marker) { + if (!marker) return 0 + let count = 0 + let offset = 0 + while (offset >= 0) { + offset = value.indexOf(marker, offset) + if (offset < 0) break + count += 1 + offset += marker.length + } + return count +} + +function terminalText(terminal) { + let text = '' + for (let row = 0; row < terminal.rows; row += 1) { + text += terminal.buffer.active.getLine(row)?.translateToString(true) ?? '' + text += '\n' + } + return text +} + +async function waitFor(label, predicate, timeoutMs) { + const deadline = performance.now() + timeoutMs + for (;;) { + const value = await predicate() + if (value) return value + if (performance.now() >= deadline) throw new Error(`${label} timed out after ${timeoutMs}ms`) + await sleep(50) + } +} + +function createPty(binary, config, statePath, exitTimeoutMs) { + const terminal = new xterm.Terminal({ cols: 120, rows: 36, allowProposedApi: true }) + const child = pty.spawn(process.execPath, [binary, '--inline', '--record-state', statePath], { + cwd: config.workspace, + env: { + ...config.environment, + BRAID_SHUTDOWN_MODE: 'detach', + NO_COLOR: '1', + NODE_NO_WARNINGS: '1', + }, + name: 'xterm-256color', + cols: 120, + rows: 36, + }) + let output = '' + let exited = false + let exitResult + let processCleanup + const waitForProcessCleanup = async () => { + processCleanup ??= await waitForTreeGone(child, exitTimeoutMs) + return processCleanup + } + child.onData((chunk) => { + output += chunk + terminal.write(chunk) + }) + child.onExit((result) => { + exited = true + exitResult = result + }) + return { + child, + terminal, + get output() { + return output + }, + get screen() { + return terminalText(terminal) + }, + get exited() { + return exited + }, + get processCleanup() { + return processCleanup + }, + write(value) { + child.write(value) + }, + resize(columns, rows) { + assert.ok(Number.isInteger(columns) && columns > 0, 'PTY columns must be positive') + assert.ok(Number.isInteger(rows) && rows > 0, 'PTY rows must be positive') + child.resize(columns, rows) + terminal.resize(columns, rows) + }, + async close() { + if (exited) return { ...exitResult, processCleanup: await waitForProcessCleanup() } + child.write('\u0003') + await waitFor( + 'Braid terminal quit prompt', + () => /Ctrl\+C again to quit/iu.test(this.screen), + exitTimeoutMs, + ) + child.write('\u0003') + const result = await waitFor('Braid terminal exit', () => exited && exitResult, exitTimeoutMs) + return { ...result, processCleanup: await waitForProcessCleanup() } + }, + async forceClose() { + if (!exited) child.kill() + const result = await waitFor( + 'forced Braid terminal exit', + () => exited && exitResult, + exitTimeoutMs, + ) + return { ...result, processCleanup: await waitForProcessCleanup() } + }, + dispose() { + terminal.dispose() + }, + } +} + +async function captureStateFrame(runtime, recordPath, timeoutMs) { + await rm(`${recordPath}.frame`, { force: true }) + process.kill(runtime.child.pid, 'SIGUSR2') + return waitFor( + 'atomic Braid interactive state frame', + async () => { + try { + return JSON.parse(await readFile(`${recordPath}.frame`, 'utf8')) + } catch { + return undefined + } + }, + timeoutMs, + ) +} + +async function proveTuiReturned(runtime, timeoutMs, label) { + const before = runtime.output.length + runtime.write('/help\r') + await waitFor(`${label} returned to Braid`, () => /Commands/iu.test(runtime.screen), timeoutMs) + runtime.write('\u001b') + await waitFor( + `${label} closed the Braid help surface`, + () => !/Commands/iu.test(runtime.screen), + timeoutMs, + ) + return { outputBytes: runtime.output.length - before } +} + +function eventKind(event) { + return event?.kind ?? event?.event?.kind ?? event?.type +} + +function eventRunId(event) { + return event?.runId ?? event?.event?.runId +} + +function assertOrderedRunEvents(events, runId, required) { + let nextIndex = 0 + for (const kind of required) { + const found = events.findIndex( + (event, index) => + index >= nextIndex && + eventKind(event) === kind && + (eventRunId(event) === undefined || eventRunId(event) === runId), + ) + assert.ok(found >= nextIndex, `interactive record omitted ordered ${kind}`) + nextIndex = found + 1 + } +} + +function exactControlRef(value, label) { + const parsed = AgentExactRunControlRefSchema.safeParse(value) + assert.ok(parsed.success, `${label} is not a valid exact Braid control reference`) + return parsed.data +} + +function assertControlRefsEqual(expected, actual, label) { + const expectedRef = exactControlRef(expected, `${label} expected`) + const actualRef = exactControlRef(actual, `${label} actual`) + for (const field of CONTROL_REF_FIELDS) { + assert.equal(actualRef[field], expectedRef[field], `${label}.${field} mismatch`) + } + return actualRef +} + +function interactiveAdmissionIdentity(run) { + const admission = run?.retainedAdmission + assert.equal( + admission?.phase, + 'interactive_started', + 'interactive proof requires the canonical interactive_started admission phase', + ) + assert.equal( + typeof admission?.interactiveIdempotencyKey, + 'string', + 'interactive_started admission omitted interactiveIdempotencyKey', + ) + assert.ok( + admission.interactiveIdempotencyKey.length > 0, + 'interactive_started admission has an empty interactiveIdempotencyKey', + ) + const parsedRef = AgentInteractiveSessionRefSchema.safeParse(admission.ref) + assert.ok(parsedRef.success, 'interactive_started admission ref is invalid') + const controlRef = assertControlRefsEqual( + run.controlRef, + parsedRef.data.run, + 'interactive admission control reference', + ) + if (run.providerSessionId !== undefined) { + assert.equal( + parsedRef.data.run.sessionId, + run.providerSessionId, + 'interactive admission session differs from the stored provider session', + ) + } + return { admission, ref: parsedRef.data, controlRef } +} + +export function recoverInteractiveIdentity(record) { + const runs = Array.isArray(record?.state?.runs) ? record.state.runs : [] + if (runs.length !== 1 || typeof runs[0]?.id !== 'string' || runs[0].id.length === 0) + return undefined + const run = runs[0] + try { + const { admission, ref, controlRef } = interactiveAdmissionIdentity(run) + return { + run, + admission, + ref, + controlRef, + eventKinds: (Array.isArray(record?.events) ? record.events : []).map(eventKind), + } + } catch { + return undefined + } +} + +export function assertInteractiveRecord(record) { + const runs = Array.isArray(record?.state?.runs) ? record.state.runs : [] + assert.equal(runs.length, 1, 'interactive proof must retain exactly one local run') + const run = runs[0] + const { controlRef } = interactiveAdmissionIdentity(run) + assert.equal(controlRef.provider, 'tangle-sandbox') + const events = Array.isArray(record?.events) ? record.events : [] + const kinds = events.map(eventKind) + for (const required of ['run.control.requested', 'run.control.acknowledged', 'run.detached']) { + assert.ok(kinds.includes(required), `interactive record omitted ${required}`) + } + assert.ok(kinds.includes('run.reconnecting'), 'interactive record omitted run.reconnecting') + assertOrderedRunEvents(events, run.id, [ + 'run.control.requested', + 'run.control.acknowledged', + 'run.detached', + 'run.reconnecting', + 'run.detached', + ]) + assert.equal(run.status, 'detached', 'TUI must leave the retained run detached before stop') + return { run, controlRef, eventKinds: kinds } +} + +export function assertStoppedTerminal(terminal) { + if (terminal === null) return { terminal: null, stopped: true } + assert.ok(terminal && typeof terminal === 'object', 'Sandbox terminal observation was invalid') + assert.equal(terminal.isRunning, false, 'Sandbox terminal remained active after Braid stop') + return { terminal, stopped: true } +} + +async function observeSandbox(client, controlRef, timeoutMs, expectedRunning, expectedGeometry) { + const resource = await observeRetainedResource(client, controlRef) + const box = await client.get(controlRef.environmentId) + assert.equal(box?.id, resource.id, 'Sandbox observation changed environment identity') + assert.ok(box.terminals && typeof box.terminals.get === 'function') + let terminal + await waitFor( + expectedRunning ? 'retained interactive terminal' : 'stopped retained interactive terminal', + async () => { + terminal = await box.terminals.get(controlRef.sessionId) + if (terminal !== null) assert.equal(terminal.sessionId, controlRef.sessionId) + if (expectedRunning) { + return ( + terminal?.isRunning === true && + (expectedGeometry === undefined || + (terminal.cols === expectedGeometry.cols && terminal.rows === expectedGeometry.rows)) + ) + } + return terminal === null || terminal?.isRunning === false + }, + timeoutMs, + ) + const stopped = expectedRunning ? undefined : assertStoppedTerminal(terminal) + if (expectedGeometry !== undefined) { + assert.equal( + terminal?.cols, + expectedGeometry.cols, + 'Sandbox terminal columns were not retained', + ) + assert.equal(terminal?.rows, expectedGeometry.rows, 'Sandbox terminal rows were not retained') + } + return { + resource, + terminal, + ...(terminal === null + ? {} + : { geometry: { cols: terminal.cols ?? null, rows: terminal.rows ?? null } }), + ...(stopped ?? {}), + } +} + +async function cleanupExactSandbox(client, identity) { + const observed = await observeRetainedResource(client, identity.controlRef) + const cleanup = await cleanupRetainedResourceByControlRef(client, identity.controlRef) + assert.equal(cleanup.id, observed.id, 'Sandbox cleanup changed the exact Braid resource') + assert.equal(cleanup.id, identity.controlRef.environmentId) + return cleanup +} + +function cleanupFailure(label, error) { + return new Error(`${label}: ${safeMessage(error)}`, { cause: error }) +} + +function assertStopResultMatchesIdentity(stopResult, identity) { + assert.ok(stopResult && typeof stopResult === 'object', 'Braid stop returned no result') + const controlRef = assertControlRefsEqual( + identity.controlRef, + stopResult.controlRef, + 'Braid stop result control reference', + ) + assert.ok( + RUN_STATUS_AFTER_STOP.has(stopResult.run?.status), + 'Braid stop result did not prove a terminal stopped status', + ) + assert.equal( + stopResult.run?.id, + identity.run.id, + 'Braid stop result returned a different local run', + ) + return controlRef +} + +async function attemptCleanup(errors, label, operation) { + try { + return { ok: true, value: await operation() } + } catch (error) { + errors.push(cleanupFailure(label, error)) + return { ok: false, value: undefined } + } +} + +export async function finalizeInteractiveProof({ + packed, + config, + runtime, + recordPath, + identity, + client, + timeoutMs = DEFAULT_TIMEOUT_MS, + exitTimeoutMs = DEFAULT_PROCESS_EXIT_TIMEOUT_MS, + executionStarted = runtime !== undefined, + stopped = false, + stopResult, + stop = ({ binary, targetConfig, runId, timeout }) => + stopThroughBraid(binary, targetConfig, runId, timeout), + observe = (targetClient, controlRef, timeout) => + observeSandbox(targetClient, controlRef, timeout, false), + cleanupSandbox = cleanupExactSandbox, +} = {}) { + const errors = [] + let processExited = runtime === undefined || runtime.exited === true + let processGroupExited = + runtime === undefined || + (runtime.processCleanup?.supported === true && runtime.processCleanup.gone === true) + if (runtime !== undefined && !processExited) { + await attemptCleanup(errors, 'PTY process exit', () => runtime.forceClose(exitTimeoutMs)) + processExited = runtime.exited === true + processGroupExited = + runtime.processCleanup?.supported === true && runtime.processCleanup.gone === true + } + if (runtime !== undefined && processExited) { + await attemptCleanup(errors, 'PTY terminal disposal', () => runtime.dispose?.()) + } + if (runtime !== undefined && !processExited) { + errors.push(new Error('PTY process did not exit; workspace cleanup was refused')) + } + if (runtime !== undefined && processExited && !processGroupExited) { + errors.push(new Error('PTY process group did not exit; workspace cleanup was refused')) + } + + let resolvedIdentity = identity + if (resolvedIdentity === undefined && recordPath !== undefined) { + const recovered = await attemptCleanup(errors, 'recorded Braid identity recovery', async () => { + const record = JSON.parse(await readFile(recordPath, 'utf8')) + const recoveredIdentity = recoverInteractiveIdentity(record) + if (recoveredIdentity === undefined) + throw new Error('recorded state did not contain one exact Braid run identity') + return recoveredIdentity + }) + if (recovered.ok) resolvedIdentity = recovered.value + } + if (executionStarted && resolvedIdentity === undefined) { + errors.push(new Error('Braid run identity was unavailable; exact cloud cleanup was refused')) + } + + let resolvedStop = stopResult + let didStop = false + if (resolvedIdentity !== undefined && resolvedStop !== undefined) { + const verifiedStop = await attemptCleanup(errors, 'Braid stop identity', () => + assertStopResultMatchesIdentity(resolvedStop, resolvedIdentity), + ) + didStop = verifiedStop.ok + } + if (resolvedIdentity !== undefined && resolvedStop === undefined && stopped) { + errors.push(new Error('Braid stop result was unavailable; exact cloud cleanup was refused')) + } else if (resolvedIdentity !== undefined && resolvedStop === undefined) { + if (packed?.binary === undefined || config === undefined) { + errors.push(new Error('Braid stop was unavailable; exact cloud cleanup was refused')) + } else { + const stoppedResult = await attemptCleanup(errors, 'Braid stop', () => + stop({ + binary: packed.binary, + targetConfig: config, + runId: resolvedIdentity.run.id, + timeout: timeoutMs, + }), + ) + if (stoppedResult.ok) { + resolvedStop = stoppedResult.value + const verifiedStop = await attemptCleanup(errors, 'Braid stop identity', () => + assertStopResultMatchesIdentity(resolvedStop, resolvedIdentity), + ) + didStop = verifiedStop.ok + } + } + } + + let afterStop + let cleanup + if (resolvedIdentity !== undefined && didStop && processExited && processGroupExited) { + if (client === undefined) { + errors.push( + new Error('Sandbox observation client was unavailable; exact deletion was refused'), + ) + } else { + const observed = await attemptCleanup(errors, 'stopped Sandbox observation', () => + observe(client, resolvedIdentity.controlRef, timeoutMs), + ) + if (observed.ok) { + afterStop = observed.value + const deleted = await attemptCleanup(errors, 'exact Sandbox deletion', () => + cleanupSandbox(client, resolvedIdentity), + ) + if (deleted.ok) { + cleanup = deleted.value + if ( + cleanup?.confirmed !== true || + cleanup.id !== resolvedIdentity.controlRef.environmentId + ) + errors.push(new Error('exact Sandbox deletion did not return confirmed identity')) + } + } + } + } else if (resolvedIdentity !== undefined && didStop) { + errors.push(new Error('PTY process group did not exit; exact cloud deletion was refused')) + } else if (resolvedIdentity !== undefined) { + errors.push(new Error('Braid stop was not proven; exact cloud cleanup was refused')) + } + + if (config !== undefined) { + if (!processExited || !processGroupExited) { + errors.push( + new Error('Braid workspace cleanup was refused while the PTY process group remained'), + ) + } else { + const workspace = await attemptCleanup(errors, 'Braid workspace cleanup', () => + config.cleanup(), + ) + if ( + workspace.ok && + (workspace.value?.credentialRemoved !== true || + workspace.value?.temporaryRootRemoved !== true) + ) + errors.push(new Error('Braid workspace cleanup returned incomplete evidence')) + } + } + if (packed !== undefined) { + if (processExited && processGroupExited) { + await attemptCleanup(errors, 'packed Braid cleanup', () => packed.cleanup()) + } else { + errors.push( + new Error('packed Braid cleanup was refused while the PTY process group remained alive'), + ) + } + } + + if (errors.length > 0) { + const failure = new AggregateError(errors, 'Braid interactive proof cleanup incomplete') + failure.code = 'BRAID_INTERACTIVE_CLEANUP_INCOMPLETE' + throw failure + } + return { + processExited, + processGroupExited, + processCleanup: runtime?.processCleanup, + identity: resolvedIdentity, + stop: resolvedStop, + afterStop, + cleanup, + } +} + +async function stopThroughBraid(binary, config, runId, timeoutMs) { + const initialized = await initializedSession(binary, config) + let result + try { + const before = await rpcState(initialized.session) + const beforeRun = runFromState(before.state, runId) + if (RUN_STATUS_AFTER_STOP.has(beforeRun?.status)) { + result = { + operationId: undefined, + acknowledgement: { outcome: 'already-applied' }, + run: beforeRun, + controlRef: exactControlRef(beforeRun.controlRef, 'already-stopped Braid run'), + } + } else { + assert.equal(beforeRun?.status, 'detached', 'Braid stop must target the detached run') + const operationId = `live-interactive-stop-${randomUUID()}` + const acknowledgement = await rpcRequest( + initialized.session, + 'cancel_run', + { runId, reason: 'Braid packed-binary interactive live proof complete' }, + operationId, + ) + assert.ok( + acknowledgement.outcome === 'accepted' || acknowledgement.outcome === 'replayed', + 'Braid stop did not return an accepted or replayed outcome', + ) + const terminal = await initialized.session.waitFor( + 'Braid interactive stop state', + (candidate) => { + const run = stateForRun(candidate, runId) + return run !== undefined && RUN_STATUS_AFTER_STOP.has(run.status) + }, + timeoutMs, + ) + const stoppedRun = runFromState(terminal.state, runId) + assert.ok(stoppedRun, 'Braid stop did not return the target run') + assert.ok(RUN_STATUS_AFTER_STOP.has(stoppedRun.status)) + const controlRef = assertControlRefsEqual( + beforeRun.controlRef, + stoppedRun.controlRef, + 'Braid stop state control reference', + ) + result = { operationId, acknowledgement, run: stoppedRun, controlRef } + } + } finally { + const closed = await closeSession(initialized.session) + const processCleanup = assertVerifiedProcessCleanup( + closed?.termination, + 'Braid interactive stop RPC process', + ) + if (result !== undefined) result = { ...result, processCleanup } + } + return result +} + +async function runProof({ + repository: targetRepository = repository, + environment = process.env, +} = {}) { + const timeoutMs = positiveEnvironment( + environment, + 'BRAID_TANGLE_SANDBOX_INTERACTIVE_TIMEOUT_MS', + DEFAULT_TIMEOUT_MS, + ) + const exitTimeoutMs = Math.min(DEFAULT_PROCESS_EXIT_TIMEOUT_MS, timeoutMs) + const idleTtlSeconds = positiveEnvironment( + environment, + 'BRAID_TANGLE_SANDBOX_INTERACTIVE_IDLE_TTL_SECONDS', + DEFAULT_IDLE_TTL_SECONDS, + ) + const values = sandboxConfiguration(environment) + assert.equal(values.runner, 'pi', 'LIVE-08 native interactive proof must run the Pi harness') + if (!values.credentialValue) { + throw protectedUnavailable( + 'SANDBOX_OBSERVATION_CREDENTIAL_REQUIRED', + 'The packed interactive proof needs a raw Sandbox credential to observe and clean its exact Braid resource', + ) + } + + let packed + let config + let runtime + let recordPath + let identity + let client + let proofData + let proofError + let stopped = false + try { + packed = await installPackedBraid(targetRepository) + config = await prepareProductionWorkspace({ + repository: targetRepository, + environment: sanitizedEnvironment(configurationEnvironment(environment)), + kind: values.kind, + endpoint: values.endpoint, + model: values.model, + runner: values.runner, + provider: values.provider, + providerOptions: { lifecycle: 'retained', idleTtlSeconds }, + credentialRef: values.credentialRef, + credentialValue: values.credentialValue, + }) + client = new Sandbox({ baseUrl: values.endpoint, apiKey: values.credentialValue }) + recordPath = join(config.root, 'interactive-state.json') + runtime = createPty(packed.binary, config, recordPath, exitTimeoutMs) + await waitFor('packed Braid TUI startup', () => /Braid/iu.test(runtime.screen), timeoutMs) + + const markers = { + outputSeed: `braid_interactive_output_${randomUUID().replaceAll('-', '')}`, + inputSeed: `braid_interactive_input_${randomUUID().replaceAll('-', '')}`, + reconnectSeed: `braid_interactive_reconnect_${randomUUID().replaceAll('-', '')}`, + } + markers.output = markers.outputSeed.toUpperCase() + markers.input = markers.inputSeed.toUpperCase() + markers.reconnect = markers.reconnectSeed.toUpperCase() + + const [interactiveCommand, inputMarker, detach, , attach, reconnectMarker] = + interactiveProofCommandSequence(markers) + const promptCount = occurrences(runtime.output, markers.output) + runtime.write(`${interactiveCommand}\r`) + await waitFor( + 'native interactive output', + () => occurrences(runtime.output, markers.output) > promptCount, + timeoutMs, + ) + const initialFrame = await captureStateFrame(runtime, recordPath, timeoutMs) + const initialIdentity = recoverInteractiveIdentity(initialFrame) + assert.ok(initialIdentity, 'interactive frame did not contain one exact Braid run identity') + const initialAttach = await observeSandbox( + client, + initialIdentity.controlRef, + timeoutMs, + true, + { cols: 120, rows: 36 }, + ) + const inputCount = occurrences(runtime.output, markers.input) + runtime.write(`${inputMarker}\r`) + await waitFor( + 'native interactive input', + () => occurrences(runtime.output, markers.input) > inputCount, + timeoutMs, + ) + + runtime.write(detach) + await proveTuiReturned(runtime, timeoutMs, 'native interactive detach') + runtime.write(`${attach}\r`) + await sleep(250) + runtime.resize(100, 30) + const resized = await observeSandbox(client, initialIdentity.controlRef, timeoutMs, true, { + cols: 100, + rows: 30, + }) + const reconnectedFrame = await captureStateFrame(runtime, recordPath, timeoutMs) + const reconnectedIdentity = recoverInteractiveIdentity(reconnectedFrame) + assert.ok(reconnectedIdentity, 'reconnected frame did not contain one exact Braid run identity') + assert.equal( + reconnectedIdentity.run.id, + initialIdentity.run.id, + 'native reconnect changed the local run identity', + ) + assertControlRefsEqual( + initialIdentity.controlRef, + reconnectedIdentity.controlRef, + 'native reconnect provider control reference', + ) + const reconnectCount = occurrences(runtime.output, markers.reconnect) + runtime.write(`${reconnectMarker}\r`) + await waitFor( + 'native interactive reconnect input', + () => occurrences(runtime.output, markers.reconnect) > reconnectCount, + timeoutMs, + ) + runtime.write(detach) + await proveTuiReturned(runtime, timeoutMs, 'native interactive reconnect detach') + const exit = await runtime.close() + assert.equal(exit.exitCode, 0, 'packed Braid TUI exited with a non-zero status') + + const record = JSON.parse(await readFile(recordPath, 'utf8')) + identity = assertInteractiveRecord(record) + assert.equal( + identity.run.id, + initialIdentity.run.id, + 'final TUI state changed the local run identity', + ) + assertControlRefsEqual( + initialIdentity.controlRef, + identity.controlRef, + 'final TUI state provider control reference', + ) + const sameLocalRun = + initialIdentity.run.id === reconnectedIdentity.run.id && + initialIdentity.run.id === identity.run.id + const sameProviderControlRef = CONTROL_REF_FIELDS.every( + (field) => + initialIdentity.controlRef[field] === reconnectedIdentity.controlRef[field] && + initialIdentity.controlRef[field] === identity.controlRef[field], + ) + const beforeStop = await observeSandbox(client, identity.controlRef, timeoutMs, true, { + cols: 100, + rows: 30, + }) + const stop = await stopThroughBraid(packed.binary, config, identity.run.id, timeoutMs) + stopped = true + proofData = { + markers, + initialAttach, + resized, + beforeStop, + stop, + sameLocalRun, + sameProviderControlRef, + identityContinuity: { + initialLocalRunId: initialIdentity.run.id, + reconnectedLocalRunId: reconnectedIdentity.run.id, + finalLocalRunId: identity.run.id, + initialControlRef: initialIdentity.controlRef, + reconnectedControlRef: reconnectedIdentity.controlRef, + finalControlRef: identity.controlRef, + }, + } + } catch (error) { + proofError = error + } + + let cleanup + let cleanupError + try { + cleanup = await finalizeInteractiveProof({ + packed, + config, + runtime, + recordPath, + identity, + client, + timeoutMs, + exitTimeoutMs, + executionStarted: runtime !== undefined, + stopped, + stopResult: proofData?.stop, + }) + } catch (error) { + cleanupError = error + } + + if (proofError !== undefined && cleanupError !== undefined) + throw new AggregateError( + [proofError, cleanupError], + 'Braid interactive proof failed and cleanup was incomplete', + ) + if (cleanupError !== undefined) throw cleanupError + if (proofError !== undefined) throw proofError + assert.ok(proofData && cleanup, 'interactive proof completed without proof and cleanup evidence') + + return { + status: 'passed', + proof: 'braid-packed-binary-native-interactive', + checks: { + packedBinary: true, + interactiveCommand: true, + input: true, + detach: true, + reconnect: true, + terminalResize: + proofData.resized.geometry?.cols === 100 && proofData.resized.geometry?.rows === 30, + sameLocalRun: proofData.sameLocalRun === true, + sameProviderControlRef: proofData.sameProviderControlRef === true, + sandboxObservedBeforeStop: proofData.beforeStop.terminal?.isRunning === true, + stopThroughBraid: RUN_STATUS_AFTER_STOP.has(proofData.stop.run.status), + sandboxObservedStopped: cleanup.afterStop?.stopped === true, + exactSandboxCleanup: cleanup.cleanup?.confirmed === true, + processExitedBeforeWorkspaceCleanup: cleanup.processExited === true, + processGroupExitedBeforeWorkspaceCleanup: cleanup.processGroupExited === true, + }, + binary: { tarballSha256: packed.tarballSha256 }, + configuration: configEvidence(config), + run: { + localRunId: proofData.stop.run.id, + controlRef: identity.controlRef, + eventKinds: identity.eventKinds, + stoppedStatus: proofData.stop.run.status, + }, + sandbox: { + initialAttach: proofData.initialAttach, + resized: proofData.resized, + beforeStop: proofData.beforeStop.resource, + afterStop: cleanup.afterStop.resource, + cleanup: cleanup.cleanup, + }, + identityContinuity: proofData.identityContinuity, + processCleanup: cleanup.processCleanup, + markers: proofData.markers, + } +} + +export async function runInteractiveProof({ + repository: targetRepository = repository, + environment = process.env, + invocationId = proofInvocation('live-tangle'), +} = {}) { + const startedAt = new Date().toISOString() + const proof = await runProof({ repository: targetRepository, environment }) + const evidence = proofReceipt({ + invocationId, + operation: PROOF_OPERATIONS.tangleSandboxInteractive, + startedAt, + completedAt: new Date().toISOString(), + config: proof.configuration, + runIds: [proof.run.localRunId], + environmentId: proof.run.controlRef.environmentId, + facts: { + environmentId: proof.run.controlRef.environmentId, + localRunId: proof.run.localRunId, + stoppedStatus: proof.run.stoppedStatus, + cloudControl: proof.run.controlRef, + exactResource: proof.checks.exactSandboxCleanup, + processExitedBeforeWorkspaceCleanup: proof.checks.processExitedBeforeWorkspaceCleanup, + terminalResize: proof.checks.terminalResize, + processGroupExitedBeforeWorkspaceCleanup: + proof.checks.processGroupExitedBeforeWorkspaceCleanup, + }, + checks: INTERACTIVE_PROOF_CHECKS, + observations: proof, + environment, + }) + return { + status: 'passed', + measurement: scalarMeasurement('LIVE-08'), + evidence, + } +} + +export async function main(argv = process.argv, environment = process.env) { + const outputPath = argument('output', argv) + try { + if (outputPath) await rm(outputPath, { force: true }) + const result = await runProof({ environment }) + if (outputPath) await writeFile(outputPath, `${safeJson(result, environment)}\n`) + process.stdout.write(`${safeJson(result, environment)}\n`) + return EXIT_CODES.passed + } catch (error) { + const status = error?.unavailable === true ? 'unavailable' : 'failed' + process.stderr.write( + `${safeJson({ status, error: safeMessage(error, environment) }, environment)}\n`, + ) + return status === 'unavailable' ? EXIT_CODES.unavailable : EXIT_CODES.failed + } +} + +if (process.argv[1] === scriptPath) process.exitCode = await main() diff --git a/scripts/live-required/tangle-sandbox-braid-soak.mjs b/scripts/live-required/tangle-sandbox-braid-soak.mjs index 1ee6495..58eb77f 100644 --- a/scripts/live-required/tangle-sandbox-braid-soak.mjs +++ b/scripts/live-required/tangle-sandbox-braid-soak.mjs @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { safeJson, safeMessage } from './contracts.mjs' import { runBraidSandboxStress } from './tangle-sandbox-braid-stress.mjs' import { resourceDelta } from './tangle-sandbox-braid-stress-support.mjs' @@ -923,7 +924,7 @@ export async function runBraidSandboxSoak({ async function writeOutput(path, value) { const resolved = resolve(path) await mkdir(dirname(resolved), { recursive: true, mode: 0o700 }) - await writeFile(resolved, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }) + await writeFile(resolved, `${safeJson(value)}\n`, { mode: 0o600 }) } async function main() { @@ -938,13 +939,13 @@ async function main() { }) const output = argument('output') if (output) await writeOutput(output, result) - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) + process.stdout.write(`${safeJson(result)}\n`) if (result.status !== 'passed') process.exitCode = 1 } if (resolve(process.argv[1] ?? '') === scriptPath) { main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.stderr.write(`${safeMessage(error)}\n`) process.exitCode = 1 }) } diff --git a/scripts/live-required/tangle-sandbox-braid-stress.mjs b/scripts/live-required/tangle-sandbox-braid-stress.mjs index 96209ec..2f0052a 100644 --- a/scripts/live-required/tangle-sandbox-braid-stress.mjs +++ b/scripts/live-required/tangle-sandbox-braid-stress.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url' import { Sandbox } from '@tangle-network/sandbox' import { sleep } from '../live-bridge/process.mjs' import { connectionConfiguration } from './configuration.mjs' +import { safeJson } from './contracts.mjs' import { closeSession, configEvidence, @@ -1801,22 +1802,20 @@ export async function runBraidSandboxStress({ async function writeOutput(path, value) { await mkdir(dirname(resolve(path)), { recursive: true }) - await writeFile(resolve(path), `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }) + await writeFile(resolve(path), `${safeJson(value)}\n`, { mode: 0o600 }) } async function main() { const proof = await runBraidSandboxStress() const outputPath = argument('output') if (outputPath) await writeOutput(outputPath, proof) - process.stdout.write(`${JSON.stringify(proof, null, 2)}\n`) + process.stdout.write(`${safeJson(proof)}\n`) if (proof.status !== 'passed') process.exitCode = 1 } if (resolve(process.argv[1] ?? '') === scriptPath) { main().catch((error) => { - process.stdout.write( - `${JSON.stringify({ status: 'failed', failure: errorDetails(error) }, null, 2)}\n`, - ) + process.stdout.write(`${safeJson({ status: 'failed', failure: errorDetails(error) })}\n`) process.exitCode = 1 }) } diff --git a/scripts/live-required/tangle-sandbox-interactive-continuity.mjs b/scripts/live-required/tangle-sandbox-interactive-continuity.mjs new file mode 100644 index 0000000..cfeee60 --- /dev/null +++ b/scripts/live-required/tangle-sandbox-interactive-continuity.mjs @@ -0,0 +1,995 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { connectionConfiguration } from './configuration.mjs' +import { + EXIT_CODES, + endpointEvidence, + LiveRequiredError, + safeJson, + safeMessage, +} from './contracts.mjs' + +const OPERATION = 'tangle.sandbox.interactive-continuity' +const SANDBOX_PROVIDER = 'tangle-sandbox' +const DEFAULT_TIMEOUT_MS = 180_000 +const DEFAULT_COLS = 120 +const DEFAULT_ROWS = 36 +const EXACT_HANDLE_METHODS = [ + 'start', + 'attach', + 'status', + 'claimControl', + 'sendPrompt', + 'stop', + 'stopLifecycle', +] +const EXACT_STREAM_METHODS = ['write', 'close', 'reconnect'] +const EXACT_INTERFACE_FUNCTIONS = [ + 'agentInteractiveSessionRunRef', + 'agentInteractiveSessionRefMatchesStart', + 'agentInteractiveSessionStatusMatchesRef', + 'agentInteractiveSessionControlClaimMatchesRef', + 'agentInteractiveSessionPromptRequestDigest', + 'agentInteractiveSessionStopRequestDigest', + 'canonicalAgentProfileDigest', + 'canonicalCandidateDigest', + 'exactAgentInteractiveSessionStart', +] + +function hasMethod(value, name) { + return typeof value?.[name] === 'function' +} + +function capabilityUnavailable(code, message, details = {}) { + const error = new LiveRequiredError(code, message, { unavailable: true }) + error.details = details + return error +} + +function identityText(ref) { + return [ + ref?.run?.runId, + ref?.run?.provider, + ref?.run?.environmentId, + ref?.run?.sessionId, + ref?.run?.executionId, + ref?.incarnationId, + ] + .map((value) => (typeof value === 'string' ? value : '')) + .join('/') +} + +function directCredentialEnvironment(environment) { + const hasSandboxCredential = [ + 'BRAID_TANGLE_SANDBOX_AUTH', + 'BRAID_TANGLE_SANDBOX_API_KEY', + 'BRAID_TANGLE_SANDBOX_BEARER', + ].some((name) => typeof environment[name] === 'string' && environment[name].trim()) + if (!hasSandboxCredential && environment.TANGLE_API_KEY) { + return { + ...environment, + BRAID_TANGLE_SANDBOX_API_KEY: environment.TANGLE_API_KEY, + } + } + return environment +} + +function safeIdentifier(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 120 || + !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value) + ) { + throw new Error(`${label} must be a bounded public identifier`) + } + return value +} + +function argument(name, argv = process.argv) { + const index = argv.indexOf(`--${name}`) + return index >= 0 && index + 1 < argv.length ? argv[index + 1] : undefined +} + +function positiveInteger(value, fallback) { + const parsed = Number(value) + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function sleep(milliseconds) { + return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)) +} + +async function bounded(label, task, timeoutMs) { + let timer + try { + return await Promise.race([ + Promise.resolve().then(task), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`${label} exceeded ${timeoutMs}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +async function waitFor(label, predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const result = await predicate() + if (result) return result + await sleep(100) + } + throw new Error(`${label} timed out after ${timeoutMs}ms`) +} + +function requireExactMethods(value, methods, label) { + return methods.filter((method) => !hasMethod(value, method)).map((method) => `${label}.${method}`) +} + +/** + * Check the package-level public contract before creating a cloud resource. + * The old Sandbox release is intentionally rejected because it cannot carry + * exact process receipts or control claims. + */ +export function assertPublicInteractiveSurface(sandboxModule, interfaceModule) { + const missing = [ + ...(typeof sandboxModule?.Sandbox !== 'function' ? ['Sandbox'] : []), + ...(typeof sandboxModule?.TerminalStream !== 'function' ? ['TerminalStream'] : []), + ...requireExactMethods( + sandboxModule?.Sandbox?.prototype, + ['create', 'get'], + 'Sandbox.prototype', + ), + ...requireExactMethods( + sandboxModule?.InteractiveSessionHandle?.prototype, + EXACT_HANDLE_METHODS, + 'InteractiveSessionHandle.prototype', + ), + ...requireExactMethods( + sandboxModule?.TerminalStream?.prototype, + EXACT_STREAM_METHODS, + 'TerminalStream.prototype', + ), + ...(typeof sandboxModule?.TerminalStream?.connect !== 'function' + ? ['TerminalStream.connect'] + : []), + ...EXACT_INTERFACE_FUNCTIONS.filter( + (name) => typeof interfaceModule?.[name] !== 'function', + ).map((name) => `agent-interface.${name}`), + ] + if (missing.length > 0) { + throw capabilityUnavailable( + 'SANDBOX_INTERACTIVE_EXACT_SURFACE_UNAVAILABLE', + `The public Sandbox interactive continuity contract is unavailable: ${missing.join(', ')}`, + { missing }, + ) + } + return Object.freeze({ + sandbox: sandboxModule, + agentInterface: interfaceModule, + }) +} + +export function assertSandboxSurface(box) { + const missing = [ + ...requireExactMethods( + box, + ['session', 'capabilities', 'waitFor', 'delete'], + 'SandboxInstance', + ), + ...(box?.terminals === undefined ? ['SandboxInstance.terminals'] : []), + ...requireExactMethods(box?.terminals, ['attach', 'get'], 'SandboxInstance.terminals'), + ] + if (missing.length > 0) { + throw capabilityUnavailable( + 'SANDBOX_INTERACTIVE_RUNTIME_SURFACE_UNAVAILABLE', + `The deployed Sandbox does not expose the native terminal surface: ${missing.join(', ')}`, + { missing }, + ) + } +} + +function configuration(environment) { + const values = connectionConfiguration(directCredentialEnvironment(environment), { + prefix: 'BRAID_TANGLE_SANDBOX', + kind: 'tangle-sandbox-interactive-continuity', + endpointNames: ['BRAID_TANGLE_ENDPOINT'], + modelNames: ['BRAID_TANGLE_MODEL'], + runnerNames: ['BRAID_TANGLE_RUNNER'], + providerNames: ['BRAID_TANGLE_SANDBOX_PROVIDER'], + }) + if (!values.credentialValue?.trim()) { + throw capabilityUnavailable( + 'PROTECTED_CREDENTIAL_VALUE_REQUIRED', + 'The direct Sandbox continuity proof requires a raw API key for the public SDK client; a protected reference alone is insufficient.', + { required: 'BRAID_TANGLE_SANDBOX_API_KEY or TANGLE_API_KEY' }, + ) + } + return values +} + +async function loadPublicApis(environment) { + try { + const [sandboxModule, interfaceModule] = await Promise.all([ + import('@tangle-network/sandbox'), + import('@tangle-network/agent-interface'), + ]) + return assertPublicInteractiveSurface(sandboxModule, interfaceModule) + } catch (error) { + if (error?.unavailable === true) throw error + throw capabilityUnavailable( + 'SANDBOX_PUBLIC_PACKAGE_UNAVAILABLE', + `The public Sandbox or Agent Interface package could not be loaded: ${safeMessage(error, environment)}`, + ) + } +} + +function markers(proofId) { + const safe = safeIdentifier(proofId, 'proofId').replaceAll(':', '_') + return { + output: `BRAID_NATIVE_OUTPUT_${safe}`, + input: `BRAID_NATIVE_INPUT_${safe}`, + reconnect: `BRAID_NATIVE_RECONNECT_${safe}`, + } +} + +/** Build one exact AgentProfile-backed native start request. */ +export function buildExactInteractiveStart({ + interfaceModule, + environmentId, + sessionId, + executionId, + proofId, + runner, + model, + modelProvider = 'tangle', + cols = DEFAULT_COLS, + rows = DEFAULT_ROWS, +}) { + const exactEnvironmentId = safeIdentifier(environmentId, 'environmentId') + const exactSessionId = safeIdentifier(sessionId, 'sessionId') + const exactExecutionId = safeIdentifier(executionId, 'executionId') + const exactProofId = safeIdentifier(proofId, 'proofId') + const profile = { + name: `braid-native-continuity-${exactProofId}`, + harness: runner, + model: { + default: model, + provider: modelProvider, + }, + } + const requestedProfileDigest = interfaceModule.canonicalAgentProfileDigest(profile) + const input = { + profile, + requestedProfileDigest, + initialPrompt: `Reply with exactly ${markers(exactProofId).output}, then wait for my next terminal input.`, + cwd: '.', + cols, + rows, + } + const run = interfaceModule.agentInteractiveSessionRunRef( + { + provider: SANDBOX_PROVIDER, + environmentId: exactEnvironmentId, + sessionId: exactSessionId, + executionId: exactExecutionId, + }, + input, + ) + const start = interfaceModule.exactAgentInteractiveSessionStart({ + run, + ...input, + }) + return Object.freeze({ + start, + run, + profile, + requestedProfileDigest, + markers: markers(exactProofId), + }) +} + +export function assertSameInteractiveRun(interfaceModule, expected, observed, label) { + const context = `${label} [${expected?.runId ?? ''}/${expected?.sessionId ?? ''}/${expected?.executionId ?? ''}]` + assert.ok(observed, `${context} returned no run reference`) + assert.equal( + interfaceModule.canonicalCandidateDigest(observed), + interfaceModule.canonicalCandidateDigest(expected), + `${context} changed its exact run reference`, + ) + assert.equal(observed.runId, expected.runId, `${context} changed runId`) + assert.equal(observed.provider, expected.provider, `${context} changed provider`) + assert.equal(observed.environmentId, expected.environmentId, `${context} changed environmentId`) + assert.equal(observed.sessionId, expected.sessionId, `${context} changed sessionId`) + assert.equal(observed.executionId, expected.executionId, `${context} changed executionId`) + assert.equal(observed.requestDigest, expected.requestDigest, `${context} changed requestDigest`) +} + +export function assertSameInteractiveRef(interfaceModule, expected, observed, label) { + const context = `${label} [${identityText(expected)}]` + assert.ok(observed, `${context} returned no process reference`) + assert.equal( + interfaceModule.canonicalCandidateDigest(observed), + interfaceModule.canonicalCandidateDigest(expected), + `${context} changed its exact process reference`, + ) + assert.equal(observed.run.runId, expected.run.runId, `${context} changed runId`) + assert.equal(observed.run.provider, expected.run.provider, `${context} changed provider`) + assert.equal( + observed.run.environmentId, + expected.run.environmentId, + `${context} changed environmentId`, + ) + assert.equal(observed.run.sessionId, expected.run.sessionId, `${context} changed sessionId`) + assert.equal(observed.run.executionId, expected.run.executionId, `${context} changed executionId`) + assert.equal( + observed.run.requestDigest, + expected.run.requestDigest, + `${context} changed requestDigest`, + ) + assert.equal(observed.incarnationId, expected.incarnationId, `${context} changed incarnationId`) +} + +export function assertStatusForRef(interfaceModule, ref, status, label) { + const context = `${label} [${identityText(ref)}]` + assert.ok(status, `${context} returned no status`) + assert.equal(status.state, 'running', `${context} is not running`) + assert.equal( + interfaceModule.agentInteractiveSessionStatusMatchesRef(ref, status), + true, + `${context} returned a status for a different process`, + ) + assertSameInteractiveRef(interfaceModule, ref, status.ref, label) + return status +} + +export function assertControlForRef(interfaceModule, ref, control, label) { + const context = `${label} [${identityText(ref)}]` + assert.ok(control, `${context} returned no control claim`) + assert.equal( + interfaceModule.agentInteractiveSessionControlClaimMatchesRef(ref, control), + true, + `${context} returned a control claim for a different process`, + ) + assert.ok(control.generation > 0, `${context} returned an invalid control generation`) + assert.ok( + Date.parse(control.expiresAt) > Date.now(), + `${context} returned an expired control claim`, + ) + return control +} + +function checkRecord(name, boxId, ref, detail = {}) { + return { + name, + status: 'passed', + environmentId: boxId, + runId: ref.run.runId, + provider: ref.run.provider, + sessionId: ref.run.sessionId, + executionId: ref.run.executionId, + incarnationId: ref.incarnationId, + ...detail, + } +} + +function terminalCollector(environment) { + let text = '' + let bytes = 0 + const errors = [] + const decoder = new TextDecoder() + const handlers = { + onData(data) { + bytes += data.byteLength + text += decoder.decode(data, { stream: true }) + }, + onError(error) { + errors.push({ + code: typeof error?.code === 'string' ? error.code : 'UNKNOWN', + message: safeMessage(error, environment), + }) + }, + } + return { + handlers, + get text() { + return text + }, + get bytes() { + return bytes + }, + get errors() { + return [...errors] + }, + } +} + +async function waitForMarker(collector, marker, label, timeoutMs, from = 0) { + return waitFor( + label, + () => { + if (collector.errors.length > 0) { + throw new Error(`${label} terminal stream error ${collector.errors[0].code}`) + } + return collector.text.slice(from).includes(marker) + }, + timeoutMs, + ) +} + +function assertReady(stream, sessionId, label) { + const ready = stream.ready + const context = `${label} [${sessionId}]` + assert.equal(ready.connectionId, sessionId, `${context} changed terminal connectionId`) + assert.equal(ready.sessionId, sessionId, `${context} changed terminal sessionId`) + assert.equal(typeof ready.restored, 'boolean', `${context} omitted restored state`) + assert.ok( + Number.isSafeInteger(ready.detachTimeoutMs) && ready.detachTimeoutMs > 0, + `${context} omitted the bounded detach timeout`, + ) + return ready +} + +function ownedBox(box, options, proofId) { + return ( + box?.id && + box.name === options.name && + box.metadata?.owner === 'braid' && + box.metadata?.lifecycle === 'interactive-continuity' && + box.metadata?.proofId === proofId + ) +} + +async function waitForDeleted(client, boxId, timeoutMs) { + return waitFor( + `Sandbox ${boxId} deletion`, + async () => { + const current = await client.get(boxId) + if (current !== null) { + assert.equal(current.id, boxId, `deletion lookup returned a different Sandbox for ${boxId}`) + return false + } + return true + }, + timeoutMs, + ) +} + +async function waitForTerminalReaped(box, sessionId, ref, timeoutMs) { + return waitFor( + `PTY cleanup [${identityText(ref)}]`, + async () => { + const terminal = await box.terminals.get(sessionId) + if (terminal === null) return { terminal: null } + assert.equal( + terminal.sessionId, + sessionId, + `PTY cleanup [${identityText(ref)}] returned a different session`, + ) + assert.equal( + terminal.isRunning, + false, + `PTY cleanup [${identityText(ref)}] still reports a running terminal`, + ) + return { terminal } + }, + timeoutMs, + ) +} + +function createOptions(values, profile, proofId) { + const name = `braid-native-continuity-${proofId}` + return { + environment: 'universal', + agent: true, + backend: { + type: values.runner, + profile, + }, + name, + metadata: { + owner: 'braid', + lifecycle: 'interactive-continuity', + proofId, + provider: SANDBOX_PROVIDER, + }, + idempotencyKey: name, + maxLifetimeSeconds: 900, + idleTimeoutSeconds: 600, + } +} + +/** + * Run the real native Sandbox continuity proof. + * + * This function never falls back to a shell, headless turn, direct WebSocket, + * or private Sandbox route when the exact public contract is unavailable. + */ +export async function runInteractiveContinuityProof({ + environment = process.env, + proofId = `run-${Date.now()}-${process.pid}-${randomUUID().slice(0, 8)}`, + timeoutMs = positiveInteger( + environment.BRAID_TANGLE_SANDBOX_INTERACTIVE_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, + ), + sandboxModule, + interfaceModule, + clientFactory, +} = {}) { + const startedAt = new Date().toISOString() + const exactProofId = safeIdentifier(proofId, 'proofId') + const values = configuration(environment) + const apis = + sandboxModule && interfaceModule + ? assertPublicInteractiveSurface(sandboxModule, interfaceModule) + : await loadPublicApis(environment) + const client = clientFactory + ? clientFactory({ values, sandbox: apis.sandbox }) + : new apis.sandbox.Sandbox({ + baseUrl: values.endpoint, + apiKey: values.credentialValue, + }) + if (!client || !hasMethod(client, 'create') || !hasMethod(client, 'get')) { + throw capabilityUnavailable( + 'SANDBOX_CLIENT_SURFACE_UNAVAILABLE', + 'The public Sandbox client does not expose create() and get() required for exact resource ownership.', + ) + } + + const sessionId = safeIdentifier(`session-${exactProofId}`, 'sessionId') + const executionId = safeIdentifier(`execution-${exactProofId}`, 'executionId') + let box + let boxWasOwned = false + let boxWasDeleted = false + let handle + let stream + let startInfo + let failure + const checks = [] + const cleanupErrors = [] + const createProfile = { + name: `braid-native-continuity-${exactProofId}`, + harness: values.runner, + model: { + default: values.model, + provider: values.provider, + }, + } + const options = createOptions(values, createProfile, exactProofId) + try { + box = await bounded('Sandbox create', () => client.create(options), timeoutMs) + assertSandboxSurface(box) + if (!ownedBox(box, options, exactProofId)) { + throw new Error( + `Created Sandbox ${box.id} did not retain the exact Braid ownership metadata for ${exactProofId}`, + ) + } + boxWasOwned = true + await bounded('Sandbox waitFor running', () => box.waitFor('running', { timeoutMs }), timeoutMs) + + const capabilities = await bounded( + 'Sandbox capability document', + () => box.capabilities(), + timeoutMs, + ) + if (capabilities?.schema !== 1) { + throw capabilityUnavailable( + 'SANDBOX_CAPABILITIES_UNKNOWN', + `Sandbox ${box.id} did not provide a recognized runtime capability document; native interactive support is not proven.`, + { environmentId: box.id }, + ) + } + + const session = box.session(sessionId) + if (!session || !hasMethod(session, 'interactive')) { + throw capabilityUnavailable( + 'SANDBOX_INTERACTIVE_SESSION_UNAVAILABLE', + `Sandbox ${box.id} does not expose the public interactive session controller for ${sessionId}.`, + { environmentId: box.id, sessionId }, + ) + } + handle = session.interactive() + if (!handle || !EXACT_HANDLE_METHODS.every((method) => hasMethod(handle, method))) { + throw capabilityUnavailable( + 'SANDBOX_INTERACTIVE_HANDLE_UNAVAILABLE', + `Sandbox ${box.id} returned an incomplete interactive controller for ${sessionId}.`, + { environmentId: box.id, sessionId }, + ) + } + + const exact = buildExactInteractiveStart({ + interfaceModule: apis.agentInterface, + environmentId: box.id, + sessionId, + executionId, + proofId: exactProofId, + runner: values.runner, + model: values.model, + modelProvider: values.provider, + }) + startInfo = await bounded( + `Interactive start [${identityText(exact.start)}]`, + () => handle.start(exact.start), + timeoutMs, + ) + assert.equal( + startInfo.state, + 'running', + `Interactive start [${identityText(exact.start)}] did not remain running`, + ) + assert.equal( + typeof startInfo.streamUrl, + 'string', + `Interactive start [${identityText(exact.start)}] returned no terminal stream URL`, + ) + assert.equal( + apis.agentInterface.agentInteractiveSessionRefMatchesStart(exact.start, startInfo.ref), + true, + `Interactive start [${identityText(exact.start)}] returned an inexact process ref`, + ) + assertSameInteractiveRun(apis.agentInterface, exact.start.run, startInfo.ref.run, 'start run') + assertControlForRef(apis.agentInterface, startInfo.ref, startInfo.control, 'start control') + const ref = startInfo.ref + checks.push( + checkRecord('start-identify-exact-process', box.id, ref, { + refDigest: apis.agentInterface.canonicalCandidateDigest(ref), + controlGeneration: startInfo.control.generation, + preparationReceiptDigest: ref.preparationReceipt.digest, + }), + ) + + const initialStatus = await bounded( + `Interactive status after start [${identityText(ref)}]`, + () => handle.status(), + timeoutMs, + ) + assertStatusForRef(apis.agentInterface, ref, initialStatus, 'status after start') + + const firstOutput = terminalCollector(environment) + stream = await bounded( + `Interactive attach [${identityText(ref)}]`, + () => + handle.attach({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS, handlers: firstOutput.handlers }), + timeoutMs, + ) + assert.ok( + stream && EXACT_STREAM_METHODS.every((method) => hasMethod(stream, method)), + `Interactive attach [${identityText(ref)}] returned an incomplete terminal stream`, + ) + const firstReady = assertReady(stream, sessionId, 'initial attach') + checks.push( + checkRecord('attach-exact-process', box.id, ref, { + connectionId: firstReady.connectionId, + restored: firstReady.restored, + controlGeneration: startInfo.control.generation, + }), + ) + + await waitForMarker( + firstOutput, + exact.markers.output, + `native output [${identityText(ref)}]`, + timeoutMs, + ) + checks.push( + checkRecord('receive-output', box.id, ref, { + marker: exact.markers.output, + bytes: firstOutput.bytes, + }), + ) + + const inputStart = firstOutput.text.length + stream.write(`${exact.markers.input}\r`) + await waitForMarker( + firstOutput, + exact.markers.input, + `native input echo [${identityText(ref)}]`, + timeoutMs, + inputStart, + ) + checks.push( + checkRecord('send-input', box.id, ref, { + marker: exact.markers.input, + inputBytes: Buffer.byteLength(`${exact.markers.input}\r`), + }), + ) + + await bounded(`Interactive detach [${identityText(ref)}]`, () => stream.close(), timeoutMs) + assert.equal( + stream.isOpen, + false, + `Interactive detach [${identityText(ref)}] left the terminal socket open`, + ) + const detachedStatus = await bounded( + `Interactive status after detach [${identityText(ref)}]`, + () => handle.status(), + timeoutMs, + ) + assertStatusForRef(apis.agentInterface, ref, detachedStatus, 'status after detach') + checks.push(checkRecord('detach-preserves-process', box.id, ref)) + + const reconnectedOutput = terminalCollector(environment) + const reconnected = await bounded( + `Interactive reconnect [${identityText(ref)}]`, + () => stream.reconnect({ handlers: reconnectedOutput.handlers }), + timeoutMs, + ) + stream = reconnected + const reconnectedReady = assertReady(stream, sessionId, 'reconnect') + assert.equal( + reconnectedReady.restored, + true, + `Interactive reconnect [${identityText(ref)}] created a new PTY instead of restoring the same one`, + ) + await waitForMarker( + reconnectedOutput, + exact.markers.output, + `reconnect replay [${identityText(ref)}]`, + timeoutMs, + ) + const reconnectInputStart = reconnectedOutput.text.length + stream.write(`${exact.markers.reconnect}\r`) + await waitForMarker( + reconnectedOutput, + exact.markers.reconnect, + `reconnect input [${identityText(ref)}]`, + timeoutMs, + reconnectInputStart, + ) + const reconnectedStatus = await bounded( + `Interactive status after reconnect [${identityText(ref)}]`, + () => handle.status(), + timeoutMs, + ) + assertStatusForRef(apis.agentInterface, ref, reconnectedStatus, 'status after reconnect') + checks.push( + checkRecord('reconnect-same-incarnation-control', box.id, ref, { + connectionId: reconnectedReady.connectionId, + restored: reconnectedReady.restored, + replayedMarker: exact.markers.output, + controlContinuity: 'TerminalStream.reconnect reused the attached exact control claim', + }), + ) + + const staleRef = { + ...ref, + incarnationId: `${ref.incarnationId}-stale`, + } + const staleHandle = session.interactive({ ref: staleRef, control: startInfo.control }) + const staleStatus = await bounded( + `Stale identity status [${identityText(staleRef)}]`, + () => staleHandle.status(), + timeoutMs, + ) + assert.equal( + staleStatus, + null, + `Stale identity [${identityText(staleRef)}] resolved to a live process`, + ) + await assert.rejects( + () => staleHandle.attach({ cols: DEFAULT_COLS, rows: DEFAULT_ROWS }), + (error) => { + const message = safeMessage(error, environment) + return /stale|identity|not running|exists/iu.test(message) + }, + ) + checks.push( + checkRecord('stale-identity-rejected', box.id, ref, { + rejectedIncarnationId: staleRef.incarnationId, + }), + ) + + await bounded( + `Interactive second detach [${identityText(ref)}]`, + () => stream.close(), + timeoutMs, + ) + const stopAcknowledgement = await bounded( + `Interactive lifecycle stop [${identityText(ref)}]`, + () => handle.stopLifecycle(), + timeoutMs, + ) + assert.ok( + stopAcknowledgement.status === 'accepted' || stopAcknowledgement.status === 'replayed', + `Interactive lifecycle stop [${identityText(ref)}] was not acknowledged`, + ) + assert.notEqual( + stopAcknowledgement.effect, + 'unknown', + `Interactive lifecycle stop [${identityText(ref)}] returned an unknown effect`, + ) + assertSameInteractiveRef( + apis.agentInterface, + ref, + stopAcknowledgement.ref, + 'lifecycle stop ref', + ) + assertControlForRef( + apis.agentInterface, + ref, + stopAcknowledgement.control, + 'lifecycle stop control', + ) + const exitedStatus = await waitFor( + `Interactive exit [${identityText(ref)}]`, + async () => { + const status = await handle.status() + if (!status) return false + assertSameInteractiveRef(apis.agentInterface, ref, status.ref, 'exit status ref') + return status.state === 'exited' ? status : false + }, + timeoutMs, + ) + assert.equal( + exitedStatus.reason, + 'stopped', + `Interactive exit [${identityText(ref)}] was not caused by the exact stop request`, + ) + checks.push( + checkRecord('stop-exact-process', box.id, ref, { + acknowledgementStatus: stopAcknowledgement.status, + effect: stopAcknowledgement.effect, + reason: exitedStatus.reason, + }), + ) + + const reaped = await waitForTerminalReaped(box, sessionId, ref, timeoutMs) + checks.push( + checkRecord('stop-reaps-pty', box.id, ref, { + terminalPresentAfterStop: reaped.terminal !== null, + }), + ) + + await bounded(`Sandbox delete ${box.id}`, () => box.delete(), timeoutMs) + boxWasDeleted = true + await waitForDeleted(client, box.id, timeoutMs) + checks.push(checkRecord('delete-exact-sandbox', box.id, ref)) + + return { + schema: 'braid.live-required.interactive-continuity.v1', + operation: OPERATION, + status: 'passed', + startedAt, + completedAt: new Date().toISOString(), + connection: { + endpoint: endpointEvidence(values.endpoint), + connectionKind: 'tangle-sandbox-public-sdk', + credentialConfigured: true, + model: values.model, + runner: values.runner, + provider: values.provider, + }, + run: { + environmentId: box.id, + runId: startInfo.ref.run.runId, + provider: startInfo.ref.run.provider, + sessionId, + executionId, + incarnationId: startInfo.ref.incarnationId, + refDigest: apis.agentInterface.canonicalCandidateDigest(startInfo.ref), + preparationReceiptDigest: startInfo.ref.preparationReceipt.digest, + }, + capabilities: { + schema: capabilities.schema, + agentInterface: capabilities.agentInterface, + sidecarVersion: capabilities.sidecarVersion, + image: capabilities.image, + }, + checks, + observations: { + outputMarker: exact.markers.output, + inputMarker: exact.markers.input, + reconnectMarker: exact.markers.reconnect, + ptyDeleted: reaped.terminal === null, + }, + } + } catch (error) { + failure = error + } finally { + if (stream?.isOpen) { + try { + await bounded('failure detach', () => stream.close(), timeoutMs) + } catch (error) { + cleanupErrors.push(safeMessage(error, environment)) + } + } + if (box && boxWasOwned && !boxWasDeleted) { + if (handle && startInfo) { + try { + await bounded( + `failure lifecycle stop [${identityText(startInfo.ref)}]`, + () => handle.stopLifecycle(), + timeoutMs, + ) + } catch (error) { + cleanupErrors.push(safeMessage(error, environment)) + } + } + try { + await bounded( + `failure PTY cleanup [${box.id}/${sessionId}]`, + () => + waitForTerminalReaped( + box, + sessionId, + startInfo?.ref ?? { run: { runId: '' }, incarnationId: '' }, + timeoutMs, + ), + timeoutMs, + ) + } catch (error) { + cleanupErrors.push(safeMessage(error, environment)) + } + try { + await bounded(`failure Sandbox delete ${box.id}`, () => box.delete(), timeoutMs) + boxWasDeleted = true + await waitForDeleted(client, box.id, timeoutMs) + } catch (error) { + cleanupErrors.push(safeMessage(error, environment)) + } + } + } + + if (failure) { + if (cleanupErrors.length > 0) { + failure.cleanupErrors = cleanupErrors + } + throw failure + } + throw new Error('Interactive continuity proof ended without a result') +} + +export function safeErrorRecord(error, environment = process.env) { + let details + if (error?.details && typeof error.details === 'object') { + try { + details = JSON.parse(safeJson(error.details, environment)) + } catch { + details = '[REDACTED]' + } + } + return { + name: error instanceof Error ? error.name : 'Error', + code: typeof error?.code === 'string' ? error.code : 'BRAID_INTERACTIVE_CONTINUITY_FAILED', + unavailable: error?.unavailable === true, + message: safeMessage(error, environment), + ...(details === undefined ? {} : { details }), + ...(Array.isArray(error?.cleanupErrors) ? { cleanupErrors: error.cleanupErrors } : {}), + } +} + +export async function main(argv = process.argv, environment = process.env) { + const proofId = argument('proof-id', argv) + try { + const result = await runInteractiveContinuityProof({ + environment, + ...(proofId === undefined ? {} : { proofId }), + }) + process.stdout.write(`${safeJson(result, environment)}\n`) + return EXIT_CODES.passed + } catch (error) { + const unavailable = error?.unavailable === true + const report = { + schema: 'braid.live-required.interactive-continuity.v1', + operation: OPERATION, + status: unavailable ? 'unavailable' : 'failed', + error: safeErrorRecord(error, environment), + } + process.stderr.write(`${safeJson(report, environment)}\n`) + return unavailable ? EXIT_CODES.unavailable : EXIT_CODES.failed + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : undefined +if (invokedPath === fileURLToPath(import.meta.url)) { + main().then((code) => { + process.exitCode = code + }) +} diff --git a/scripts/live-required/tangle-sandbox-interactive-continuity.test.mjs b/scripts/live-required/tangle-sandbox-interactive-continuity.test.mjs new file mode 100644 index 0000000..ec66493 --- /dev/null +++ b/scripts/live-required/tangle-sandbox-interactive-continuity.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + assertControlForRef, + assertPublicInteractiveSurface, + assertSameInteractiveRef, + assertStatusForRef, + buildExactInteractiveStart, + safeErrorRecord, +} from './tangle-sandbox-interactive-continuity.mjs' + +function exactControl(refDigest, generation = 1) { + return { + refDigest, + generation, + leaseId: `lease-${generation}`, + holderId: `holder-${generation}`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } +} + +function fakeSandboxModule({ exact = true } = {}) { + class Sandbox {} + class InteractiveSessionHandle {} + class TerminalStream {} + for (const name of ['create', 'get']) Sandbox.prototype[name] = () => {} + for (const name of exact + ? ['start', 'attach', 'status', 'claimControl', 'sendPrompt', 'stop', 'stopLifecycle'] + : ['start', 'attach', 'status', 'sendPrompt', 'stop']) { + InteractiveSessionHandle.prototype[name] = () => {} + } + for (const name of ['write', 'close', 'reconnect']) TerminalStream.prototype[name] = () => {} + TerminalStream.connect = () => {} + return { Sandbox, InteractiveSessionHandle, TerminalStream } +} + +function fakeInterfaceModule() { + return { + agentInteractiveSessionRunRef: () => {}, + agentInteractiveSessionRefMatchesStart: () => true, + agentInteractiveSessionStatusMatchesRef: () => true, + agentInteractiveSessionControlClaimMatchesRef: () => true, + agentInteractiveSessionPromptRequestDigest: () => `sha256:${'a'.repeat(64)}`, + agentInteractiveSessionStopRequestDigest: () => `sha256:${'b'.repeat(64)}`, + canonicalAgentProfileDigest: () => `sha256:${'c'.repeat(64)}`, + canonicalCandidateDigest: (value) => JSON.stringify(value), + exactAgentInteractiveSessionStart: (value) => value, + } +} + +class FakeTerminalWebSocket { + static OPEN = 1 + + static CLOSED = 3 + + static frames = [] + + static sockets = [] + + constructor(url, protocols) { + this.url = url + this.protocols = protocols + this.readyState = 0 + this.binaryType = 'blob' + this.listeners = new Map() + FakeTerminalWebSocket.sockets.push(this) + queueMicrotask(() => { + this.readyState = FakeTerminalWebSocket.OPEN + this.emit('open', {}) + }) + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? [] + listeners.push(listener) + this.listeners.set(type, listeners) + } + + emit(type, event) { + for (const listener of this.listeners.get(type) ?? []) listener(event) + } + + send(payload) { + const frame = JSON.parse(payload) + FakeTerminalWebSocket.frames.push({ socket: this, frame }) + if (frame.type === 'init') { + const initCount = FakeTerminalWebSocket.frames.filter( + ({ frame: candidate }) => candidate.type === 'init', + ).length + queueMicrotask(() => + this.emit('message', { + data: JSON.stringify({ + type: 'ready', + connectionId: frame.connectionId, + sessionId: frame.connectionId, + restored: initCount > 1, + detachTimeoutMs: 5_000, + }), + }), + ) + queueMicrotask(() => + this.emit('message', { + data: new TextEncoder().encode(`replay-${frame.connectionId}`).buffer, + }), + ) + } + if (frame.type === 'input') { + queueMicrotask(() => + this.emit('message', { + data: new TextEncoder().encode(`echo-${frame.data}`).buffer, + }), + ) + } + } + + close(code = 1000, reason = '') { + if (this.readyState === FakeTerminalWebSocket.CLOSED) return + this.readyState = FakeTerminalWebSocket.CLOSED + queueMicrotask(() => this.emit('close', { code, reason })) + } +} + +test('missing exact public methods are a hard unavailable result', () => { + assert.throws( + () => + assertPublicInteractiveSurface(fakeSandboxModule({ exact: false }), fakeInterfaceModule()), + (error) => + error.unavailable === true && + error.code === 'SANDBOX_INTERACTIVE_EXACT_SURFACE_UNAVAILABLE' && + error.details.missing.includes('InteractiveSessionHandle.prototype.claimControl'), + ) +}) + +test('the installed public package either satisfies the exact surface or reports its blocker', async () => { + const sandboxModule = await import('@tangle-network/sandbox') + const interfaceModule = await import('@tangle-network/agent-interface') + try { + const surface = assertPublicInteractiveSurface(sandboxModule, interfaceModule) + assert.equal(surface.sandbox, sandboxModule) + } catch (error) { + assert.equal(error.unavailable, true) + assert.equal(error.code, 'SANDBOX_INTERACTIVE_EXACT_SURFACE_UNAVAILABLE') + } +}) + +test('exact start material binds one run to one profile and session', () => { + const interfaceModule = { + ...fakeInterfaceModule(), + canonicalAgentProfileDigest: () => `sha256:${'1'.repeat(64)}`, + agentInteractiveSessionRunRef: (coordinates, _input) => ({ + ...coordinates, + runId: 'interactive-run-test', + requestDigest: `sha256:${'2'.repeat(64)}`, + }), + } + const exact = buildExactInteractiveStart({ + interfaceModule, + environmentId: 'sandbox-1', + sessionId: 'session-1', + executionId: 'execution-1', + proofId: 'proof-1', + runner: 'pi', + model: 'test-model', + }) + assert.equal(exact.start.run.environmentId, 'sandbox-1') + assert.equal(exact.start.run.sessionId, 'session-1') + assert.equal(exact.start.run.executionId, 'execution-1') + assert.equal(exact.start.profile.harness, 'pi') + assert.match(exact.start.initialPrompt, /BRAID_NATIVE_OUTPUT_proof-1/u) +}) + +test('same-ref assertions reject a changed incarnation and accept an exact replay', () => { + const interfaceModule = { + canonicalCandidateDigest: (value) => JSON.stringify(value), + } + const expected = { + run: { + runId: 'run-1', + provider: 'tangle-sandbox', + environmentId: 'sandbox-1', + sessionId: 'session-1', + executionId: 'execution-1', + requestDigest: `sha256:${'a'.repeat(64)}`, + }, + incarnationId: 'incarnation-1', + } + assert.doesNotThrow(() => + assertSameInteractiveRef(interfaceModule, expected, structuredClone(expected), 'reconnect'), + ) + assert.throws( + () => + assertSameInteractiveRef( + interfaceModule, + expected, + { ...structuredClone(expected), incarnationId: 'incarnation-stale' }, + 'stale identity', + ), + /changed its exact process reference/u, + ) +}) + +test('status and control assertions stay bound to the exact process', () => { + const ref = { + run: { + runId: 'run-2', + provider: 'tangle-sandbox', + environmentId: 'sandbox-2', + sessionId: 'session-2', + executionId: 'execution-2', + requestDigest: `sha256:${'b'.repeat(64)}`, + }, + incarnationId: 'incarnation-2', + } + const interfaceModule = { + canonicalCandidateDigest: (value) => JSON.stringify(value), + agentInteractiveSessionStatusMatchesRef: () => true, + agentInteractiveSessionControlClaimMatchesRef: () => true, + } + assertStatusForRef( + interfaceModule, + ref, + { state: 'running', ref: structuredClone(ref) }, + 'status', + ) + assertControlForRef(interfaceModule, ref, exactControl(`sha256:${'c'.repeat(64)}`), 'control') +}) + +test('error evidence does not expose a credential', () => { + const secret = 'sk-test-continuity-secret' + const record = safeErrorRecord(new Error(`upstream rejected ${secret}`), { + BRAID_TANGLE_SANDBOX_API_KEY: secret, + }) + assert.equal(record.message.includes(secret), false) +}) + +test('public TerminalStream proves input, detach, and same-connection replay with a fake server', async () => { + const { TerminalStream } = await import('@tangle-network/sandbox') + const previousWebSocket = globalThis.WebSocket + FakeTerminalWebSocket.frames = [] + FakeTerminalWebSocket.sockets = [] + globalThis.WebSocket = FakeTerminalWebSocket + const output = [] + const control = { + refDigest: `sha256:${'d'.repeat(64)}`, + generation: 1, + leaseId: 'lease-unit', + holderId: 'holder-unit', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + try { + const first = await TerminalStream.connect({ + url: 'https://sandbox.test/terminals/session-unit/ws', + token: 'unit-token', + connectionId: 'session-unit', + incarnationId: 'incarnation-unit', + control, + handlers: { + onData(data) { + output.push(new TextDecoder().decode(data)) + }, + }, + }) + assert.equal(first.ready.connectionId, 'session-unit') + assert.equal(first.ready.restored, false) + first.write('input-unit\r') + await new Promise((resolve) => setTimeout(resolve, 0)) + await first.close() + + const second = await first.reconnect({ + handlers: { + onData(data) { + output.push(new TextDecoder().decode(data)) + }, + }, + }) + assert.equal(second.ready.connectionId, 'session-unit') + assert.equal(second.ready.restored, true) + second.write('reconnect-unit\r') + await new Promise((resolve) => setTimeout(resolve, 0)) + await second.close() + + const initFrames = FakeTerminalWebSocket.frames.filter(({ frame }) => frame.type === 'init') + assert.equal(initFrames.length, 2) + assert.deepEqual( + initFrames.map(({ frame }) => frame.connectionId), + ['session-unit', 'session-unit'], + ) + assert.deepEqual( + FakeTerminalWebSocket.frames + .filter(({ frame }) => frame.type === 'input') + .map(({ frame }) => frame.data), + ['input-unit\r', 'reconnect-unit\r'], + ) + assert.equal( + output.some((chunk) => chunk.includes('echo-input-unit')), + true, + ) + assert.equal( + output.some((chunk) => chunk.includes('replay-session-unit')), + true, + ) + if (Object.hasOwn(initFrames[0].frame, 'incarnationId')) { + assert.equal(initFrames[0].frame.incarnationId, 'incarnation-unit') + assert.deepEqual(initFrames[0].frame.control, control) + assert.equal(initFrames[1].frame.incarnationId, 'incarnation-unit') + assert.deepEqual(initFrames[1].frame.control, control) + } + } finally { + globalThis.WebSocket = previousWebSocket + } +}) diff --git a/scripts/release/bindings.mjs b/scripts/release/bindings.mjs index 698a819..ac792da 100644 --- a/scripts/release/bindings.mjs +++ b/scripts/release/bindings.mjs @@ -69,6 +69,17 @@ export function normalizeRequirementCheckBindings(input, requirementIds, checkId return bindings } +export function selectRequirementCheckBindings(bindings, checkIds) { + assert(bindings instanceof Map, 'Requirement check bindings must be a map') + const selected = new Set(checkIds) + return new Map( + [...bindings.entries()].flatMap(([id, binding]) => { + const checks = binding.checks.filter((checkId) => selected.has(checkId)) + return checks.length === 0 ? [] : [[id, { checks }]] + }), + ) +} + export function materializeRequirementBindings( checkBindings, { artifactsByCheck = new Map(), additionalArtifacts = [] } = {}, diff --git a/scripts/release/collector-validation.mjs b/scripts/release/collector-validation.mjs index dad619f..4d31dae 100644 --- a/scripts/release/collector-validation.mjs +++ b/scripts/release/collector-validation.mjs @@ -341,7 +341,7 @@ export async function validateCheckpoint(checkpoint, { artifactRoot, identity, p assert(referencedEnvironmentIds.has(id), `Checkpoint has an unreferenced environment ${id}`) const mappings = normalizeRequirementBindings( checkpoint.envelope.requirements, - identity.requirementIds, + Object.keys(plan.requirements), plan.checkIds, ) for (const [id, binding] of mappings) { diff --git a/scripts/release/collector.mjs b/scripts/release/collector.mjs index 9fd4923..927eefe 100644 --- a/scripts/release/collector.mjs +++ b/scripts/release/collector.mjs @@ -10,6 +10,7 @@ import { materializeRequirementBindings, normalizeRequirementCheckBindings, requirementsObject, + selectRequirementCheckBindings, } from './bindings.mjs' import { readBuildIdentity, readRequirementIds } from './build-identity.mjs' import { registerCheckArtifacts } from './check-artifacts.mjs' @@ -191,11 +192,15 @@ export async function collectReleaseEvidence({ 'Build identity requirement list differs from docs', ) const selected = selectedChecks(checkIds, identity.requirementIds) - const checkBindings = normalizeRequirementCheckBindings( + const knownCheckIds = [ + ...new Set([...requiredEvidenceCheckIds(identity.requirementIds), ...selected]), + ] + const allCheckBindings = normalizeRequirementCheckBindings( requirementBindings, identity.requirementIds, - selected, + knownCheckIds, ) + const checkBindings = selectRequirementCheckBindings(allCheckBindings, selected) for (const id of selected) assert( requirementIdsForPlan({ requirements: Object.fromEntries(checkBindings) }, id).length > 0, @@ -431,7 +436,7 @@ export async function collectReleaseEvidence({ packageIntegrity: identity.packageIntegrity, packageFileManifestDigest: identity.packageFileManifestDigest, dependencyDigest: identity.dependencyDigest, - requirementIds: identity.requirementIds, + requirementIds: Object.keys(plan.requirements).sort(), checkIds: selected, checkCount: checks.size, result: passed ? 'passed' : complete ? 'failed' : 'incomplete', diff --git a/scripts/release/release-collector.test.mjs b/scripts/release/release-collector.test.mjs index c71672e..2229608 100644 --- a/scripts/release/release-collector.test.mjs +++ b/scripts/release/release-collector.test.mjs @@ -60,7 +60,9 @@ function git(root, ...args) { return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim() } -async function makeRepo() { +async function makeRepo({ + requirementDocument = '## Product acceptance\n\n| PR-01 | local proof |\n', +} = {}) { const root = await mkdtemp(join(tmpdir(), 'braid-release-repo-')) const artifactRoot = await mkdtemp(join(tmpdir(), 'braid-release-artifacts-')) await mkdir(join(root, 'docs'), { recursive: true }) @@ -74,10 +76,7 @@ async function makeRepo() { })}\n`, ) await writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n') - await writeFile( - join(root, 'docs', 'requirements.md'), - '## Product acceptance\n\n| PR-01 | local proof |\n', - ) + await writeFile(join(root, 'docs', 'requirements.md'), requirementDocument) git(root, 'init', '-q') git(root, 'config', 'core.hooksPath', '/dev/null') git(root, 'config', 'user.email', 'release-test@example.invalid') @@ -110,8 +109,8 @@ async function makeRepo() { return { root, artifactRoot, tarballPath, tarballBytes, proof } } -async function withRepo(action) { - const fixture = await makeRepo() +async function withRepo(action, options) { + const fixture = await makeRepo(options) try { return await action(fixture) } finally { @@ -811,6 +810,51 @@ test('one local catalog check produces redacted artifacts and an immutable colle }) }) +test('a selected check reports only requirements covered by that check', async () => { + await withRepo( + async ({ root, artifactRoot, tarballPath, proof }) => { + const result = await collectReleaseEvidence({ + repository: root, + artifactRoot, + tarballPath, + packageProof: proof, + requirementBindings: { + 'AN-01': { checks: ['unit'] }, + 'PR-01': { checks: ['live-bridge'] }, + 'VR-01': { checks: ['release'] }, + }, + checkIds: ['release'], + environment: { PATH: process.env.PATH ?? '', NODE_ENV: 'test' }, + runCheck: async ({ checkId }) => { + const processResult = await executeArgv({ + file: process.execPath, + args: ['-e', 'process.stdout.write("passed")'], + cwd: root, + environment: { PATH: process.env.PATH ?? '', NODE_ENV: 'test' }, + }) + return { + checkId, + category: 'release', + command: 'pnpm check:release', + argv: ['pnpm', 'check:release'], + ...processResult, + } + }, + }) + + assert.equal(result.result, 'passed') + assert.deepEqual(Object.keys(result.envelope.requirements), ['VR-01']) + assert.deepEqual(result.manifest.requirementIds, ['VR-01']) + }, + { + requirementDocument: + '## Analysis acceptance\n\n| AN-01 | analysis proof |\n\n' + + '## Product acceptance\n\n| PR-01 | product proof |\n\n' + + '## Release acceptance\n\n| VR-01 | release proof |\n', + }, + ) +}) + test('a restored failed check is retried with a new recorded attempt', async () => { await withRepo(async ({ root, artifactRoot, tarballPath, proof }) => { let calls = 0 diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 10d6123..ce0c8ff 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -47,6 +47,8 @@ const scopeFiles = { 'domain-reducer.test.js', 'domain-text.test.js', 'eval.test.js', + 'native-interactive-actions.test.js', + 'native-interactive-run-broker.test.js', 'observability.test.js', 'plain-accessibility.test.js', 'property.test.js', @@ -62,6 +64,7 @@ const scopeFiles = { 'analysis-model-call-observability.test.js', 'analysis-model-call-roundtrip.test.js', 'application.test.js', + 'cli-bridge-interactions.test.js', 'cli-bridge-profile-contract.test.js', 'cli-bridge-retained-restart.test.js', 'conversations.test.js', @@ -71,15 +74,19 @@ const scopeFiles = { 'observability.test.js', 'reducer.test.js', 'scripts.test.js', + 'tangle-retained-lifecycle.test.js', 'usage-projection.test.js', 'w6-contract.test.js', ], coordination: [ 'analysis-durable.test.js', + 'cli-bridge-interactions.test.js', 'cli-bridge-retained-restart.test.js', 'coordination.test.js', 'effect-admission.test.js', 'run-admission-architecture.test.js', + 'run-interactions.test.js', + 'tangle-retained-lifecycle.test.js', ], rpc: [ 'automation-interaction-commands.test.js', @@ -92,6 +99,7 @@ const scopeFiles = { 'configuration-product-flow.test.js', 'intelligence-dispatch.test.js', 'keyboard.test.js', + 'native-interactive-command.test.js', 'terminal-responsive.test.js', 'terminal-usage-status.test.js', 'tui-autocomplete.test.js', diff --git a/scripts/test-pty.mjs b/scripts/test-pty.mjs index 191b4f5..ce9edb4 100644 --- a/scripts/test-pty.mjs +++ b/scripts/test-pty.mjs @@ -220,8 +220,10 @@ async function runAutocompleteRace() { session.write('profile\r') await waitFor( () => - normalizeScreen(screen).includes('profiles') && - normalizeScreen(screen).includes('^V valid'), + normalizeScreen(screen).includes('profile') && + normalizeScreen(screen).includes('Braid starter') && + normalizeScreen(screen).includes('runner pi · model fixture/deterministic') && + normalizeScreen(screen).includes('←/esc close'), 'exact /profile overlay', ) if (/unknown command \/profil(?:new|enew)/iu.test(output)) @@ -229,7 +231,7 @@ async function runAutocompleteRace() { session.write('\u001b') await waitFor( - () => !normalizeScreen(screen).includes('^V valid'), + () => !normalizeScreen(screen).includes('runner pi · model fixture/deterministic'), 'autocomplete race profile close', () => screen, ) diff --git a/src/adapters/analysis/managed-analysis-runtime.ts b/src/adapters/analysis/managed-analysis-runtime.ts index 1a00772..1777a7c 100644 --- a/src/adapters/analysis/managed-analysis-runtime.ts +++ b/src/adapters/analysis/managed-analysis-runtime.ts @@ -1,6 +1,6 @@ import { createRequire } from 'node:module' -const AGENT_EVAL_RPC_VERSION = '0.144.11' +const AGENT_EVAL_RPC_VERSION = '0.145.15' const PYTHON_VERSION = '3.12' const RESOLUTION_CUTOFF = '2026-08-11T05:00:00Z' const RUNTIME_PROBE = [ diff --git a/src/adapters/analysis/production-analysis-analyst.ts b/src/adapters/analysis/production-analysis-analyst.ts index 2192928..84364e4 100644 --- a/src/adapters/analysis/production-analysis-analyst.ts +++ b/src/adapters/analysis/production-analysis-analyst.ts @@ -44,9 +44,15 @@ export class ProductionAnalysisAnalyst implements AnalysisAnalyst { await createTraceAnalysisAdapter({ profile: target.profile, connection: target.connection, - ...(modelSettings.maxOutputTokens === undefined + ...(modelSettings.maxVisibleOutputTokens === undefined ? {} - : { maxOutputTokens: modelSettings.maxOutputTokens }), + : { maxOutputTokens: modelSettings.maxVisibleOutputTokens }), + ...(modelSettings.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: modelSettings.maxReasoningTokens }), + ...(modelSettings.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: modelSettings.maxTotalOutputTokens }), ...connectionOptions, managedRuntimeReadiness: 'complete', ...(request.onRetainedAdmission === undefined diff --git a/src/adapters/analysis/runtime-model-owner.ts b/src/adapters/analysis/runtime-model-owner.ts index 2ea9b65..52f59cd 100644 --- a/src/adapters/analysis/runtime-model-owner.ts +++ b/src/adapters/analysis/runtime-model-owner.ts @@ -18,9 +18,9 @@ import type { RuntimeStreamEvent } from '@tangle-network/agent-runtime' import type { RouterTransportConfig } from '@tangle-network/agent-runtime/kernel' import { canonicalDigest } from '../../domain/canonical.js' import type { ConnectionRecord } from '../../domain/entities.js' -import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' import { safePublicIdentifier } from '../../domain/provider-values.js' import { redactProviderError } from '../../domain/redaction.js' +import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' import { canonicalAgentProfileDigestHex, snapshotAgentProfile, @@ -76,6 +76,9 @@ export interface RuntimeTraceModelOwnerOptions { readonly credential?: string readonly model: string readonly pricing?: CustomTokenPricing + /** Hidden reasoning budget for one analyst call. */ + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly complete?: RouterTransportConfig['complete'] readonly retry?: RuntimeRouterRetryPolicy readonly recordExecution?: ModelExecutionRecorder @@ -590,6 +593,21 @@ function analystCallProfile( (sourceReasoning === undefined || sourceReasoning === 'none') ? 'minimal' : sourceReasoning + const maxVisibleOutputTokens = request.maxTokens ?? source.model?.maxVisibleOutputTokens + const maxReasoningTokens = options.maxReasoningTokens ?? source.model?.maxReasoningTokens + const maxTotalOutputTokens = options.maxTotalOutputTokens ?? source.model?.maxTotalOutputTokens + if ( + maxTotalOutputTokens !== undefined && + ((maxVisibleOutputTokens !== undefined && maxVisibleOutputTokens > maxTotalOutputTokens) || + (maxReasoningTokens !== undefined && maxReasoningTokens > maxTotalOutputTokens) || + (maxVisibleOutputTokens !== undefined && + maxReasoningTokens !== undefined && + maxVisibleOutputTokens + maxReasoningTokens > maxTotalOutputTokens)) + ) { + throw new RangeError( + 'Trace analysis execution limits exceed AgentProfile.model.maxTotalOutputTokens', + ) + } return snapshotAgentProfile({ name: `${source.name ?? 'Braid'} trace analyst`, description: 'One bounded trace-analysis model call', @@ -606,9 +624,13 @@ function analystCallProfile( default: model, ...(provider === undefined ? {} : { provider }), ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + ...(maxVisibleOutputTokens === undefined ? {} : { maxVisibleOutputTokens }), + ...(maxReasoningTokens === undefined || maxReasoningTokens <= 0 + ? {} + : { maxReasoningTokens }), + ...(maxTotalOutputTokens === undefined ? {} : { maxTotalOutputTokens }), metadata: { ...(bridge ? {} : { retry }), - ...(request.maxTokens === undefined ? {} : { maxTokens: request.maxTokens }), ...(request.temperature === undefined ? {} : { temperature: request.temperature }), ...(format === undefined ? {} : { extraBody: { response_format: format } }), }, @@ -718,7 +740,7 @@ export function createRuntimeTraceModelOwner( }) for await (const event of streamAgentTurn( backend, - { messages }, + { prompt: JSON.stringify({ messages }) }, { signal: request.signal, callId: request.callId, diff --git a/src/adapters/analysis/trace-analysis-adapter.ts b/src/adapters/analysis/trace-analysis-adapter.ts index 4aef07a..47b9ee4 100644 --- a/src/adapters/analysis/trace-analysis-adapter.ts +++ b/src/adapters/analysis/trace-analysis-adapter.ts @@ -75,6 +75,7 @@ export interface TraceAnalysisAdapterOptions extends ProductionConnectionOptions readonly maxCostUsd?: number readonly maxOutputTokens?: number readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly timeoutMs?: number readonly recordExecution?: (observation: ExternalOptimizerModelExecutionObservation) => void readonly onRetainedAdmission?: ( @@ -234,6 +235,39 @@ function defaultModelCostCeiling( ) } +interface AnalysisTokenLimits { + readonly maxOutputTokens: number + readonly maxReasoningTokens: number + readonly maxTotalOutputTokens?: number +} + +function analysisTokenLimits(options: TraceAnalysisAdapterOptions): AnalysisTokenLimits { + const model = options.profile.model + const total = options.maxTotalOutputTokens ?? model?.maxTotalOutputTokens + const visible = + options.maxOutputTokens ?? + model?.maxVisibleOutputTokens ?? + (total === undefined ? DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS : total) + const reasoning = + options.maxReasoningTokens ?? + model?.maxReasoningTokens ?? + (total === undefined ? visible * DEFAULT_ANALYSIS_REASONING_MULTIPLIER : total - visible) + if ( + visible <= 0 || + reasoning < 0 || + (total !== undefined && (visible > total || visible + reasoning > total)) + ) { + throw new RangeError( + 'Trace analysis visible and reasoning token limits exceed maxTotalOutputTokens', + ) + } + return { + maxOutputTokens: visible, + maxReasoningTokens: reasoning, + ...(total === undefined ? {} : { maxTotalOutputTokens: total }), + } +} + /** * Configure the model-backed trace analysts for one exact profile/connection pair. * @@ -343,6 +377,7 @@ export async function createTraceAnalysisAdapter( ? normalizeCliBridgeProviderBaseUrl(endpoint, connection.id) : normalizeTangleInferenceRuntimeBaseUrl(endpoint, connection.id) try { + const limits = analysisTokenLimits(options) const modelExecutionScope = new ModelExecutionScope() const owner = createRuntimeTraceModelOwner({ profile, @@ -351,6 +386,10 @@ export async function createTraceAnalysisAdapter( ...(credential === undefined ? {} : { credential }), model, ...(options.pricing === undefined ? {} : { pricing: { ...options.pricing } }), + ...(limits.maxReasoningTokens > 0 ? { maxReasoningTokens: limits.maxReasoningTokens } : {}), + ...(limits.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: limits.maxTotalOutputTokens }), ...(connection.kind !== 'tangle-inference' || options.routerComplete === undefined ? {} : { complete: options.routerComplete }), @@ -362,9 +401,7 @@ export async function createTraceAnalysisAdapter( ? {} : { onRetainedAdmission: options.onRetainedAdmission }), }) - const maxOutputTokens = options.maxOutputTokens ?? DEFAULT_ANALYSIS_MAX_OUTPUT_TOKENS - const maxReasoningTokens = - options.maxReasoningTokens ?? maxOutputTokens * DEFAULT_ANALYSIS_REASONING_MULTIPLIER + const { maxOutputTokens, maxReasoningTokens } = limits const maxCostUsd = options.maxCostUsd ?? (owner.pricing === undefined diff --git a/src/adapters/analysis/trace-event-projection.ts b/src/adapters/analysis/trace-event-projection.ts index 6ab398c..ebfbccb 100644 --- a/src/adapters/analysis/trace-event-projection.ts +++ b/src/adapters/analysis/trace-event-projection.ts @@ -112,8 +112,12 @@ function addRequestedRun(attributes: Record, event: BraidEvent) attributes['braid.provider_session_id'] = safeAnalysisText(receipt.providerSessionId) if (settings.reasoningEffort !== undefined) attributes['braid.reasoning_effort'] = safeAnalysisText(settings.reasoningEffort) - if (settings.maxOutputTokens !== undefined) - attributes['braid.max_output_tokens'] = settings.maxOutputTokens + if (settings.maxVisibleOutputTokens !== undefined) + attributes['braid.max_visible_output_tokens'] = settings.maxVisibleOutputTokens + if (settings.maxReasoningTokens !== undefined) + attributes['braid.max_reasoning_tokens'] = settings.maxReasoningTokens + if (settings.maxTotalOutputTokens !== undefined) + attributes['braid.max_total_output_tokens'] = settings.maxTotalOutputTokens attributes['braid.admission_status'] = receipt.admissionStatus ?? 'admitted' attributes['braid.capabilities'] = safeAnalysisValue(receipt.capabilities) } diff --git a/src/adapters/connections/cli-bridge-model-validation.ts b/src/adapters/connections/cli-bridge-model-validation.ts index d119b50..acb66dc 100644 --- a/src/adapters/connections/cli-bridge-model-validation.ts +++ b/src/adapters/connections/cli-bridge-model-validation.ts @@ -37,7 +37,8 @@ export function cliBridgeModelValidationRequest(profile: Readonly) ...(profile.model?.reasoningEffort === undefined ? {} : { reasoningEffort: profile.model.reasoningEffort }), - metadata: { maxTokens: CLI_BRIDGE_MODEL_VALIDATION_MAX_TOKENS }, + maxVisibleOutputTokens: CLI_BRIDGE_MODEL_VALIDATION_MAX_TOKENS, + maxTotalOutputTokens: CLI_BRIDGE_MODEL_VALIDATION_MAX_TOKENS, }, }, } diff --git a/src/adapters/connections/production-connection-types.ts b/src/adapters/connections/production-connection-types.ts index fd3be4e..4f15cdb 100644 --- a/src/adapters/connections/production-connection-types.ts +++ b/src/adapters/connections/production-connection-types.ts @@ -1,7 +1,10 @@ import type { AgentExactRunControlRef, AgentProfile } from '@tangle-network/agent-interface' import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface/environment-provider' import type { SandboxClientLike } from '@tangle-network/agent-provider-tangle' -import type { RouterTransportConfig } from '@tangle-network/agent-runtime/kernel' +import type { + BridgeModelCredential, + RouterTransportConfig, +} from '@tangle-network/agent-runtime/kernel' import type { ConnectionHealth, ConnectionKind, @@ -91,6 +94,8 @@ export interface ProductionConnectionOptions { readonly credentials?: CredentialPort /** Map Braid's durable credential id to the credential-port's opaque ref. */ readonly credentialRefResolver?: (ref: CredentialRefId) => CredentialRef | Promise + /** Request-scoped model credential for a loopback CLI Bridge. */ + readonly bridgeModelCredential?: BridgeModelCredential readonly fetch?: typeof fetch /** Injectable Runtime Router transport for tests and embedded deployments. */ readonly routerComplete?: RouterTransportConfig['complete'] diff --git a/src/adapters/connections/tangle-retained-control-lookup.ts b/src/adapters/connections/tangle-retained-control-lookup.ts index 04fdfe7..d492a65 100644 --- a/src/adapters/connections/tangle-retained-control-lookup.ts +++ b/src/adapters/connections/tangle-retained-control-lookup.ts @@ -1,5 +1,6 @@ import { AgentExactRunControlRefSchema } from '@tangle-network/agent-interface' import type { SandboxClientLike, SandboxInstanceLike } from '@tangle-network/agent-provider-tangle' +import { stableProviderId } from '../runtime/production-backend-common.js' import type { TangleRetainedControlLookup, TangleRetainedControlLookupInput, @@ -18,17 +19,13 @@ type RetainedList = (options?: { readonly signal?: AbortSignal }) => Promise -function safeIdentity(value: string): string { - return value.replace(/[^A-Za-z0-9._:-]/gu, '-').slice(0, 128) || 'run' -} - function matchesRetainedEnvironment( box: SandboxInstanceLike, input: TangleRetainedControlLookupInput, ): boolean { const metadata = box.metadata return ( - box.name === `braid-${safeIdentity(input.providerSessionId)}` && + box.name === stableProviderId('braid-', input.providerSessionId) && metadata?.owner === 'braid' && metadata.lifecycle === 'retained' && metadata.providerSessionId === input.providerSessionId && diff --git a/src/adapters/runtime/agent-runtime-execution.ts b/src/adapters/runtime/agent-runtime-execution.ts index 70eb10c..a670b57 100644 --- a/src/adapters/runtime/agent-runtime-execution.ts +++ b/src/adapters/runtime/agent-runtime-execution.ts @@ -203,10 +203,14 @@ export class AgentRuntimeExecutionPort implements ExecutionPort { const { streamAgentTurn } = await import('@tangle-network/agent-runtime/kernel') try { let terminal: Extract | undefined - for await (const event of streamAgentTurn(runtimeBackend, input.text, { - signal: localAbort.signal, - preserveToolParts: true, - })) { + for await (const event of streamAgentTurn( + runtimeBackend, + { prompt: input.text }, + { + signal: localAbort.signal, + preserveToolParts: true, + }, + )) { if (event.type === 'final') terminal = event else yield event } @@ -360,6 +364,7 @@ function admissionKey(input: ExecuteTurnInput, profileDigest: string): string { text: input.text, profileDigest, connectionId: input.connectionId ?? null, + mode: input.mode ?? null, sessionId: input.sessionId ?? null, contextBoundary: input.contextBoundary ?? null, }) diff --git a/src/adapters/runtime/cli-bridge-retained-execution.ts b/src/adapters/runtime/cli-bridge-retained-execution.ts index b0aef66..0530862 100644 --- a/src/adapters/runtime/cli-bridge-retained-execution.ts +++ b/src/adapters/runtime/cli-bridge-retained-execution.ts @@ -1,16 +1,19 @@ import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' -import type { ExecuteTurnInput } from '../../ports/execution.js' +import type { ExecuteTurnInput, RetainedExecutionRecoveryContext } from '../../ports/execution.js' import { createCliBridgeRetainedPlan } from './cli-bridge-retained-run.js' import type { PreparedCliBridgeConnection } from './production-cli-bridge-backend.js' import { RetainedExecutionPort } from './retained-execution.js' export interface CliBridgeRetainedExecutionOptions { readonly resolve: (input: ExecuteTurnInput) => Promise - readonly recover: (input: { - readonly runId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - }) => Promise + readonly recover: ( + input: { + readonly runId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ) => Promise } /** Braid's durable CLI-Bridge path. CLI-Bridge owns the job; the shared port owns readers. */ @@ -19,15 +22,18 @@ export class CliBridgeRetainedExecutionPort extends RetainedExecutionPort { super({ resolve: async (input) => createCliBridgeRetainedPlan(await options.resolve(input), input.runId), - recover: async ({ runId, providerSessionId, controlRef }) => + recover: async ({ runId, providerSessionId, controlRef, signal, ...recovery }) => createCliBridgeRetainedPlan( await options.recover({ runId, ...(providerSessionId === undefined ? {} : { providerSessionId }), ...(controlRef === undefined ? {} : { controlRef }), + ...(signal === undefined ? {} : { signal }), + ...recovery, }), runId, controlRef, + recovery, ), }) } diff --git a/src/adapters/runtime/cli-bridge-retained-run.ts b/src/adapters/runtime/cli-bridge-retained-run.ts index aebb93e..c8ad7cb 100644 --- a/src/adapters/runtime/cli-bridge-retained-run.ts +++ b/src/adapters/runtime/cli-bridge-retained-run.ts @@ -1,17 +1,21 @@ -import { - type AgentExactRunControlRef, - AgentExactRunControlRefSchema, -} from '@tangle-network/agent-interface' -import type { AgentEnvironmentProvider } from '@tangle-network/agent-interface/environment-provider' +import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' +import type { CliBridgeProvider } from '@tangle-network/agent-provider-cli-bridge' import { type RetainedRunHandle, reconnectRetainedRun, + recoverRetainedRun, startRetainedRun, } from '@tangle-network/agent-runtime/kernel' +import { canonicalDigest } from '../../domain/canonical.js' import { publicMaterializationReceipt } from '../../domain/materialization-receipt.js' import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' -import type { ExecuteTurnInput } from '../../ports/execution.js' -import { safeExecutionId } from './production-backend-common.js' +import type { + ExecuteTurnInput, + RetainedExecutionRecoveryContext, + RetainedRunAdmissionRecord, + RetainedRunAdmissionRecorder, +} from '../../ports/execution.js' +import { safeExecutionId, stableProviderId } from './production-backend-common.js' import type { PreparedCliBridgeConnection } from './production-cli-bridge-backend.js' import type { RetainedExecutionPlan, @@ -26,50 +30,76 @@ import { retainedTurnUsage, } from './retained-execution-projection.js' -const MAX_STATUS_BYTES = 64 * 1024 - export interface CliBridgeRetainedPlan extends RetainedExecutionPlan { readonly prepared: PreparedCliBridgeConnection - readonly provider: AgentEnvironmentProvider - readonly environmentId: string + readonly provider: CliBridgeProvider + readonly environmentId?: string + readonly environmentIdempotencyKey: string readonly executionId: string } +type HeadlessRetainedAdmission = Extract< + RetainedRunAdmissionRecord, + { readonly phase: 'intent' | 'environment' | 'dispatched' } +> + export async function createCliBridgeRetainedPlan( prepared: PreparedCliBridgeConnection, runId: string, controlRef?: AgentExactRunControlRef, + recovery?: RetainedExecutionRecoveryContext, ): Promise { - const { createCliBridgeProvider } = await import('@tangle-network/agent-provider-cli-bridge') - const provider = createCliBridgeProvider({ - baseUrl: prepared.bridgeUrl, - bearerToken: prepared.bearerToken, - defaultModel: prepared.route, - ...(prepared.fetch === undefined ? {} : { fetch: prepared.fetch }), - }) - const environmentId = controlRef?.environmentId ?? retainedEnvironmentId(runId) - const executionId = controlRef?.executionId ?? safeExecutionId(runId) + const provider = prepared.provider + const admission = headlessRetainedAdmission(recovery?.retainedAdmission) + if (admission !== undefined && admissionProvider(admission) !== provider.name) { + throw new Error('retained CLI Bridge admission belongs to another provider') + } + const persistedControlRef = admission?.phase === 'dispatched' ? admission.controlRef : undefined + if ( + controlRef !== undefined && + persistedControlRef !== undefined && + canonicalDigest(controlRef) !== canonicalDigest(persistedControlRef) + ) { + throw new Error('retained CLI Bridge control reference conflicts with the persisted run') + } + const exactControlRef = controlRef ?? persistedControlRef + const environmentId = + exactControlRef?.environmentId ?? + (admission?.phase === 'environment' ? admission.environmentId : undefined) + const executionId = + exactControlRef?.executionId ?? + (admission?.phase === 'intent' || admission?.phase === 'environment' + ? admission.executionId + : safeExecutionId(runId)) + const environmentIdempotencyKey = + admission?.idempotencyKey ?? retainedEnvironmentIdempotencyKey(runId) const providerName = provider.name - const capabilities = retainedCapabilities(prepared.capabilities) + const capabilities = retainedCapabilities(await provider.capabilities()) const materializationReceipt = publicMaterializationReceipt({ ...prepared.materializationReceipt, backend: 'environment-provider', - environmentId, - providerRunId: controlRef?.runId ?? executionId, + ...(environmentId === undefined ? {} : { environmentId }), + providerRunId: exactControlRef?.runId ?? executionId, retainedControl: 'exact-after-dispatch', }) const plan: CliBridgeRetainedPlan = { prepared, provider, - environmentId, + ...(environmentId === undefined ? {} : { environmentId }), + environmentIdempotencyKey, executionId, providerName, - providerSessionId: controlRef?.sessionId ?? prepared.providerSessionId, + providerSessionId: + exactControlRef?.sessionId ?? + (admission?.phase === 'intent' || admission?.phase === 'environment' + ? admission.sessionId + : prepared.providerSessionId), model: prepared.route, capabilities, materializationReceipt, start: (input) => startCliBridgeRetainedRun(plan, input), reconnect: (controlRef) => reconnectCliBridgeRetainedRun(plan, controlRef), + recover: (input) => recoverCliBridgeRetainedRun(plan, input), discover: (braidRunId, signal) => discoverCliBridgeControlRef(plan, braidRunId, signal), observe: () => prepared.observation.snapshot(), projectStatus: ({ status, detached }) => retainedStatus(status, detached), @@ -104,11 +134,12 @@ export async function startCliBridgeRetainedRun( profile: plan.prepared.profile, backend: plan.prepared.runner, workspace: { cwd: plan.prepared.workspace }, - idempotencyKey: plan.environmentId, + idempotencyKey: plan.environmentIdempotencyKey, }, turn: { prompt: input.text, turnId: safeExecutionId(input.operationId), + interactions: input.interactions ?? {}, signal: input.signal, }, identity: { @@ -126,73 +157,86 @@ export async function reconnectCliBridgeRetainedRun( return reconnectRetainedRun({ provider: plan.provider, controlRef }) } +async function recoverCliBridgeRetainedRun( + plan: CliBridgeRetainedPlan, + input: RetainedExecutionRecoveryContext & { + readonly admission: RetainedRunAdmissionRecord + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + readonly signal?: AbortSignal + }, +): Promise { + const admission = headlessRetainedAdmission(input.admission) + if (admission === undefined) return null + if (admission.phase === 'intent') { + if (input.receipt === undefined || input.onRetainedAdmission === undefined) return null + const result = await recoverRetainedRun({ + provider: plan.provider, + admission, + replay: { + environment: { + profile: plan.prepared.profile, + backend: plan.prepared.runner, + workspace: { cwd: plan.prepared.workspace }, + idempotencyKey: admission.idempotencyKey, + }, + turn: { + prompt: input.receipt.requested.text, + turnId: admission.turnId, + interactions: input.receipt.requested.interactions ?? {}, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }, + identity: { + sessionId: admission.sessionId, + executionId: admission.executionId, + }, + }, + onAdmission: input.onRetainedAdmission, + }) + return result.outcome === 'recovered' ? result.handle : null + } + if (admission.phase !== 'environment') return null + const result = await recoverRetainedRun({ + provider: plan.provider, + environmentId: admission.environmentId, + sessionId: admission.sessionId, + executionId: admission.executionId, + }) + return result.outcome === 'recovered' ? result.handle : null +} + /** Recover the exact server-issued digest after a client crashed during dispatch. */ export async function discoverCliBridgeControlRef( plan: CliBridgeRetainedPlan, _braidRunId: string, signal?: AbortSignal, ): Promise { - const providerRunId = plan.executionId - const response = await (plan.prepared.fetch ?? globalThis.fetch)( - `${plan.prepared.bridgeUrl}/v1/runs/${encodeURIComponent(providerRunId)}`, - { - method: 'GET', - headers: { authorization: `Bearer ${plan.prepared.bearerToken}` }, - ...(signal === undefined ? {} : { signal }), - }, - ) - if (response.status === 404) return null - if (!response.ok) { - throw new Error(`cli-bridge run discovery returned HTTP ${response.status}`) - } - const body = await boundedResponseText(response) - let value: unknown - try { - value = JSON.parse(body) - } catch { - throw new Error('cli-bridge run discovery returned invalid JSON') - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error('cli-bridge run discovery returned an invalid run snapshot') - } - const snapshot = value as Record - if (snapshot.id !== providerRunId) { - throw new Error('cli-bridge run discovery returned another run identity') - } - return AgentExactRunControlRefSchema.parse({ - runId: snapshot.id, - provider: plan.provider.name, + if (plan.environmentId === undefined || plan.providerSessionId === undefined) return null + return plan.provider.lookupRun({ + runId: plan.executionId, environmentId: plan.environmentId, sessionId: plan.providerSessionId, executionId: plan.executionId, - requestDigest: snapshot.requestDigest, + ...(signal === undefined ? {} : { signal }), }) } -export function retainedEnvironmentId(runId: string): string { - return `environment-braid-${safeExecutionId(runId)}` +function retainedEnvironmentIdempotencyKey(runId: string): string { + return stableProviderId('environment-braid-', runId) } -async function boundedResponseText(response: Response): Promise { - if (response.body === null) return '' - const reader = response.body.getReader() - const decoder = new TextDecoder() - const chunks: string[] = [] - let byteLength = 0 - try { - while (true) { - const next = await reader.read() - if (next.done) break - byteLength += next.value.byteLength - if (byteLength > MAX_STATUS_BYTES) { - await reader.cancel('CLI Bridge run discovery response exceeded 64 KiB') - throw new Error('cli-bridge run discovery response exceeded 64 KiB') - } - chunks.push(decoder.decode(next.value, { stream: true })) - } - chunks.push(decoder.decode()) - return chunks.join('') - } finally { - reader.releaseLock() +function headlessRetainedAdmission( + admission: RetainedRunAdmissionRecord | undefined, +): HeadlessRetainedAdmission | undefined { + if ( + admission?.phase !== 'intent' && + admission?.phase !== 'environment' && + admission?.phase !== 'dispatched' + ) { + return undefined } + return admission +} + +function admissionProvider(admission: HeadlessRetainedAdmission): string { + return admission.phase === 'dispatched' ? admission.controlRef.provider : admission.provider } diff --git a/src/adapters/runtime/mode-routing-execution.ts b/src/adapters/runtime/mode-routing-execution.ts new file mode 100644 index 0000000..0ea4840 --- /dev/null +++ b/src/adapters/runtime/mode-routing-execution.ts @@ -0,0 +1,208 @@ +import type { InteractionResponseCommand } from '@tangle-network/agent-interface' +import type { + ExecuteTurnInput, + ExecutionPort, + NormalizedExecutionEvent, + RetainedExecutionRecoveryContext, + RunCapabilities, +} from '../../ports/execution.js' +import { UNKNOWN_RUN_CAPABILITIES } from '../../ports/execution.js' + +type StatusInput = Parameters>[0] +type ReconnectInput = Parameters>[0] +type ReconnectEvent = + Awaited>> extends AsyncIterable + ? Event + : never +type CancelInput = Parameters>[0] +type DetachInput = Parameters>[0] +type SteerInput = Parameters>[0] +type NativeBoundaryInput = Parameters>[0] + +export interface ModeRoutingExecutionOptions { + readonly headless: ExecutionPort + readonly interactive: ExecutionPort + /** Supplies durable mode state after Braid has restarted. */ + readonly isInteractiveRun?: (runId: string) => boolean +} + +/** Routes one run by its explicit mode or canonical retained admission. */ +export class ModeRoutingExecutionPort implements ExecutionPort { + readonly #headless: ExecutionPort + readonly #interactive: ExecutionPort + readonly #isInteractiveRun: (runId: string) => boolean + readonly #interactiveRuns = new Set() + readonly admissionMode: 'sync' | 'async' + readonly capabilities: NonNullable + readonly environmentCapabilities?: NonNullable + + constructor(options: ModeRoutingExecutionOptions) { + this.#headless = options.headless + this.#interactive = options.interactive + this.#isInteractiveRun = options.isInteractiveRun ?? (() => false) + this.admissionMode = + options.headless.admissionMode === 'async' || options.interactive.admissionMode === 'async' + ? 'async' + : 'sync' + this.capabilities = this.#capabilities() + if (options.headless.environmentCapabilities !== undefined) { + this.environmentCapabilities = options.headless.environmentCapabilities.bind(options.headless) + } + } + + async admit(input: ExecuteTurnInput) { + const port = this.#portForInput(input) + if (port.admit === undefined) { + throw new Error('The selected execution port does not support admission') + } + const admission = await port.admit(input) + if (this.#isInteractivePort(port)) this.#interactiveRuns.add(input.runId) + return admission + } + + async *streamTurn(input: ExecuteTurnInput): AsyncIterable { + const port = this.#portForInput(input) + yield* port.streamTurn(input) + } + + async cancelRun(input: CancelInput) { + const port = this.#portForInput(input) + if (port.cancelRun === undefined) { + throw new Error('The selected execution port does not support cancellation') + } + return port.cancelRun(input) + } + + async detachRun(input: DetachInput) { + const port = this.#portForInput(input) + if (port.detachRun === undefined) { + throw new Error('The selected execution port does not support detachment') + } + return port.detachRun(input) + } + + async steerRun(input: SteerInput) { + const port = this.#portForRun(input.runId) + if (port.steerRun === undefined) { + throw new Error('The selected execution port does not support steering') + } + return port.steerRun(input) + } + + async status(input: StatusInput) { + const port = this.#portForRecovery(input) + if (port.status === undefined) { + throw new Error('The selected execution port does not support status') + } + return port.status(input) + } + + async respondInteraction(input: { + readonly command: InteractionResponseCommand + readonly signal?: AbortSignal + readonly recovery?: RetainedExecutionRecoveryContext + }) { + const port = this.#portForInput({ + runId: input.command.binding.runId, + ...(input.recovery?.retainedAdmission === undefined + ? {} + : { retainedAdmission: input.recovery.retainedAdmission }), + ...(input.recovery?.receipt === undefined ? {} : { receipt: input.recovery.receipt }), + }) + if (port.respondInteraction === undefined) { + throw new Error('The selected execution port does not support interaction responses') + } + return port.respondInteraction(input) + } + + async *reconnect(input: ReconnectInput): AsyncIterable { + const port = this.#portForRecovery(input) + if (port.reconnect === undefined) { + throw new Error('The selected execution port does not support reconnection') + } + yield* port.reconnect(input) + } + + async nativeBoundary(input: NativeBoundaryInput) { + const port = this.#portForRun(input.runId) + if (port.nativeBoundary === undefined) return null + return port.nativeBoundary(input) + } + + #capabilities(): NonNullable { + return ((input) => { + const selected = this.#portForInput(input).capabilities + if (selected === undefined) return UNKNOWN_RUN_CAPABILITIES + if (typeof selected === 'function') return selected(input) + return legacyCapabilities(selected) + }) as (input: ExecuteTurnInput) => RunCapabilities | Promise + } + + #portForRecovery(input: StatusInput | ReconnectInput): ExecutionPort { + const port = this.#portForInput(input) + if (this.#isInteractivePort(port)) this.#interactiveRuns.add(input.runId) + return port + } + + #portForRun(runId: string, _controlRef?: unknown): ExecutionPort { + const known = this.#interactiveRuns.has(runId) || this.#isInteractiveRun(runId) + if (known) { + this.#interactiveRuns.add(runId) + return this.#interactive + } + return this.#headless + } + + #portForInput(input: { + readonly runId: string + readonly mode?: string + readonly retainedAdmission?: unknown + readonly receipt?: { readonly requested?: { readonly mode?: string } } + }): ExecutionPort { + const admissionMode = retainedAdmissionMode(input.retainedAdmission) + const receiptMode = input.receipt?.requested?.mode + if (input.mode === 'interactive' && admissionMode === 'headless') { + throw new Error('Interactive mode conflicts with a headless retained admission') + } + if ( + input.mode !== undefined && + input.mode !== 'interactive' && + admissionMode === 'interactive' + ) { + throw new Error('Headless mode conflicts with an interactive retained admission') + } + if ( + input.mode === 'interactive' || + admissionMode === 'interactive' || + receiptMode === 'interactive' + ) { + this.#interactiveRuns.add(input.runId) + return this.#interactive + } + if (this.#interactiveRuns.has(input.runId) || this.#isInteractiveRun(input.runId)) { + this.#interactiveRuns.add(input.runId) + return this.#interactive + } + return this.#headless + } + + #isInteractivePort(port: ExecutionPort): boolean { + return port === this.#interactive + } +} + +function legacyCapabilities(capabilities: { readonly cancel: boolean }): RunCapabilities { + return { + ...UNKNOWN_RUN_CAPABILITIES, + controls: { ...UNKNOWN_RUN_CAPABILITIES.controls, cancel: capabilities.cancel }, + } +} + +function retainedAdmissionMode(value: unknown): 'interactive' | 'headless' | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined + const phase = (value as { readonly phase?: unknown }).phase + if (typeof phase !== 'string') return undefined + if (phase.startsWith('interactive_')) return 'interactive' + if (phase === 'intent' || phase === 'environment' || phase === 'dispatched') return 'headless' + return undefined +} diff --git a/src/adapters/runtime/native-interactive-run-broker.ts b/src/adapters/runtime/native-interactive-run-broker.ts new file mode 100644 index 0000000..5d3db1f --- /dev/null +++ b/src/adapters/runtime/native-interactive-run-broker.ts @@ -0,0 +1,124 @@ +import type { RetainedInteractiveRunHandle } from '@tangle-network/agent-runtime/kernel' +import type { + NativeInteractiveExecutionControl, + NativeInteractiveRunOutcome, +} from '../../ports/native-interactive-execution.js' + +interface Deferred { + readonly promise: Promise + readonly resolve: (value: T) => void + readonly reject: (error: unknown) => void + settled: boolean +} + +interface BrokerEntry { + readonly handle: Deferred + readonly outcome: Deferred + opened: boolean +} + +export type NativeInteractiveRunSettlement = 'settled' | 'absent' | 'already-settled' + +export interface NativeInteractiveRunLease { + publish(handle: RetainedInteractiveRunHandle): void + outcome(options?: { readonly signal?: AbortSignal }): Promise + fail(error: unknown): void + close(): void +} + +/** One in-memory rendezvous only; Runtime and Braid's journal remain the durable owners. */ +export class NativeInteractiveRunBroker implements NativeInteractiveExecutionControl { + readonly #entries = new Map() + + open(runId: string): NativeInteractiveRunLease { + const entry = this.#entry(runId) + if (entry.opened) throw new Error(`Interactive run ${runId} already has an active execution`) + entry.opened = true + let closed = false + return Object.freeze({ + publish: (handle: RetainedInteractiveRunHandle) => { + if (closed) throw new Error(`Interactive run ${runId} execution is closed`) + entry.handle.resolve(handle) + }, + outcome: (options?: { readonly signal?: AbortSignal }) => + abortable(entry.outcome.promise, options?.signal), + fail: (error: unknown) => { + entry.handle.reject(asError(error)) + entry.outcome.reject(asError(error)) + }, + close: () => { + if (closed) return + closed = true + entry.handle.reject(new Error(`Interactive run ${runId} ended before terminal attach`)) + entry.outcome.reject(new Error(`Interactive run ${runId} ended without a terminal outcome`)) + if (this.#entries.get(runId) === entry) this.#entries.delete(runId) + }, + }) + } + + waitForHandle( + runId: string, + options?: { readonly signal?: AbortSignal }, + ): Promise { + return abortable(this.#entry(runId).handle.promise, options?.signal) + } + + settle(runId: string, outcome: NativeInteractiveRunOutcome): NativeInteractiveRunSettlement { + const entry = this.#entries.get(runId) + if (entry === undefined || !entry.opened) return 'absent' + if (entry.outcome.settled) return 'already-settled' + entry.outcome.resolve(structuredClone(outcome)) + return 'settled' + } + + #entry(runId: string): BrokerEntry { + if (runId.trim().length === 0) throw new Error('Interactive run id must not be empty') + const current = this.#entries.get(runId) + if (current !== undefined) return current + const entry: BrokerEntry = { + handle: deferred(), + outcome: deferred(), + opened: false, + } + this.#entries.set(runId, entry) + return entry + } +} + +function deferred(): Deferred { + let resolvePromise: (value: T) => void = () => {} + let rejectPromise: (error: unknown) => void = () => {} + const value: Deferred = { + promise: new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }), + resolve: (next) => { + if (value.settled) return + value.settled = true + resolvePromise(next) + }, + reject: (error) => { + if (value.settled) return + value.settled = true + rejectPromise(error) + }, + settled: false, + } + void value.promise.catch(() => undefined) + return value +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +async function abortable(promise: Promise, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + if (signal === undefined) return promise + return new Promise((resolve, reject) => { + const abort = (): void => reject(signal.reason) + signal.addEventListener('abort', abort, { once: true }) + void promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)) + }) +} diff --git a/src/adapters/runtime/production-backend-common.ts b/src/adapters/runtime/production-backend-common.ts index d74aabc..c02486d 100644 --- a/src/adapters/runtime/production-backend-common.ts +++ b/src/adapters/runtime/production-backend-common.ts @@ -1,6 +1,7 @@ import type { AgentProfile, HarnessType } from '@tangle-network/agent-interface' import { ConnectionError } from '../../app/connection-errors.js' import type { ConnectionCatalog, ConnectionSelectionInput } from '../../app/connections.js' +import { canonicalDigest } from '../../domain/canonical.js' import type { ConnectionRecord } from '../../domain/entities.js' import type { ConnectionId } from '../../domain/ids.js' import type { ExecuteTurnInput } from '../../ports/execution.js' @@ -103,7 +104,18 @@ export function requiredWorkspaceCwd( export function safeExecutionId(value: string): string { const safe = value.replace(/[^A-Za-z0-9._:-]/gu, '-') - return safe.slice(0, 128) || 'run' + if (value.length > 0 && safe === value && value.length <= 128) return value + const readable = safe || 'run' + const suffix = canonicalDigest(value).slice('sha256:'.length, 'sha256:'.length + 32) + return `${readable.slice(0, 95)}-${suffix}` +} + +/** Build a readable provider key without collisions or overlong truncation. */ +export function stableProviderId(prefix: string, value: string): string { + const readable = `${prefix}${safeExecutionId(value)}` + if (readable.length <= 128) return readable + const suffix = canonicalDigest({ prefix, value }).slice('sha256:'.length, 'sha256:'.length + 32) + return `${readable.slice(0, 95)}-${suffix}` } export function freezeExecution(value: T): T { diff --git a/src/adapters/runtime/production-backend-resolver.ts b/src/adapters/runtime/production-backend-resolver.ts index 5131f9d..d358cb2 100644 --- a/src/adapters/runtime/production-backend-resolver.ts +++ b/src/adapters/runtime/production-backend-resolver.ts @@ -8,7 +8,6 @@ import type { import { type PreparedCliBridgeConnection, prepareCliBridgeConnection, - resolveCliBridgeBackend, } from './production-cli-bridge-backend.js' import { resolveTangleInferenceBackend } from './production-tangle-inference-backend.js' import { @@ -43,16 +42,11 @@ export async function resolveProductionBackend( selection: ProductionExecutionSelection, ): Promise { const record = options.connections.select(selection.connection).record + if (record.kind === 'cli-bridge') { + throw new Error('CLI Bridge execution is owned by CliBridgeRetainedExecutionPort') + } switch (record.kind) { - case 'cli-bridge': - return resolveCliBridgeBackend( - options, - input, - selection, - record.id, - connectionEndpoint(record, options), - ) case 'tangle-inference': return resolveTangleInferenceBackend( options, diff --git a/src/adapters/runtime/production-cli-bridge-backend.ts b/src/adapters/runtime/production-cli-bridge-backend.ts index 0f20c92..24849e4 100644 --- a/src/adapters/runtime/production-cli-bridge-backend.ts +++ b/src/adapters/runtime/production-cli-bridge-backend.ts @@ -3,6 +3,8 @@ import type { AgentProfile, HarnessType, } from '@tangle-network/agent-interface' +import type { CliBridgeProvider } from '@tangle-network/agent-provider-cli-bridge' +import type { BridgeModelCredential } from '@tangle-network/agent-runtime/kernel' import { ConnectionError } from '../../app/connection-errors.js' import type { ConnectionId } from '../../domain/ids.js' import type { ExecuteTurnInput } from '../../ports/execution.js' @@ -12,7 +14,10 @@ import { materializeBridgeModelRoute, } from '../connections/cli-bridge-model-route.js' import { readConnectionCredential } from '../connections/production-connection-credentials.js' -import { normalizeCliBridgeProviderBaseUrl } from '../connections/production-connection-endpoints.js' +import { + isLoopbackEndpoint, + normalizeCliBridgeProviderBaseUrl, +} from '../connections/production-connection-endpoints.js' import { endpointLocation, staticExecutionObservation } from './execution-observation-source.js' import type { PreparedExecution } from './prepared-execution.js' import { @@ -24,7 +29,7 @@ import { requiredProfileModel, requiredProfileRunner, requiredWorkspaceCwd, - safeExecutionId, + stableProviderId, } from './production-backend-common.js' const LOCAL_BRIDGE_BEARER = 'braid-local-cli-bridge' @@ -37,51 +42,15 @@ export interface PreparedCliBridgeConnection { readonly workspace: string readonly bridgeUrl: string readonly bearerToken: string + readonly bridgeModelCredential?: BridgeModelCredential readonly fetch?: typeof fetch readonly providerSessionId: string + readonly provider: CliBridgeProvider readonly capabilities: AgentEnvironmentCapabilities readonly observation: NonNullable readonly materializationReceipt: Readonly> } -export async function resolveCliBridgeBackend( - options: ProductionBackendResolverOptions, - input: ExecuteTurnInput, - selection: ProductionExecutionSelection, - connectionId: ConnectionId, - endpoint: string, -): Promise { - const prepared = await prepareCliBridgeConnection( - options, - input, - selection, - connectionId, - endpoint, - ) - const { createExecutor } = await import('@tangle-network/agent-runtime/kernel') - const backend = Object.freeze({ - kind: 'executor' as const, - factory: createExecutor({ - backend: 'bridge', - bridgeUrl: prepared.bridgeUrl, - bridgeBearer: prepared.bearerToken, - cwd: prepared.workspace, - sessionId: prepared.providerSessionId, - }), - profile: prepared.profile, - agentRunName: prepared.route, - }) - return freezeExecution({ - kind: 'prepared-execution' as const, - backend, - capabilities: prepared.capabilities, - providerSessionId: prepared.providerSessionId, - cancellation: { kind: 'runtime-executor-teardown' }, - observation: prepared.observation, - materializationReceipt: prepared.materializationReceipt, - }) -} - export async function prepareCliBridgeConnection( options: ProductionBackendResolverOptions, input: ExecuteTurnInput, @@ -109,13 +78,37 @@ export async function prepareCliBridgeConnection( const record = connectionRecord(connectionId, options) const credential = await readConnectionCredential(record, options, endpoint) - const providerSessionId = input.sessionId ?? `session-braid-${safeExecutionId(input.runId)}` + const providerSessionId = input.sessionId ?? stableProviderId('session-braid-', input.runId) const route = materializeBridgeModelRoute(runner, model, profile.model?.provider) const workspace = requiredWorkspaceCwd(input.workspaceRoot, options.workspaceCwd) const bridgeUrl = normalizeCliBridgeProviderBaseUrl(endpoint, connectionId) const bridgeLocation = endpointLocation(bridgeUrl) const createdAt = new Date().toISOString() - const { defaultCliBridgeCapabilities } = await import('@tangle-network/agent-provider-cli-bridge') + const { createCliBridgeProvider } = await import('@tangle-network/agent-provider-cli-bridge') + const providerFetch = + options.bridgeModelCredential === undefined + ? options.fetch + : bridgeCredentialFetch( + bridgeUrl, + options.bridgeModelCredential, + options.fetch ?? globalThis.fetch, + ) + const providerOptions = { + baseUrl: bridgeUrl, + bearerToken: credential ?? LOCAL_BRIDGE_BEARER, + defaultModel: route, + ...(runner === 'pi' + ? { + defaultExecution: { + kind: 'host' as const, + jail: { mode: 'fs-jail' as const }, + }, + } + : {}), + ...(providerFetch === undefined ? {} : { fetch: providerFetch }), + } + const provider = createCliBridgeProvider(providerOptions) + const capabilities = await provider.capabilities() return freezeExecution({ profile, model, @@ -124,8 +117,12 @@ export async function prepareCliBridgeConnection( workspace, bridgeUrl, bearerToken: credential ?? LOCAL_BRIDGE_BEARER, + ...(options.bridgeModelCredential === undefined + ? {} + : { bridgeModelCredential: options.bridgeModelCredential }), ...(options.fetch === undefined ? {} : { fetch: options.fetch }), - capabilities: defaultCliBridgeCapabilities(runner), + provider, + capabilities, providerSessionId, observation: staticExecutionObservation({ kind: bridgeLocation.location === 'local' ? 'local-process' : 'remote-service', @@ -158,3 +155,66 @@ export async function prepareCliBridgeConnection( }, }) } + +function bridgeCredentialFetch( + bridgeUrl: string, + credential: BridgeModelCredential, + fetcher: typeof fetch, +): typeof fetch { + if (!isLoopbackEndpoint(bridgeUrl)) { + throw new Error('A request-scoped CLI Bridge model credential requires a loopback endpoint') + } + return async (input, init) => { + const url = new URL( + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url, + ) + if (!usesBridgeModelCredential(url, init)) return fetcher(input, init) + const [token, baseUrl] = await Promise.all([ + credentialValue(credential, credential.key), + credentialValue(credential, credential.baseUrlKey), + ]) + const upstream = safeModelBaseUrl(baseUrl) + const headers = new Headers(init?.headers) + headers.set('x-cli-bridge-model-credential', token) + headers.set('x-cli-bridge-model-base-url', upstream) + return fetcher(input, { ...init, headers }) + } +} + +function usesBridgeModelCredential(url: URL, init?: RequestInit): boolean { + const method = init?.method?.toUpperCase() ?? 'GET' + if (method !== 'POST') return false + return ( + url.pathname === '/v1/chat/completions' || + url.pathname === '/v1/sessions' || + /^\/v1\/sessions\/[^/]+\/(?:turns|continue)$/u.test(url.pathname) + ) +} + +async function credentialValue(credential: BridgeModelCredential, key: string): Promise { + let value: string | undefined + try { + value = await credential.provider.get(key) + } catch { + throw new Error(`The CLI Bridge model credential provider failed for ${key}`) + } + if (typeof value !== 'string' || value.length === 0 || /[\r\n\0]/u.test(value)) { + throw new Error(`The CLI Bridge model credential provider has no usable value for ${key}`) + } + return value +} + +function safeModelBaseUrl(value: string): string { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error('The CLI Bridge model credential base URL is invalid') + } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error( + 'The CLI Bridge model credential base URL must be an HTTPS URL without credentials', + ) + } + return url.toString().replace(/\/$/u, '') +} diff --git a/src/adapters/runtime/production-tangle-sandbox-backend.ts b/src/adapters/runtime/production-tangle-sandbox-backend.ts index 31589bb..3263324 100644 --- a/src/adapters/runtime/production-tangle-sandbox-backend.ts +++ b/src/adapters/runtime/production-tangle-sandbox-backend.ts @@ -11,6 +11,10 @@ import { canonicalDigest } from '../../domain/canonical.js' import type { ConnectionId } from '../../domain/ids.js' import type { ExecuteTurnInput } from '../../ports/execution.js' import { harnessSupportsModel, snapHarnessToModel } from '../agent-interface/harness-runtime.js' +import { + createTangleRetainedControlLookup, + supportsTangleRetainedControlLookup, +} from '../connections/tangle-retained-control-lookup.js' import type { ExecutionObservationSource, PreparedExecution, @@ -25,12 +29,9 @@ import { requiredProfileModel, requiredProfileRunner, safeExecutionId, + stableProviderId, } from './production-backend-common.js' import { observeSandboxClient } from './sandbox-observation.js' -import { - createTangleRetainedControlLookup, - supportsTangleRetainedControlLookup, -} from '../connections/tangle-retained-control-lookup.js' import { withSandboxResultProjection } from './sandbox-result-projection.js' import { retainedSandboxIdentity, @@ -68,7 +69,7 @@ export async function resolveTangleSandboxBackend( import('@tangle-network/agent-runtime/kernel'), ]) const lifecycle = sandboxLifecycle() - const idempotencyKey = `env-braid-${safeExecutionId(input.runId)}` + const idempotencyKey = stableProviderId('env-braid-', input.runId) const environmentRequestDigest = canonicalDigest({ kind: 'tangle-sandbox-environment-request', idempotencyKey, @@ -157,7 +158,7 @@ export async function resolveTangleSandboxRetainedConnection( ) } const { profile, model, runner } = await tangleExecutionIdentity(input, selection, connectionId) - const providerSessionId = input.sessionId ?? `session-braid-${safeExecutionId(input.runId)}` + const providerSessionId = input.sessionId ?? stableProviderId('session-braid-', input.runId) if (!providerSessionId.startsWith('session-braid-')) { throw new ConnectionError( 'CONNECTION_UNSUPPORTED', @@ -306,7 +307,7 @@ function providerSessionFor( capabilities: AgentEnvironmentCapabilities, ): string | undefined { if (!capabilities.sessions.continue) return undefined - return input.sessionId ?? `session-braid-${safeExecutionId(input.runId)}` + return input.sessionId ?? stableProviderId('session-braid-', input.runId) } async function tangleExecutionIdentity( diff --git a/src/adapters/runtime/retained-execution-contract.ts b/src/adapters/runtime/retained-execution-contract.ts index edfab1f..32cc3d5 100644 --- a/src/adapters/runtime/retained-execution-contract.ts +++ b/src/adapters/runtime/retained-execution-contract.ts @@ -7,6 +7,9 @@ import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' import type { ExecuteTurnInput, ProviderRunSnapshot, + RetainedExecutionRecoveryContext, + RetainedRunAdmissionRecord, + RetainedRunAdmissionRecorder, RunCapabilities, } from '../../ports/execution.js' @@ -32,6 +35,14 @@ export interface RetainedExecutionPlan { controlRef: AgentExactRunControlRef, signal?: AbortSignal, ) => Promise + /** Recover a persisted intent or pre-dispatch admission with Runtime. */ + readonly recover?: ( + input: RetainedExecutionRecoveryContext & { + readonly admission: RetainedRunAdmissionRecord + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + readonly signal?: AbortSignal + }, + ) => Promise readonly discover: ( braidRunId: string, signal?: AbortSignal, @@ -52,9 +63,12 @@ export interface RetainedExecutionPlan { export interface RetainedExecutionDriver { readonly resolve: (input: ExecuteTurnInput) => Promise - readonly recover: (input: { - readonly runId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - }) => Promise + readonly recover: ( + input: { + readonly runId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ) => Promise } diff --git a/src/adapters/runtime/retained-execution-state.ts b/src/adapters/runtime/retained-execution-state.ts index 0f0bfe0..3f0da19 100644 --- a/src/adapters/runtime/retained-execution-state.ts +++ b/src/adapters/runtime/retained-execution-state.ts @@ -4,19 +4,28 @@ import { } from '@tangle-network/agent-interface' import type { RetainedRunHandle } from '@tangle-network/agent-runtime/kernel' import { canonicalDigest } from '../../domain/canonical.js' +import type { RunCapabilities } from '../../domain/run-contracts.js' +import { requestedInteractionsForRun } from '../../domain/run-interactions.js' import type { ExecuteTurnInput } from '../../ports/execution.js' import { canonicalAgentProfileDigestHex } from '../agent-interface/profile-runtime.js' import type { RetainedExecutionPlan } from './retained-execution-contract.js' const MAX_RETAINED_CLIENTS = 128 -export function retainedExecutionKey(input: ExecuteTurnInput): string { +export function retainedExecutionKey( + input: ExecuteTurnInput, + capabilities?: Pick, +): string { return canonicalDigest({ runId: input.runId, operationId: input.operationId, text: input.text, profile: canonicalAgentProfileDigestHex(input.profile), connectionId: input.connectionId ?? null, + mode: input.mode ?? null, + interactions: + input.interactions ?? + (capabilities === undefined ? {} : requestedInteractionsForRun(input.mode, capabilities)), workspaceRoot: input.workspaceRoot ?? null, sessionId: input.sessionId ?? null, contextBoundary: input.contextBoundary ?? null, @@ -59,6 +68,10 @@ export class RetainedExecutionState { return this.#plans.get(runId) } + preparedPlan(runId: string): RetainedExecutionPlan | undefined { + return this.#prepared.has(runId) ? this.#plans.get(runId) : undefined + } + rememberPlan(runId: string, plan: RetainedExecutionPlan): void { this.#plans.set(runId, plan) this.#touch(runId) diff --git a/src/adapters/runtime/retained-execution.ts b/src/adapters/runtime/retained-execution.ts index 0859c4d..9ac1e9c 100644 --- a/src/adapters/runtime/retained-execution.ts +++ b/src/adapters/runtime/retained-execution.ts @@ -1,4 +1,8 @@ -import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' +import type { + AgentExactRunControlRef, + InteractionAcknowledgementStatus, + InteractionResponseCommand, +} from '@tangle-network/agent-interface' import type { RetainedRunHandle } from '@tangle-network/agent-runtime/kernel' import { canonicalDigest } from '../../domain/canonical.js' import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' @@ -9,6 +13,9 @@ import type { ExecutionAdmission, ExecutionPort, ProviderRunSnapshot, + RetainedExecutionRecoveryContext, + RetainedRunAdmissionRecord, + RetainedRunAdmissionRecorder, } from '../../ports/execution.js' import { canonicalAgentProfileDigestHex } from '../agent-interface/profile-runtime.js' import type { @@ -42,7 +49,7 @@ export class RetainedExecutionPort implements ExecutionPort { async admit(input: ExecuteTurnInput): Promise { const plan = await this.#driver.resolve(input) - this.#state.rememberPrepared(input.runId, retainedExecutionKey(input), plan) + this.#state.rememberPrepared(input.runId, retainedExecutionKey(input, plan.capabilities), plan) return { capabilities: plan.capabilities, provider: plan.providerName, @@ -59,7 +66,11 @@ export class RetainedExecutionPort implements ExecutionPort { async *streamTurn(input: ExecuteTurnInput): AsyncGenerator { if (this.#state.isCancellationRequested(input.runId)) return - const plan = this.#state.takePrepared(input.runId, retainedExecutionKey(input)) + const prepared = this.#state.preparedPlan(input.runId) + const plan = this.#state.takePrepared( + input.runId, + retainedExecutionKey(input, prepared?.capabilities), + ) if (plan === undefined) { throw new Error('Retained run was not admitted with this exact request') } @@ -85,20 +96,56 @@ export class RetainedExecutionPort implements ExecutionPort { }) } - async *reconnect(input: { - readonly runId: string - readonly after?: string - readonly afterSequence?: number - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly signal: AbortSignal - }): AsyncGenerator { + async *reconnect( + input: { + readonly runId: string + readonly after?: string + readonly afterSequence?: number + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal: AbortSignal + } & RetainedExecutionRecoveryContext & { + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + }, + ): AsyncGenerator { if (input.after !== undefined && input.afterSequence === undefined) { throw new Error('Retained reconnect requires the saved event sequence') } - const providerSessionId = this.#recoverySessionId(input.providerSessionId, input.controlRef) - const plan = await this.#planFor(input.runId, providerSessionId, input.controlRef) - const controlRef = await this.#controlRefFor(input.runId, plan, input.controlRef, input.signal) + assertRecoveryIdentity(input.retainedAdmission, input.providerSessionId, input.controlRef) + const persistedControlRef = controlRefFromAdmission(input.retainedAdmission) + const suppliedControlRef = input.controlRef ?? persistedControlRef + const providerSessionId = this.#recoverySessionId(input.providerSessionId, suppliedControlRef) + const plan = await this.#planFor( + input.runId, + providerSessionId, + suppliedControlRef, + input, + input.signal, + ) + const recovered = await this.#recoverHandle(plan, input, input.onRetainedAdmission) + if (recovered !== undefined) { + if (recovered === null) return + assertRecoveredControlRef(this.#state, plan, suppliedControlRef, recovered) + this.#state.rememberHandle(input.runId, plan, recovered) + this.#state.markDetached(input.runId, false) + yield* streamRetainedExecution({ + runId: input.runId, + handle: recovered, + plan, + state: this.#state, + signal: input.signal, + includeObservation: (input.afterSequence ?? 0) === 0, + afterSequence: input.afterSequence ?? 0, + ...(input.after === undefined ? {} : { after: input.after }), + }) + return + } + const controlRef = await this.#controlRefFor( + input.runId, + plan, + suppliedControlRef, + input.signal, + ) if (!controlRef) return const handle = await plan.reconnect(controlRef, input.signal) if (!handle) return @@ -116,15 +163,34 @@ export class RetainedExecutionPort implements ExecutionPort { }) } - async detachRun(input: { - readonly runId: string - readonly operationId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly cursor?: string - readonly signal?: AbortSignal - }): Promise { - this.#validateKnownControl(input.runId, input.providerSessionId, input.controlRef) + async detachRun( + input: { + readonly runId: string + readonly operationId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly cursor?: string + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ): Promise { + assertRecoveryIdentity(input.retainedAdmission, input.providerSessionId, input.controlRef) + const persistedControlRef = controlRefFromAdmission(input.retainedAdmission) + const suppliedControlRef = input.controlRef ?? persistedControlRef + const recoverySessionId = this.#recoverySessionId(input.providerSessionId, suppliedControlRef) + if ( + this.#state.plan(input.runId) === undefined && + (input.retainedAdmission !== undefined || recoverySessionId !== undefined) + ) { + const plan = await this.#planFor( + input.runId, + recoverySessionId, + suppliedControlRef, + input, + input.signal, + ) + if (suppliedControlRef !== undefined) this.#state.validateControlRef(plan, suppliedControlRef) + } + this.#validateKnownControl(input.runId, input.providerSessionId, suppliedControlRef) if (this.#state.isDetached(input.runId)) { return { operationId: input.operationId, outcome: 'already-applied', detail: 'detached' } } @@ -154,11 +220,35 @@ export class RetainedExecutionPort implements ExecutionPort { detail: 'cancelled-before-start', } } + const retainedIntent = + input.retainedAdmission?.phase === 'intent' ? input.retainedAdmission : undefined + const active = this.#state.handle(input.runId) + const starting = this.#state.startingHandle(input.runId) + if (active === undefined && starting === undefined && retainedIntent !== undefined) { + if ( + input.controlRef !== undefined || + (input.providerSessionId !== undefined && + input.providerSessionId !== retainedIntent.sessionId) + ) { + return { + operationId: input.operationId, + outcome: 'rejected', + detail: 'retained-intent-control-mismatch', + } + } + this.#state.markCancellationRequested(input.runId) + return { + operationId: input.operationId, + outcome: 'accepted', + detail: 'cancelled-before-start', + } + } const resolved = await this.#handleFor( input.runId, input.providerSessionId, input.controlRef, input.signal, + input, ) if (!resolved) { return { @@ -209,17 +299,20 @@ export class RetainedExecutionPort implements ExecutionPort { } } - async status(input: { - readonly runId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly signal?: AbortSignal - }): Promise { + async status( + input: { + readonly runId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ): Promise { const resolved = await this.#handleFor( input.runId, input.providerSessionId, input.controlRef, input.signal, + input, ) if (!resolved) return null if (resolved.plan.exactStatus === false) return null @@ -255,12 +348,68 @@ export class RetainedExecutionPort implements ExecutionPort { } } + async respondInteraction(input: { + readonly command: InteractionResponseCommand + readonly signal?: AbortSignal + readonly recovery?: RetainedExecutionRecoveryContext + }): Promise { + const { binding } = input.command + const resolved = await this.#handleFor( + binding.runId, + binding.sessionId, + undefined, + input.signal, + input.recovery, + ) + if (!resolved) { + return { + operationId: input.command.operationId, + outcome: 'unknown', + detail: 'INTERACTION_RESPONSE_RUN_UNKNOWN', + } + } + const exact = resolved.handle.controlRef + if ( + binding.provider !== exact.provider || + binding.environmentId !== exact.environmentId || + binding.sessionId !== exact.sessionId || + binding.executionId !== exact.executionId + ) { + return { + operationId: input.command.operationId, + outcome: 'rejected', + detail: 'INTERACTION_BINDING_MISMATCH', + } + } + const acknowledgement = await resolved.handle.respondToInteraction(input.command, { + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + if ( + acknowledgement.operationId !== input.command.operationId || + acknowledgement.commandDigest !== input.command.commandDigest || + canonicalDigest(acknowledgement.binding) !== canonicalDigest(input.command.binding) + ) { + return { + operationId: input.command.operationId, + outcome: 'rejected', + detail: 'INTERACTION_ACKNOWLEDGEMENT_MISMATCH', + } + } + return interactionAcknowledgement(input.command.operationId, acknowledgement.status) + } + async #handleFor( runId: string, providerSessionId: string | undefined, supplied: AgentExactRunControlRef | undefined, signal: AbortSignal | undefined, + recovery?: RetainedExecutionRecoveryContext & { + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + }, ): Promise { + assertRecoveryIdentity(recovery?.retainedAdmission, providerSessionId, supplied) + const persistedControlRef = controlRefFromAdmission(recovery?.retainedAdmission) + const suppliedControlRef = supplied ?? persistedControlRef const active = this.#state.handle(runId) const activePlan = this.#state.plan(runId) const starting = this.#state.startingHandle(runId) @@ -272,8 +421,8 @@ export class RetainedExecutionPort implements ExecutionPort { if (starting === undefined) throw new Error('Retained run state is incomplete') handle = await starting } - if (supplied !== undefined) { - const exact = this.#state.validateControlRef(activePlan, supplied) + if (suppliedControlRef !== undefined) { + const exact = this.#state.validateControlRef(activePlan, suppliedControlRef) this.#state.assertSameControlRef(handle.controlRef, exact) } return { handle, plan: activePlan, wasStarting: starting !== undefined } @@ -281,9 +430,16 @@ export class RetainedExecutionPort implements ExecutionPort { if (activePlan !== undefined) { if (this.#state.isCancellationRequested(runId)) return null } - const recoverySessionId = this.#recoverySessionId(providerSessionId, supplied) - const plan = await this.#planFor(runId, recoverySessionId, supplied) - const controlRef = await this.#controlRefFor(runId, plan, supplied, signal) + const recoverySessionId = this.#recoverySessionId(providerSessionId, suppliedControlRef) + const plan = await this.#planFor(runId, recoverySessionId, suppliedControlRef, recovery, signal) + const recovered = await this.#recoverHandle(plan, recovery, recovery?.onRetainedAdmission) + if (recovered !== undefined) { + if (recovered === null) return null + assertRecoveredControlRef(this.#state, plan, suppliedControlRef, recovered) + this.#state.rememberHandle(runId, plan, recovered) + return { handle: recovered, plan, wasStarting: false } + } + const controlRef = await this.#controlRefFor(runId, plan, suppliedControlRef, signal) if (!controlRef) return null const handle = await plan.reconnect(controlRef, signal) if (handle) this.#state.rememberHandle(runId, plan, handle) @@ -323,9 +479,11 @@ export class RetainedExecutionPort implements ExecutionPort { runId: string, providerSessionId?: string, controlRef?: AgentExactRunControlRef, + recovery?: RetainedExecutionRecoveryContext, + signal?: AbortSignal, ): Promise { const cached = this.#state.plan(runId) - if (cached !== undefined) { + if (cached !== undefined && !planNeedsRecoveryRefresh(cached, controlRef, recovery)) { this.#state.assertProviderSession(cached, providerSessionId) return cached } @@ -333,12 +491,42 @@ export class RetainedExecutionPort implements ExecutionPort { runId, ...(providerSessionId === undefined ? {} : { providerSessionId }), ...(controlRef === undefined ? {} : { controlRef }), + ...(signal === undefined ? {} : { signal }), + ...(recovery ?? {}), }) this.#state.assertProviderSession(plan, providerSessionId) this.#state.rememberPlan(runId, plan) return plan } + async #recoverHandle( + plan: RetainedExecutionPlan, + recovery: + | (RetainedExecutionRecoveryContext & { + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + }) + | undefined, + onRetainedAdmission: RetainedRunAdmissionRecorder | undefined, + ): Promise { + const admission = recovery?.retainedAdmission + if ( + admission === undefined || + plan.recover === undefined || + admission.phase === 'dispatched' || + admission.phase === 'interactive_intent' || + admission.phase === 'interactive_environment' || + admission.phase === 'interactive_started' + ) { + return undefined + } + const recovered = await plan.recover({ + ...recovery, + admission, + ...(onRetainedAdmission === undefined ? {} : { onRetainedAdmission }), + }) + return admission.phase === 'environment' && recovered === null ? undefined : recovered + } + #recoverySessionId( providerSessionId: string | undefined, controlRef: AgentExactRunControlRef | undefined, @@ -367,3 +555,110 @@ export class RetainedExecutionPort implements ExecutionPort { if (active !== undefined) this.#state.assertSameControlRef(active.controlRef, exact) } } + +function controlRefFromAdmission( + admission: RetainedRunAdmissionRecord | undefined, +): AgentExactRunControlRef | undefined { + return admission?.phase === 'dispatched' ? admission.controlRef : undefined +} + +function assertRecoveryIdentity( + admission: RetainedRunAdmissionRecord | undefined, + providerSessionId: string | undefined, + supplied: AgentExactRunControlRef | undefined, +): void { + if (admission === undefined) return + const persistedSessionId = admissionSessionId(admission) + if (providerSessionId !== undefined && providerSessionId !== persistedSessionId) { + throw new Error('retained provider session conflicts with the persisted admission') + } + if (admission.phase !== 'dispatched') { + if (supplied !== undefined) { + throw new Error('retained control reference conflicts with the pre-dispatch admission') + } + return + } + if ( + supplied !== undefined && + canonicalDigest(supplied) !== canonicalDigest(admission.controlRef) + ) { + throw new Error('retained control reference conflicts with the persisted run') + } +} + +function admissionSessionId(admission: RetainedRunAdmissionRecord): string { + switch (admission.phase) { + case 'intent': + case 'environment': + case 'interactive_intent': + return admission.sessionId + case 'dispatched': + return admission.controlRef.sessionId + case 'interactive_environment': + return admission.request.run.sessionId + case 'interactive_started': + return admission.ref.run.sessionId + } +} + +function environmentIdFromRecovery( + admission: RetainedRunAdmissionRecord | undefined, +): string | undefined { + if (admission?.phase === 'environment') return admission.environmentId + if (admission?.phase === 'dispatched') return admission.controlRef.environmentId + return undefined +} + +function planNeedsRecoveryRefresh( + plan: RetainedExecutionPlan, + controlRef: AgentExactRunControlRef | undefined, + recovery: RetainedExecutionRecoveryContext | undefined, +): boolean { + const admission = recovery?.retainedAdmission + if ( + admission !== undefined && + admission.phase !== 'dispatched' && + admission.phase !== 'interactive_intent' && + admission.phase !== 'interactive_environment' && + admission.phase !== 'interactive_started' && + plan.recover === undefined + ) { + return true + } + const environmentId = + controlRef?.environmentId ?? environmentIdFromRecovery(recovery?.retainedAdmission) + return environmentId !== undefined && plan.environmentId !== environmentId +} + +function assertRecoveredControlRef( + state: RetainedExecutionState, + plan: RetainedExecutionPlan, + supplied: AgentExactRunControlRef | undefined, + recovered: RetainedRunHandle, +): void { + if (supplied === undefined) return + const exact = state.validateControlRef(plan, supplied) + state.assertSameControlRef(recovered.controlRef, exact) +} + +function interactionAcknowledgement( + operationId: string, + status: InteractionAcknowledgementStatus, +): ControlAcknowledgement { + switch (status) { + case 'accepted': + return { operationId, outcome: 'accepted', detail: 'INTERACTION_RESPONSE_ACCEPTED' } + case 'already_resolved_same': + return { operationId, outcome: 'already-applied', detail: 'INTERACTION_RESPONSE_REPLAYED' } + case 'already_resolved_different': + case 'binding_mismatch': + case 'cancelled': + case 'expired': + case 'invalid_response': + return { operationId, outcome: 'rejected', detail: `INTERACTION_${status.toUpperCase()}` } + case 'transport_failure': + case 'unknown_interaction': + case 'unknown_run': + return { operationId, outcome: 'unknown', detail: `INTERACTION_${status.toUpperCase()}` } + } +} diff --git a/src/adapters/runtime/tangle-retained-execution.ts b/src/adapters/runtime/tangle-retained-execution.ts index 44d6f44..43ceb38 100644 --- a/src/adapters/runtime/tangle-retained-execution.ts +++ b/src/adapters/runtime/tangle-retained-execution.ts @@ -1,16 +1,19 @@ import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' -import type { ExecuteTurnInput } from '../../ports/execution.js' +import type { ExecuteTurnInput, RetainedExecutionRecoveryContext } from '../../ports/execution.js' import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' import { RetainedExecutionPort } from './retained-execution.js' import { createTangleRetainedPlan } from './tangle-retained-run.js' export interface TangleRetainedExecutionOptions { readonly resolve: (input: ExecuteTurnInput) => Promise - readonly recover: (input: { - readonly runId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - }) => Promise + readonly recover: ( + input: { + readonly runId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ) => Promise } /** The cloud owns the retained sandbox; Braid owns readers and exact recovery state. */ @@ -18,12 +21,14 @@ export class TangleRetainedExecutionPort extends RetainedExecutionPort { constructor(options: TangleRetainedExecutionOptions) { super({ resolve: async (input) => createTangleRetainedPlan(await options.resolve(input), input.runId), - recover: async ({ runId, providerSessionId, controlRef }) => + recover: async ({ runId, providerSessionId, controlRef, signal, ...recovery }) => createTangleRetainedPlan( await options.recover({ runId, ...(providerSessionId === undefined ? {} : { providerSessionId }), ...(controlRef === undefined ? {} : { controlRef }), + ...(signal === undefined ? {} : { signal }), + ...recovery, }), runId, controlRef, diff --git a/src/adapters/runtime/tangle-retained-interactive-contract.ts b/src/adapters/runtime/tangle-retained-interactive-contract.ts new file mode 100644 index 0000000..6003bed --- /dev/null +++ b/src/adapters/runtime/tangle-retained-interactive-contract.ts @@ -0,0 +1,207 @@ +import type { AgentExactRunControlRef } from '@tangle-network/agent-interface' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveAdmissionHook, + RetainedInteractiveStartedAdmission, +} from '@tangle-network/agent-runtime/kernel' +import { canonicalDigest } from '../../domain/canonical.js' +import { publicMaterializationReceipt } from '../../domain/materialization-receipt.js' +import type { RunAdmissionReceipt } from '../../domain/receipts.js' +import type { RetainedRunAdmissionRecord } from '../../domain/run-contracts.js' +import type { + ExecutionPort, + RetainedRunAdmissionRecorder, + RunCapabilities, +} from '../../ports/execution.js' +import { stableProviderId } from './production-backend-common.js' +import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' + +type StatusInput = Parameters>[0] +type ReconnectInput = Parameters>[0] + +export type TangleInteractiveRecoveryInput = StatusInput & + Partial> + +export function assertInteractiveProvider(prepared: PreparedTangleRetainedConnection): void { + const interactive = prepared.capabilities.interactiveAgent + if ( + interactive?.start !== true || + interactive.control !== true || + interactive.status !== true || + interactive.attach !== true || + interactive.reattach !== true || + interactive.sendPrompt !== true || + interactive.input !== true || + interactive.resize !== true || + interactive.stop !== true + ) { + throw new Error('Tangle retained connection does not support exact interactive agents') + } +} + +export function interactiveRunCapabilities( + prepared: PreparedTangleRetainedConnection, +): RunCapabilities { + const { interactions: _interactions, ...environment } = prepared.capabilities + return Object.freeze({ + streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, + sessions: { continue: true, messages: false }, + controls: { cancel: true, steer: false, queue: false, status: true, recreate: true }, + events: { stableIdentity: true, sequence: true, cursor: true }, + usage: false, + environment, + }) +} + +export function interactiveMaterializationReceipt( + prepared: PreparedTangleRetainedConnection, +): Readonly> { + return publicMaterializationReceipt({ + provider: prepared.provider.name, + backend: 'environment-provider', + surface: 'interactive-agent', + lifecycle: 'retained', + execution: 'agent-runtime-retained-interactive', + cleanup: 'explicit', + continuity: 'session', + idleTtlSeconds: prepared.idleTtlSeconds, + model: prepared.model, + runner: prepared.runner, + }) +} + +export function interactiveEnvironment( + prepared: PreparedTangleRetainedConnection, + runId: string, + idempotencyKey = interactiveEnvironmentIdempotencyKey(runId), + profile = prepared.profile, +) { + return { + profile, + backend: prepared.runner, + name: stableProviderId('braid-interactive-', runId), + metadata: { + owner: 'braid', + lifecycle: 'retained', + surface: 'interactive-agent', + }, + idempotencyKey, + } +} + +export function interactiveIdempotencyKey(runId: string): string { + return stableProviderId('interactive-braid-', runId) +} + +function interactiveEnvironmentIdempotencyKey(runId: string): string { + return stableProviderId('env-braid-interactive-', runId) +} + +export function requireAdmissionRecorder( + recorder: RetainedRunAdmissionRecorder | undefined, +): RetainedInteractiveAdmissionHook { + if (recorder === undefined) { + throw new Error('Retained Tangle interactive execution requires a durable admission recorder') + } + return (admission) => recorder(admission) +} + +export function requireRecoveryReceipt( + input: RunAdmissionReceipt | undefined, +): RunAdmissionReceipt { + if (input === undefined) { + throw new Error('Interactive intent recovery requires the original run receipt') + } + return input +} + +export function requireInteractiveAdmission( + value: RetainedRunAdmissionRecord | undefined, +): RetainedInteractiveAdmission { + switch (value?.phase) { + case 'interactive_intent': + case 'interactive_environment': + case 'interactive_started': + return value + default: + throw new Error('Interactive recovery requires a canonical interactive admission') + } +} + +export function interactiveAdmissionOrUndefined( + value: RetainedRunAdmissionRecord | undefined, +): RetainedInteractiveAdmission | undefined { + if (value === undefined) return undefined + return requireInteractiveAdmission(value) +} + +export function interactiveSessionId(admission: RetainedInteractiveAdmission): string | undefined { + if (admission.phase === 'interactive_intent') return admission.sessionId + return admission.phase === 'interactive_environment' + ? admission.request.run.sessionId + : admission.ref.run.sessionId +} + +export function assertInteractiveAdmissionMatchesInput( + admission: RetainedInteractiveAdmission, + input: Pick, +): void { + if (admission.phase === 'interactive_started') { + assertStartedAdmissionMatchesInput(admission, input) + return + } + const sessionId = interactiveSessionId(admission) + if (input.providerSessionId !== undefined && input.providerSessionId !== sessionId) { + throw new Error('Interactive provider session conflicts with the saved admission') + } + if (input.controlRef !== undefined) { + throw new Error('Interactive control reference conflicts with the saved admission') + } +} + +export function assertStartedAdmissionMatchesInput( + admission: RetainedInteractiveStartedAdmission, + input: Pick, +): void { + if ( + input.providerSessionId !== undefined && + input.providerSessionId !== admission.ref.run.sessionId + ) { + throw new Error('Interactive provider session conflicts with the saved process reference') + } + if ( + input.controlRef !== undefined && + canonicalDigest(input.controlRef) !== canonicalDigest(admission.ref.run) + ) { + throw new Error('Interactive control reference conflicts with the saved process reference') + } +} + +export function assertExactControl( + input: { + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + }, + actual: AgentExactRunControlRef | undefined, +): void { + if ( + (input.providerSessionId !== undefined || input.controlRef !== undefined) && + actual === undefined + ) { + throw new Error('Interactive exact control reference is unavailable') + } + if ( + input.providerSessionId !== undefined && + actual !== undefined && + input.providerSessionId !== actual.sessionId + ) { + throw new Error('Interactive provider session conflicts with the saved run') + } + if ( + input.controlRef !== undefined && + actual !== undefined && + canonicalDigest(input.controlRef) !== canonicalDigest(actual) + ) { + throw new Error('Interactive control reference conflicts with the saved run') + } +} diff --git a/src/adapters/runtime/tangle-retained-interactive-execution.ts b/src/adapters/runtime/tangle-retained-interactive-execution.ts new file mode 100644 index 0000000..30c4cb5 --- /dev/null +++ b/src/adapters/runtime/tangle-retained-interactive-execution.ts @@ -0,0 +1,607 @@ +import type { AgentExactRunControlRef, AgentProfile } from '@tangle-network/agent-interface' +import { + agentInteractiveSessionStopRequestDigest, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveRunHandle, +} from '@tangle-network/agent-runtime/kernel' +import { + claimRetainedInteractiveControl, + reconnectRetainedInteractiveRun, + recoverRetainedInteractiveRun, + startRetainedInteractiveRun, +} from '@tangle-network/agent-runtime/kernel' +import { canonicalDigest } from '../../domain/canonical.js' +import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' +import type { + CancelRunInput, + ControlAcknowledgement, + ExecuteTurnInput, + ExecutionAdmission, + ExecutionPort, + ProviderRunSnapshot, + RunCapabilities, +} from '../../ports/execution.js' +import { canonicalAgentProfileDigestHex } from '../agent-interface/profile-runtime.js' +import type { NativeInteractiveRunBroker } from './native-interactive-run-broker.js' +import { safeExecutionId } from './production-backend-common.js' +import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' +import { retainedExecutionKey } from './retained-execution-state.js' +import { + assertExactControl, + assertInteractiveAdmissionMatchesInput, + assertInteractiveProvider, + assertStartedAdmissionMatchesInput, + interactiveAdmissionOrUndefined, + interactiveEnvironment, + interactiveIdempotencyKey, + interactiveMaterializationReceipt, + interactiveRunCapabilities, + requireAdmissionRecorder, + requireInteractiveAdmission, + requireRecoveryReceipt, + type TangleInteractiveRecoveryInput, +} from './tangle-retained-interactive-contract.js' +import { + interactiveStatus, + interactiveStatusDetail, + observedEnvelope, + partialInteractiveStatus, + replayBoundary, + terminalEnvelope, + terminalOutcome, +} from './tangle-retained-interactive-projection.js' + +export interface TangleRetainedInteractiveExecutionOptions { + readonly resolve: (input: ExecuteTurnInput) => Promise + readonly recover?: (input: ExecuteTurnInput) => Promise + readonly broker: Pick + readonly holderId?: string +} + +interface PreparedInteractiveConnection { + readonly key: string + readonly prepared: PreparedTangleRetainedConnection +} + +/** Runs one exact Tangle coding-agent TUI through Runtime's durable APIs. */ +export class TangleRetainedInteractiveExecutionPort implements ExecutionPort { + readonly admissionMode = 'async' as const + readonly #resolve: TangleRetainedInteractiveExecutionOptions['resolve'] + readonly #recover: TangleRetainedInteractiveExecutionOptions['recover'] + readonly #broker: Pick + readonly #holderId: string + readonly #prepared = new Map() + readonly #starting = new Map>() + readonly #handles = new Map() + readonly #activeRuns = new Set() + readonly #detachRequested = new Set() + readonly #cancelledRuns = new Set() + + constructor(options: TangleRetainedInteractiveExecutionOptions) { + this.#resolve = options.resolve + this.#recover = options.recover + this.#broker = options.broker + this.#holderId = options.holderId ?? 'braid' + } + + capabilities = (input: ExecuteTurnInput): RunCapabilities | Promise => + this.#prepare(input).then((prepared) => interactiveRunCapabilities(prepared)) + + async admit(input: ExecuteTurnInput): Promise { + const prepared = await this.#prepare(input) + const capabilities = interactiveRunCapabilities(prepared) + const materializationReceipt = interactiveMaterializationReceipt(prepared) + return { + capabilities, + provider: prepared.provider.name, + materializationReceipt, + profileDigest: canonicalAgentProfileDigestHex(input.profile), + capabilitiesDigest: canonicalDigest(capabilities), + materializationDigest: canonicalDigest(materializationReceipt), + } + } + + async *streamTurn(input: ExecuteTurnInput): AsyncIterable { + if (this.#cancelledRuns.has(input.runId)) return + const prepared = await this.#prepare(input) + if (this.#cancelledRuns.has(input.runId)) return + const lease = this.#broker.open(input.runId) + this.#activeRuns.add(input.runId) + let terminal = false + try { + const handle = this.#handles.get(input.runId) ?? (await this.#startOnce(prepared, input)) + this.#rememberHandle(input.runId, handle) + lease.publish(handle) + if (this.#detachRequested.has(input.runId)) this.#settleDetached(input.runId) + yield await observedEnvelope(input.runId, handle, prepared, 1) + const providerOutcome = await terminalOutcome(handle, input.signal) + if (providerOutcome !== undefined) { + terminal = true + yield terminalEnvelope( + input.runId, + handle, + prepared, + providerOutcome, + 2, + this.#cancelledRuns.has(input.runId), + ) + return + } + const outcome = await lease.outcome( + input.signal === undefined ? undefined : { signal: input.signal }, + ) + if (outcome.kind === 'detached') return + terminal = true + yield terminalEnvelope( + input.runId, + handle, + prepared, + outcome, + 2, + this.#cancelledRuns.has(input.runId), + ) + } catch (error) { + lease.fail(error) + throw error + } finally { + this.#activeRuns.delete(input.runId) + if (terminal) this.#forgetHandle(input.runId) + lease.close() + } + } + + async *reconnect(input: TangleInteractiveRecoveryInput): AsyncIterable { + const admission = requireInteractiveAdmission(input.retainedAdmission) + assertInteractiveAdmissionMatchesInput(admission, input) + const prepared = await this.#resolveRecovery(input, admission) + assertInteractiveProvider(prepared) + const lease = this.#broker.open(input.runId) + this.#activeRuns.add(input.runId) + let terminal = false + try { + const handle = await this.#recoverHandle(prepared, input, admission) + if (handle === null) return + this.#rememberHandle(input.runId, handle) + this.#detachRequested.delete(input.runId) + lease.publish(handle) + const replayedSequence = replayBoundary(input, input.runId, handle) + if (replayedSequence < 1) { + yield await observedEnvelope(input.runId, handle, prepared, 1) + } + if (replayedSequence >= 2) { + terminal = true + return + } + const providerOutcome = await terminalOutcome(handle, input.signal) + if (providerOutcome !== undefined) { + terminal = true + yield terminalEnvelope( + input.runId, + handle, + prepared, + providerOutcome, + 2, + this.#cancelledRuns.has(input.runId), + ) + return + } + const outcome = await lease.outcome( + input.signal === undefined ? undefined : { signal: input.signal }, + ) + if (outcome.kind === 'detached') return + terminal = true + yield terminalEnvelope( + input.runId, + handle, + prepared, + outcome, + 2, + this.#cancelledRuns.has(input.runId), + ) + } catch (error) { + lease.fail(error) + throw error + } finally { + this.#activeRuns.delete(input.runId) + if (terminal) this.#forgetHandle(input.runId) + lease.close() + } + } + + async status(input: TangleInteractiveRecoveryInput): Promise { + const partial = interactiveAdmissionOrUndefined(input.retainedAdmission) + if (partial !== undefined) assertInteractiveAdmissionMatchesInput(partial, input) + if ( + this.#handles.get(input.runId) === undefined && + partial !== undefined && + partial.phase !== 'interactive_started' + ) { + return partialInteractiveStatus(input.runId, partial) + } + const handle = await this.#handleFor(input) + if (handle === null) return null + const status = await handle.status({ + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + const mapped = interactiveStatus(status, this.#detachRequested.has(input.runId)) + if (status.state === 'exited') this.#forgetHandle(input.runId) + return { + runId: input.runId, + status: mapped, + sessionId: handle.ref.run.sessionId, + ...(status.state === 'unknown' ? { error: status.message, detail: status.message } : {}), + ...(status.state === 'exited' ? { detail: interactiveStatusDetail(status) } : {}), + } + } + + async detachRun( + input: { + readonly runId: string + readonly operationId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly cursor?: string + readonly signal?: AbortSignal + } & TangleInteractiveRecoveryInput, + ): Promise { + const admission = interactiveAdmissionOrUndefined(input.retainedAdmission) + if (admission !== undefined) assertInteractiveAdmissionMatchesInput(admission, input) + let handle = this.#handles.get(input.runId) + if ( + handle === undefined && + !this.#prepared.has(input.runId) && + !this.#starting.has(input.runId) && + admission !== undefined && + admission.phase !== 'interactive_started' + ) { + this.#detachRequested.add(input.runId) + return { operationId: input.operationId, outcome: 'accepted', detail: 'detached' } + } + if (handle === undefined && !this.#prepared.has(input.runId)) { + handle = (await this.#handleFor(input)) ?? undefined + } else if (handle === undefined && this.#starting.has(input.runId)) { + handle = (await this.#handleFor(input)) ?? undefined + } + this.#assertControl(input.runId, input.providerSessionId, input.controlRef, handle?.ref.run) + if (this.#detachRequested.has(input.runId)) { + return { operationId: input.operationId, outcome: 'already-applied', detail: 'detached' } + } + this.#detachRequested.add(input.runId) + this.#settleDetached(input.runId) + return { operationId: input.operationId, outcome: 'accepted', detail: 'detached' } + } + + async cancelRun( + input: CancelRunInput & { readonly signal?: AbortSignal }, + ): Promise { + if (this.#cancelledRuns.has(input.runId) && !this.#activeRuns.has(input.runId)) { + return { operationId: input.operationId, outcome: 'already-applied', detail: 'cancelled' } + } + const startInFlight = this.#starting.has(input.runId) + const controlSignal = startInFlight || input.signal?.aborted ? undefined : input.signal + const { signal: _foregroundSignal, ...recoveryInputBase } = input + const recoveryInput = { + ...recoveryInputBase, + ...(controlSignal === undefined ? {} : { signal: controlSignal }), + } + let handle = this.#handles.get(input.runId) + const admissionPhase = input.retainedAdmission?.phase + const interactiveIntent = + input.retainedAdmission?.phase === 'interactive_intent' ? input.retainedAdmission : undefined + if ( + handle === undefined && + !this.#starting.has(input.runId) && + interactiveIntent !== undefined && + input.controlRef === undefined + ) { + if ( + input.providerSessionId !== undefined && + input.providerSessionId !== interactiveIntent.sessionId + ) { + return { + operationId: input.operationId, + outcome: 'rejected', + detail: 'interactive-intent-provider-session-mismatch', + } + } + this.#cancelledRuns.add(input.runId) + this.#prepared.delete(input.runId) + this.#settleCancelled(input.runId) + return { + operationId: input.operationId, + outcome: 'accepted', + detail: 'cancelled-before-start', + } + } + if ( + handle === undefined && + this.#prepared.has(input.runId) && + !this.#starting.has(input.runId) && + input.providerSessionId === undefined && + input.controlRef === undefined && + admissionPhase !== 'interactive_started' + ) { + this.#cancelledRuns.add(input.runId) + this.#prepared.delete(input.runId) + this.#settleCancelled(input.runId) + return { + operationId: input.operationId, + outcome: 'accepted', + detail: 'cancelled-before-start', + } + } + if (handle === undefined && this.#starting.has(input.runId)) { + this.#cancelledRuns.add(input.runId) + try { + handle = (await this.#handleFor(recoveryInput)) ?? undefined + } catch { + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'interactive-start-could-not-be-cancelled', + } + } + } + if (handle === undefined) { + try { + handle = (await this.#handleFor(recoveryInput)) ?? undefined + } catch { + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'interactive-run-missing', + } + } + } + if (handle === undefined) { + return { + operationId: input.operationId, + outcome: 'unknown', + detail: 'interactive-run-missing', + } + } + this.#assertControl(input.runId, input.providerSessionId, input.controlRef) + const control = await claimRetainedInteractiveControl({ + handle, + holderId: this.#holderId, + ...(controlSignal === undefined ? {} : { signal: controlSignal }), + }) + const material = { operationId: input.operationId, ref: handle.ref, control } + const acknowledgement = await handle.stop( + { ...material, requestDigest: agentInteractiveSessionStopRequestDigest(material) }, + controlSignal === undefined ? undefined : { signal: controlSignal }, + ) + if (acknowledgement.status === 'accepted' || acknowledgement.status === 'replayed') { + this.#cancelledRuns.add(input.runId) + this.#settleCancelled(input.runId) + this.#forgetHandle(input.runId) + return { + operationId: input.operationId, + outcome: acknowledgement.status === 'replayed' ? 'already-applied' : 'accepted', + detail: acknowledgement.effect, + } + } + return { + operationId: input.operationId, + outcome: + acknowledgement.status === 'conflict' + ? 'rejected' + : acknowledgement.status === 'unknown' + ? 'unknown' + : 'rejected', + detail: acknowledgement.effect, + } + } + + async #start( + prepared: PreparedTangleRetainedConnection, + input: ExecuteTurnInput, + ): Promise { + const onAdmission = requireAdmissionRecorder(input.onRetainedAdmission) + return startRetainedInteractiveRun({ + provider: prepared.provider, + environment: interactiveEnvironment(prepared, input.runId), + interactiveIdempotencyKey: interactiveIdempotencyKey(input.runId), + ...(input.text.trim() === '' ? {} : { initialPrompt: input.text }), + ...(input.workspaceRoot === undefined ? {} : { cwd: input.workspaceRoot }), + onAdmission, + signal: input.signal, + }) + } + + async #startOnce( + prepared: PreparedTangleRetainedConnection, + input: ExecuteTurnInput, + ): Promise { + const current = this.#starting.get(input.runId) + if (current !== undefined) return current + const starting = this.#start(prepared, input) + this.#starting.set(input.runId, starting) + starting.then( + () => this.#clearStarting(input.runId, starting), + () => this.#clearStarting(input.runId, starting), + ) + return starting + } + + async #prepare(input: ExecuteTurnInput): Promise { + const current = this.#prepared.get(input.runId) + if (current !== undefined) { + if ( + current.key !== retainedExecutionKey(input, interactiveRunCapabilities(current.prepared)) + ) { + throw new Error('Interactive run admission received a different request') + } + return current.prepared + } + const prepared = await this.#resolve(input) + assertInteractiveProvider(prepared) + this.#prepared.set(input.runId, { + key: retainedExecutionKey(input, interactiveRunCapabilities(prepared)), + prepared, + }) + return prepared + } + + async #recoverHandle( + prepared: PreparedTangleRetainedConnection, + input: TangleInteractiveRecoveryInput, + admission: RetainedInteractiveAdmission, + ): Promise { + if (admission.phase === 'interactive_started') { + assertStartedAdmissionMatchesInput(admission, input) + return reconnectRetainedInteractiveRun({ + provider: prepared.provider, + ref: admission.ref, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + } + const onAdmission = requireAdmissionRecorder(input.onRetainedAdmission) + if (admission.phase === 'interactive_environment') { + return recoverRetainedInteractiveRun({ + provider: prepared.provider, + admission, + onAdmission, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + } + const receipt = requireRecoveryReceipt(input.receipt) + const profile = receipt.requested.profile + return recoverRetainedInteractiveRun({ + provider: prepared.provider, + admission, + replay: { + environment: interactiveEnvironment( + prepared, + input.runId, + admission.idempotencyKey, + profile, + ), + interactiveIdempotencyKey: admission.interactiveIdempotencyKey, + ...(receipt.requested.text.trim() === '' ? {} : { initialPrompt: receipt.requested.text }), + ...(input.workspaceRoot === undefined ? {} : { cwd: input.workspaceRoot }), + }, + onAdmission, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }) + } + + async #handleFor( + input: TangleInteractiveRecoveryInput, + ): Promise { + const admissionForInput = interactiveAdmissionOrUndefined(input.retainedAdmission) + if (admissionForInput !== undefined) { + assertInteractiveAdmissionMatchesInput(admissionForInput, input) + } + const active = this.#handles.get(input.runId) + if (active !== undefined) { + this.#assertControl(input.runId, input.providerSessionId, input.controlRef, active.ref.run) + return active + } + const starting = this.#starting.get(input.runId) + if (starting !== undefined) { + const handle = await starting + this.#rememberHandle(input.runId, handle) + this.#assertControl(input.runId, input.providerSessionId, input.controlRef, handle.ref.run) + return handle + } + const admission = requireInteractiveAdmission(input.retainedAdmission) + const prepared = await this.#resolveRecovery(input, admission) + assertInteractiveProvider(prepared) + const handle = await this.#recoverHandle(prepared, input, admission) + if (handle !== null) this.#rememberHandle(input.runId, handle) + return handle + } + + async #resolveRecovery( + input: TangleInteractiveRecoveryInput, + admission: RetainedInteractiveAdmission, + ): Promise { + const receipt = input.receipt + const profile = ( + admission.phase === 'interactive_environment' + ? admission.request.profile + : receipt?.requested.profile + ) as Readonly | undefined + if (profile === undefined) { + throw new Error('Interactive recovery requires the original run receipt') + } + const text = + admission.phase === 'interactive_environment' + ? (admission.request.initialPrompt ?? '') + : (receipt?.requested.text ?? '') + const recoveredInput: ExecuteTurnInput = { + operationId: `recover-${safeExecutionId(input.runId)}`, + runId: input.runId, + text, + profile, + mode: 'interactive', + ...(receipt?.requested.connectionId === undefined + ? {} + : { connectionId: receipt.requested.connectionId }), + ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), + signal: input.signal ?? new AbortController().signal, + } + return (this.#recover ?? this.#resolve)(recoveredInput) + } + + #rememberHandle(runId: string, handle: RetainedInteractiveRunHandle): void { + const previous = this.#handles.get(runId) + if ( + previous !== undefined && + canonicalCandidateDigest(previous.ref) !== canonicalCandidateDigest(handle.ref) + ) { + throw new Error('Interactive run recovered with a different process reference') + } + this.#handles.set(runId, handle) + } + + #clearStarting(runId: string, starting: Promise): void { + if (this.#starting.get(runId) === starting) this.#starting.delete(runId) + } + + #assertControl( + runId: string, + providerSessionId?: string, + controlRef?: AgentExactRunControlRef, + expected?: AgentExactRunControlRef, + ): void { + const actual = expected ?? this.#handles.get(runId)?.ref.run + assertExactControl( + { + ...(providerSessionId === undefined ? {} : { providerSessionId }), + ...(controlRef === undefined ? {} : { controlRef }), + }, + actual, + ) + } + + #settleDetached(runId: string): void { + if (!this.#activeRuns.has(runId)) return + switch (this.#broker.settle(runId, { kind: 'detached' })) { + case 'settled': + case 'absent': + case 'already-settled': + return + } + } + + #settleCancelled(runId: string): void { + if (!this.#activeRuns.has(runId)) return + switch (this.#broker.settle(runId, { kind: 'exited', exitSignal: 'SIGTERM' })) { + case 'settled': + case 'absent': + case 'already-settled': + return + } + } + + #forgetHandle(runId: string): void { + this.#handles.delete(runId) + this.#prepared.delete(runId) + if (!this.#activeRuns.has(runId)) this.#cancelledRuns.delete(runId) + } +} diff --git a/src/adapters/runtime/tangle-retained-interactive-projection.ts b/src/adapters/runtime/tangle-retained-interactive-projection.ts new file mode 100644 index 0000000..ae904df --- /dev/null +++ b/src/adapters/runtime/tangle-retained-interactive-projection.ts @@ -0,0 +1,154 @@ +import type { AgentInteractiveSessionStatus } from '@tangle-network/agent-interface' +import type { + RetainedInteractiveAdmission, + RetainedInteractiveRunHandle, + RetainedInteractiveStartedAdmission, +} from '@tangle-network/agent-runtime/kernel' +import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' +import type { ProviderRunSnapshot } from '../../ports/execution.js' +import type { NativeInteractiveRunOutcome } from '../../ports/native-interactive-execution.js' +import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' +import type { TangleInteractiveRecoveryInput } from './tangle-retained-interactive-contract.js' +import { interactiveSessionId } from './tangle-retained-interactive-contract.js' + +export function partialInteractiveStatus( + runId: string, + admission: Exclude, +): ProviderRunSnapshot { + const sessionId = interactiveSessionId(admission) + return sessionId === undefined + ? { runId, status: 'unknown', detail: `replayable:${admission.phase}` } + : { runId, status: 'unknown', sessionId, detail: `replayable:${admission.phase}` } +} + +export function interactiveStatusDetail(status: AgentInteractiveSessionStatus): string { + if (status.state === 'unknown') return status.message + if (!('reason' in status)) return 'interactive session running' + return !('exitCode' in status) || status.exitCode === undefined + ? `interactive session ${status.reason}` + : `interactive session ${status.reason} with code ${status.exitCode}` +} + +export function interactiveStatus( + status: AgentInteractiveSessionStatus, + detached: boolean, +): ProviderRunSnapshot['status'] { + if (detached && status.state === 'running') return 'detached' + if (status.state === 'running') return 'streaming' + if (status.state === 'exited' && status.reason !== 'lost') return 'completed' + return 'unknown' +} + +export async function terminalOutcome( + handle: RetainedInteractiveRunHandle, + signal?: AbortSignal, +): Promise | undefined> { + const status = await handle.status(signal === undefined ? undefined : { signal }) + if (status.state !== 'exited') return undefined + return { + kind: 'exited', + ...(status.exitCode === undefined ? {} : { exitCode: status.exitCode }), + ...(status.exitSignal === undefined ? {} : { exitSignal: status.exitSignal }), + } +} + +export function replayBoundary( + input: TangleInteractiveRecoveryInput, + runId: string, + handle: RetainedInteractiveRunHandle, +): number { + const cursorSequence = + input.after === undefined + ? undefined + : sequenceFromCursor(input.after, runId, handle.ref.incarnationId) + if ( + cursorSequence !== undefined && + input.afterSequence !== undefined && + cursorSequence !== input.afterSequence + ) { + throw new Error('Interactive replay cursor conflicts with the saved sequence') + } + return input.afterSequence ?? cursorSequence ?? 0 +} + +function sequenceFromCursor(cursor: string, runId: string, incarnationId: string): number { + const prefix = `${runId}:interactive:${incarnationId}:` + if (!cursor.startsWith(prefix)) { + throw new Error('Interactive replay cursor belongs to another process') + } + const sequence = Number(cursor.slice(prefix.length)) + if (!Number.isSafeInteger(sequence) || sequence < 0) { + throw new Error('Interactive replay cursor is malformed') + } + return sequence +} + +export async function observedEnvelope( + runId: string, + handle: RetainedInteractiveRunHandle, + prepared: PreparedTangleRetainedConnection, + sequence: number, +): Promise { + const observation = await prepared.observation.snapshot() + if (observation === undefined) throw new Error('Tangle interactive observation is unavailable') + const receivedAt = new Date().toISOString() + return { + runId, + eventId: `${runId}:interactive:${handle.ref.incarnationId}:observed`, + sequence, + cursor: interactiveCursor(runId, handle, sequence), + occurredAt: receivedAt, + receivedAt, + event: { + type: 'braid.execution.observed', + observation, + controlRef: handle.ref.run, + timestamp: receivedAt, + }, + } +} + +export function terminalEnvelope( + runId: string, + handle: RetainedInteractiveRunHandle, + prepared: PreparedTangleRetainedConnection, + outcome: Exclude, + sequence: number, + cancelled: boolean, +): RuntimeEventEnvelope { + const timestamp = new Date().toISOString() + const failed = outcome.kind === 'failed' + const reason = cancelled + ? 'Interactive agent stopped' + : failed + ? outcome.message + : outcome.exitCode === undefined + ? 'Interactive agent exited' + : `Interactive agent exited with code ${outcome.exitCode}` + return { + runId, + eventId: `${runId}:interactive:${handle.ref.incarnationId}:final`, + sequence, + cursor: interactiveCursor(runId, handle, sequence), + occurredAt: timestamp, + receivedAt: timestamp, + event: { + type: 'final', + task: { id: runId, intent: 'Run the retained Tangle interactive agent' }, + status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', + reason, + text: '', + metadata: { model: prepared.model, tokensKnown: false, usdKnown: false }, + ...(failed ? { error: { kind: 'backend', message: outcome.message } } : {}), + timestamp, + }, + } +} + +function interactiveCursor( + runId: string, + handle: RetainedInteractiveRunHandle, + sequence: number, +): string { + return `${runId}:interactive:${handle.ref.incarnationId}:${sequence}` +} diff --git a/src/adapters/runtime/tangle-retained-run.ts b/src/adapters/runtime/tangle-retained-run.ts index 1f43a6a..5fce744 100644 --- a/src/adapters/runtime/tangle-retained-run.ts +++ b/src/adapters/runtime/tangle-retained-run.ts @@ -8,8 +8,8 @@ import { import { publicMaterializationReceipt } from '../../domain/materialization-receipt.js' import type { RuntimeEventEnvelope } from '../../domain/runtime-events.js' import type { ExecuteTurnInput } from '../../ports/execution.js' -import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' import { safeExecutionId } from './production-backend-common.js' +import type { PreparedTangleRetainedConnection } from './production-tangle-sandbox-backend.js' import type { RetainedExecutionPlan, RetainedResultProjection, @@ -92,6 +92,12 @@ export async function startTangleRetainedRun( } // A retained create may return an existing environment for the same key. // Do not destroy it after an ambiguous dispatch failure without a provider-issued creation receipt. + const turn = { + prompt: input.text, + turnId: safeExecutionId(input.operationId), + interactions: input.interactions ?? {}, + signal: input.signal, + } return startRetainedRun({ provider: plan.provider, environment: { @@ -101,11 +107,7 @@ export async function startTangleRetainedRun( metadata: plan.prepared.environmentMetadata, idempotencyKey: plan.prepared.environmentIdempotencyKey, }, - turn: { - prompt: input.text, - turnId: safeExecutionId(input.operationId), - signal: input.signal, - }, + turn, identity: { sessionId: plan.prepared.providerSessionId, executionId: plan.executionId, diff --git a/src/adapters/runtime/tangle-sandbox-retention.ts b/src/adapters/runtime/tangle-sandbox-retention.ts index e34698b..8379c32 100644 --- a/src/adapters/runtime/tangle-sandbox-retention.ts +++ b/src/adapters/runtime/tangle-sandbox-retention.ts @@ -5,7 +5,7 @@ import { MIN_RETAINED_IDLE_TTL_SECONDS, } from '../../domain/entities-core.js' import type { SandboxLifecyclePolicy } from './prepared-execution.js' -import { safeExecutionId } from './production-backend-common.js' +import { stableProviderId } from './production-backend-common.js' import type { ObservableSandboxClient } from './sandbox-observation-types.js' export { MAX_RETAINED_IDLE_TTL_SECONDS, MIN_RETAINED_IDLE_TTL_SECONDS } @@ -22,11 +22,10 @@ export interface RetainedSandboxIdentity { } export function retainedSandboxIdentity(providerSessionId: string): RetainedSandboxIdentity { - const suffix = safeExecutionId(providerSessionId) return Object.freeze({ providerSessionId, - environmentIdempotencyKey: `env-braid-${suffix}`, - name: `braid-${suffix}`, + environmentIdempotencyKey: stableProviderId('env-braid-', providerSessionId), + name: stableProviderId('braid-', providerSessionId), metadata: Object.freeze({ owner: 'braid', lifecycle: 'retained', providerSessionId }), }) } diff --git a/src/adapters/tui/application-ui-controller.ts b/src/adapters/tui/application-ui-controller.ts index d2fe079..369a1f7 100644 --- a/src/adapters/tui/application-ui-controller.ts +++ b/src/adapters/tui/application-ui-controller.ts @@ -19,7 +19,7 @@ import { capabilityMap } from './ui-capabilities.js' import { errorResult } from './ui-dispatch-error.js' import { FIXTURE_FORK, FIXTURE_INTERACTION, type UiFixture } from './ui-fixtures.js' import { withIntelligenceResult } from './ui-intelligence-result-view.js' -import { toEvent, toHeadlessState } from './ui-projection.js' +import { interactionViews, toEvent, toHeadlessState } from './ui-projection.js' import { createUiSubscriberDelivery, type UiSubscriberDelivery } from './ui-subscriber-delivery.js' import { buildBraidViewModel, type UiAppearanceOptions } from './ui-view-model.js' @@ -91,6 +91,7 @@ export class ApplicationUiController implements BraidUiController { } #project(state: BraidState, app: BraidApplication): BraidViewModel { + const canRespond = app.canRespondToInteractions(interactionViews(state)[0]?.runId) const view = withRunUsage( buildBraidViewModel( state, @@ -99,7 +100,7 @@ export class ApplicationUiController implements BraidUiController { app.canCancel(), app.storageFailure(), app.cleanupUncertain(), - app.canRespondToInteractions(), + canRespond, this.#graphQuery, ), state, @@ -114,12 +115,7 @@ export class ApplicationUiController implements BraidUiController { ? decorated : freezeView({ ...decorated, - capabilities: capabilityMap( - state, - app.canCancel(), - this.#fixture, - app.canRespondToInteractions(), - ), + capabilities: capabilityMap(state, app.canCancel(), this.#fixture, canRespond), }) const intelligenceDecorated = this.#selectedIntelligenceData === undefined diff --git a/src/adapters/tui/native-terminal-transport.ts b/src/adapters/tui/native-terminal-transport.ts new file mode 100644 index 0000000..a5a7a2a --- /dev/null +++ b/src/adapters/tui/native-terminal-transport.ts @@ -0,0 +1,306 @@ +import type { + AgentTerminalSession, + TerminalDetachAck, + TerminalOutputEvent, +} from '@tangle-network/agent-interface' +import type { + NativeTerminalCleanup, + NativeTerminalCleanupIssue, + NativeTerminalTransport, + NativeTerminalTransportInput, + NativeTerminalTransportOutcome, + NativeTerminalTransportPhase, + NativeTerminalTransportResult, +} from '../../ports/native-terminal-transport.js' + +const DEFAULT_DETACH_CHORD = '\u001d' +const DEFAULT_CLEANUP_TIMEOUT_MS = 1_000 + +export function createNativeTerminalTransport( + input: NativeTerminalTransportInput, +): NativeTerminalTransport { + let runPromise: Promise | undefined + return Object.freeze({ + run: (options?: { readonly signal?: AbortSignal }) => { + runPromise ??= runTransport(input, options?.signal) + return runPromise + }, + }) +} + +async function runTransport( + input: NativeTerminalTransportInput, + callerSignal: AbortSignal | undefined, +): Promise { + const session = input.session + const sessionId = session.ref.terminalSessionId + const detachChord = input.detachChord ?? DEFAULT_DETACH_CHORD + const cleanupTimeoutMs = + input.cleanupTimeoutMs === undefined || !Number.isFinite(input.cleanupTimeoutMs) + ? DEFAULT_CLEANUP_TIMEOUT_MS + : Math.min(30_000, Math.max(0, Math.floor(input.cleanupTimeoutMs))) + const transportAbort = new AbortController() + let finished = false + let terminalStartAttempted = false + let outcome: NativeTerminalTransportOutcome | undefined + let signalRelease: (() => void) | undefined + let forwardingBusy = false + let forwardingTail = Promise.resolve() + let detachRequested = false + let settleOutcome: (value: NativeTerminalTransportOutcome) => void = () => {} + const outcomeReady = new Promise((resolve) => { + settleOutcome = resolve + }) + const finish = (next: NativeTerminalTransportOutcome): void => { + if (finished) return + finished = true + outcome = next + transportAbort.abort() + settleOutcome(next) + } + const enqueue = ( + phase: Extract, + operation: () => Promise, + ): void => { + const run = async (): Promise => { + if (finished) return + forwardingBusy = true + try { + await operation() + } catch (error) { + finish(transportError(sessionId, phase, error)) + } finally { + forwardingBusy = false + } + } + forwardingTail = forwardingBusy ? forwardingTail.then(run) : run() + } + const requestDetach = (): void => { + if (detachRequested || finished) return + detachRequested = true + // Finish immediately so the lifecycle aborts in-flight forwarding and skips queued operations. + finish({ kind: 'detached', sessionId, trigger: 'user' }) + } + const onInput = (data: string): void => { + if (finished) return + const chordAt = data.indexOf(detachChord) + const forwarded = chordAt < 0 ? data : data.slice(0, chordAt) + if (forwarded.length > 0) { + enqueue('input', () => session.input({ data: forwarded }, { signal: transportAbort.signal })) + } + if (chordAt >= 0) requestDetach() + } + const onResize = (): void => { + if (finished) return + const cols = input.terminal.columns + const rows = input.terminal.rows + enqueue('resize', () => session.resize({ cols, rows }, { signal: transportAbort.signal })) + } + const onSignal = (exitCode: number): void => + finish({ kind: 'aborted', sessionId, source: 'signal', exitCode }) + const onCallerAbort = (): void => finish({ kind: 'aborted', sessionId, source: 'caller' }) + const consumeEvents = async (): Promise => { + try { + for await (const event of session.events({ signal: transportAbort.signal })) { + if (finished) return + if (event.type === 'output') { + input.terminal.write(event.data) + continue + } + if (event.type === 'exit') { + finish(remoteExit(sessionId, event)) + return + } + if (event.type === 'error') { + finish(transportError(sessionId, 'events', new Error(event.message))) + return + } + } + if (!finished) { + finish( + transportError( + sessionId, + 'events', + new Error('terminal event stream ended without exit'), + ), + ) + } + } catch (error) { + if (!finished) finish(transportError(sessionId, 'events', error)) + } + } + const issues: NativeTerminalCleanupIssue[] = [] + let terminalCleanup: NativeTerminalCleanup['terminal'] = 'not-started' + let signalCleanup: NativeTerminalCleanup['signal'] = 'not-installed' + let remoteCleanup: NativeTerminalCleanup['remote'] = 'not-required' + let callerAbortCleanup: (() => void) | undefined + try { + if (callerSignal?.aborted) onCallerAbort() + else if (callerSignal !== undefined) { + callerSignal.addEventListener('abort', onCallerAbort, { once: true }) + callerAbortCleanup = () => callerSignal.removeEventListener('abort', onCallerAbort) + } + + if (!finished && input.signals !== undefined) { + try { + signalRelease = input.signals.takeOver(onSignal) + } catch (error) { + finish(transportError(sessionId, 'signal-install', error)) + } + } + if (!finished) { + terminalStartAttempted = true + try { + input.terminal.start(onInput, onResize) + } catch (error) { + finish(transportError(sessionId, 'terminal-start', error)) + } + } + if (!finished) { + void consumeEvents() + await outcomeReady + } + } catch (error) { + if (!finished) finish(transportError(sessionId, 'terminal-start', error)) + } finally { + callerAbortCleanup?.() + transportAbort.abort() + outcome ??= transportError(sessionId, 'events', new Error('transport ended without an outcome')) + if (signalRelease !== undefined) { + try { + signalRelease() + signalCleanup = 'restored' + } catch (error) { + signalCleanup = 'failed' + issues.push({ phase: 'signal-release', message: errorMessage(error) }) + } + } + if (terminalStartAttempted) { + try { + input.terminal.stop() + terminalCleanup = 'restored' + } catch (error) { + terminalCleanup = 'failed' + issues.push({ phase: 'terminal-stop', message: errorMessage(error) }) + } + } + if (shouldDetach(outcome)) { + const detach = await detachRemote(session, cleanupTimeoutMs) + remoteCleanup = detach.remote + if (detach.issue !== undefined) issues.push(detach.issue) + if (detach.remote === 'closed' && outcome.kind === 'detached') { + const closed = detach.ack?.status === 'closed' ? detach.ack : undefined + outcome = { + kind: 'remote-exit', + sessionId, + reason: 'closed-during-detach', + ...(closed?.exitCode === undefined ? {} : { exitCode: closed.exitCode }), + ...(closed?.exitSignal === undefined ? {} : { exitSignal: closed.exitSignal }), + } + } + } + } + return { + outcome, + cleanup: { + terminal: terminalCleanup, + signal: signalCleanup, + remote: remoteCleanup, + issues: Object.freeze(issues), + }, + } +} +function shouldDetach(outcome: NativeTerminalTransportOutcome): boolean { + return ( + outcome.kind === 'detached' || outcome.kind === 'aborted' || outcome.kind === 'transport-error' + ) +} + +function transportError( + sessionId: string, + phase: NativeTerminalTransportPhase, + error: unknown, +): NativeTerminalTransportOutcome { + return { kind: 'transport-error', sessionId, phase, message: errorMessage(error) } +} + +function remoteExit( + sessionId: string, + event: Extract, +): NativeTerminalTransportOutcome { + return { + kind: 'remote-exit', + sessionId, + reason: 'exited', + ...(event.exitCode === undefined ? {} : { exitCode: event.exitCode }), + ...(event.exitSignal === undefined ? {} : { exitSignal: event.exitSignal }), + } +} +async function detachRemote( + session: AgentTerminalSession, + timeoutMs: number, +): Promise<{ + readonly remote: NativeTerminalCleanup['remote'] + readonly ack?: TerminalDetachAck + readonly issue?: NativeTerminalCleanupIssue +}> { + const controller = new AbortController() + let timedOut = false + try { + const acknowledgement = await settleWithin( + session.detach({ signal: controller.signal }), + timeoutMs, + () => { + timedOut = true + controller.abort() + }, + ) + if (acknowledgement === undefined) { + return { + remote: 'unknown', + issue: { phase: 'remote-detach', message: 'remote detach did not settle before cleanup' }, + } + } + if (acknowledgement.status === 'detached') return { remote: 'detached', ack: acknowledgement } + if (acknowledgement.status === 'closed') return { remote: 'closed', ack: acknowledgement } + return { + remote: 'unknown', + ack: acknowledgement, + issue: { phase: 'remote-detach', message: acknowledgement.message }, + } + } catch (error) { + return { + remote: 'unknown', + issue: { + phase: 'remote-detach', + message: timedOut ? 'remote detach did not settle before cleanup' : errorMessage(error), + }, + } + } +} + +async function settleWithin( + promise: Promise, + timeoutMs: number, + onTimeout?: () => void, +): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timer = setTimeout(() => { + onTimeout?.() + resolve(undefined) + }, timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.length > 512 ? `${message.slice(0, 509)}...` : message +} diff --git a/src/adapters/tui/ui-capabilities.ts b/src/adapters/tui/ui-capabilities.ts index 7c71228..20f3372 100644 --- a/src/adapters/tui/ui-capabilities.ts +++ b/src/adapters/tui/ui-capabilities.ts @@ -16,6 +16,8 @@ export const UNSUPPORTED = Object.freeze({ 'analysis.compare': 'Comparisons require the current agent-eval adapter', 'interaction.respond': 'Interaction response is not exposed by the current runtime adapter', 'interaction.automation': 'Interaction automation requires the shared response contract', + 'run.interactive': 'Native terminal mode requires an interactive TUI and a supported provider', + 'run.attach': 'Native terminal attachment requires an interactive TUI and a retained session', 'export.create': 'Redacted export is not exposed by the current storage adapter', } satisfies Readonly>) diff --git a/src/adapters/tui/ui-core-dispatch.ts b/src/adapters/tui/ui-core-dispatch.ts index 16b397e..bb6b733 100644 --- a/src/adapters/tui/ui-core-dispatch.ts +++ b/src/adapters/tui/ui-core-dispatch.ts @@ -150,7 +150,7 @@ async function dispatchInteractionAutomation( intent: Extract, context: UiDispatchContext, ): Promise { - if (!context.app.canRespondToInteractions()) { + if (!context.app.canRespondToInteractions(intent.runId)) { return { kind: 'unavailable', code: 'CAPABILITY_UNAVAILABLE', @@ -225,7 +225,7 @@ async function dispatchInteractionResponse( } } - if (!context.app.canRespondToInteractions()) { + if (!context.app.canRespondToInteractions(intent.runId)) { return { kind: 'unavailable', code: 'CAPABILITY_UNAVAILABLE', diff --git a/src/adapters/tui/ui-headless-dispatch.ts b/src/adapters/tui/ui-headless-dispatch.ts index 4c69f55..ef0c8ee 100644 --- a/src/adapters/tui/ui-headless-dispatch.ts +++ b/src/adapters/tui/ui-headless-dispatch.ts @@ -177,6 +177,13 @@ export async function dispatchHeadlessCommand( retryable: false, } } + if (!context.app.canRespondToInteractions(runId)) { + return { + kind: 'unavailable', + code: 'CAPABILITY_UNAVAILABLE', + reason: 'The current runtime cannot acknowledge interaction responses', + } + } const receipt = await context.app.respondInteraction({ operationId: intent.operationId ?? '', runId, diff --git a/src/adapters/tui/ui-projection.ts b/src/adapters/tui/ui-projection.ts index 05cd4e9..64c89e8 100644 --- a/src/adapters/tui/ui-projection.ts +++ b/src/adapters/tui/ui-projection.ts @@ -199,9 +199,15 @@ export function runViews(state: BraidState): RunView[] { ...(modelSettings.reasoningEffort === undefined ? {} : { effort: sanitizeTerminalText(modelSettings.reasoningEffort) }), - ...(modelSettings.maxOutputTokens === undefined + ...(modelSettings.maxVisibleOutputTokens === undefined ? {} - : { maxOutputTokens: modelSettings.maxOutputTokens }), + : { maxVisibleOutputTokens: modelSettings.maxVisibleOutputTokens }), + ...(modelSettings.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: modelSettings.maxReasoningTokens }), + ...(modelSettings.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: modelSettings.maxTotalOutputTokens }), ...(run.error ? { error: sanitizeTerminalText(run.error) } : {}), ...(run.lastCursor ? { cursor: sanitizeTerminalText(run.lastCursor) } : {}), ...(run.providerSessionId @@ -567,6 +573,15 @@ export function toHeadlessState( ...(modelSettings.reasoningEffort === undefined ? {} : { effort: sanitizeTerminalText(modelSettings.reasoningEffort) }), + ...(modelSettings.maxVisibleOutputTokens === undefined + ? {} + : { maxVisibleOutputTokens: modelSettings.maxVisibleOutputTokens }), + ...(modelSettings.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: modelSettings.maxReasoningTokens }), + ...(modelSettings.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: modelSettings.maxTotalOutputTokens }), ...(configuration.mode === undefined ? {} : { mode: sanitizeTerminalText(configuration.mode) }), diff --git a/src/adapters/tui/ui-view-model.ts b/src/adapters/tui/ui-view-model.ts index 6ec9336..1782166 100644 --- a/src/adapters/tui/ui-view-model.ts +++ b/src/adapters/tui/ui-view-model.ts @@ -124,9 +124,15 @@ export function buildBraidViewModel( ? {} : { mode: sanitizeTerminalText(selectedBranch.overrides.mode) }), }), - ...(modelSettings.maxOutputTokens === undefined + ...(modelSettings.maxVisibleOutputTokens === undefined ? {} - : { maxOutputTokens: modelSettings.maxOutputTokens }), + : { maxVisibleOutputTokens: modelSettings.maxVisibleOutputTokens }), + ...(modelSettings.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: modelSettings.maxReasoningTokens }), + ...(modelSettings.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: modelSettings.maxTotalOutputTokens }), connection: fixture ? 'deterministic fixture' : sanitizeTerminalText(selectedConnection?.name ?? 'not connected'), diff --git a/src/app/analysis-retained-admission.ts b/src/app/analysis-retained-admission.ts index c4542c2..d5fa405 100644 --- a/src/app/analysis-retained-admission.ts +++ b/src/app/analysis-retained-admission.ts @@ -1,4 +1,4 @@ -import { canonicalDigest } from '../domain/canonical.js' +import { canonicalDigest, canonicalJson } from '../domain/canonical.js' import type { JsonValue } from '../domain/entities-base.js' import type { RetainedRunAdmissionRecord } from '../domain/run-contracts.js' import { @@ -16,30 +16,7 @@ function isJsonObject(value: unknown): value is Readonly this.respond(input), reconcilePending: () => this.reconcile(), - canRespond: () => this.canRespond(), + canRespond: (runId) => this.canRespond(runId), }) this.#coordinator = new InteractionAutomationCoordinator({ state: options.state, @@ -66,15 +66,23 @@ export class ApplicationInteractionActions { }) } - canRespond(): boolean { - return this.#options.execution.respondInteraction !== undefined + canRespond(runId?: string): boolean { + if (runId === undefined || this.#options.execution.respondInteraction === undefined) + return false + const run = this.#options.state().runs.find((candidate) => candidate.id === runId) + return run !== undefined && supportsInteractionResponse(run.receipt.capabilities) } acceptRuntimeEvent( envelope: RuntimeEventEnvelopeLike, result: RuntimeEventIngestionResult, ): void { - if (!result.accepted || envelope.event.type !== 'interaction' || !this.canRespond()) return + if ( + !result.accepted || + envelope.event.type !== 'interaction' || + !this.canRespond(envelope.runId) + ) + return const interactionId = envelope.event.request.id const target = this.#options .state() @@ -108,7 +116,7 @@ export class ApplicationInteractionActions { } reconcile(): Promise { - return this.canRespond() ? this.#coordinator.reconcile() : Promise.resolve() + return this.#coordinator.reconcile() } async whenIdle(): Promise { diff --git a/src/app/application-ports.ts b/src/app/application-ports.ts index 1807d2c..dd4a930 100644 --- a/src/app/application-ports.ts +++ b/src/app/application-ports.ts @@ -4,7 +4,11 @@ import type { RunAdmissionReceipt } from '../domain/receipts.js' import type { BraidRun, BraidState, RunStatus } from '../domain/state.js' import type { Clock } from '../ports/clock.js' import type { EffectRecord } from '../ports/effect-storage.js' -import type { ControlAcknowledgement, ExecutionPort } from '../ports/execution.js' +import type { + ControlAcknowledgement, + ExecutionPort, + RetainedExecutionRecoveryContext, +} from '../ports/execution.js' import type { IdSource } from '../ports/ids.js' import type { SendInput, SendReceipt } from './application-types.js' import type { RunExecutionSnapshot } from './run-execution-snapshot.js' @@ -157,6 +161,7 @@ export interface ControlEffectRequest { readonly reason?: string readonly text?: string readonly cursor?: string + readonly recovery?: RetainedExecutionRecoveryContext } export type RuntimeEventEnvelopeLike = import('../domain/runtime-events.js').RuntimeEventEnvelope diff --git a/src/app/application-support.ts b/src/app/application-support.ts index 5483772..30b526d 100644 --- a/src/app/application-support.ts +++ b/src/app/application-support.ts @@ -29,6 +29,7 @@ import { RUN_EFFECT_KIND, runEffectRequest } from './run-admission.js' import { executeRun } from './run-execution.js' import type { RunExecutionSnapshot } from './run-execution-snapshot.js' import type { RunLedger } from './run-ledger.js' +import { retainedExecutionRecoveryContext } from './run-recovery-context.js' export interface ApplicationJournal { readonly all: () => readonly BraidEventEnvelope[] @@ -164,6 +165,12 @@ export function restoreApplicationOperations( text: event.text, profile: event.receipt.requested.profile, connectionId: event.receipt.requested.connectionId ?? null, + ...(event.receipt.requested.mode === undefined + ? {} + : { mode: event.receipt.requested.mode }), + ...(event.receipt.requested.interactions === undefined + ? {} + : { interactions: event.receipt.requested.interactions }), }), runId: run.id, admission: event.receipt, @@ -272,6 +279,7 @@ async function reconcileRestartSnapshot(context: RestartPort, runId: string): Pr runId, ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), ...(run.controlRef === undefined ? {} : { controlRef: run.controlRef }), + ...retainedExecutionRecoveryContext(run, context.currentState().workspace), }) } catch { await markRestartReconnecting(context, runId) diff --git a/src/app/application-types.ts b/src/app/application-types.ts index 7b99972..6871c98 100644 --- a/src/app/application-types.ts +++ b/src/app/application-types.ts @@ -13,6 +13,8 @@ export type AppSubscriber = (state: BraidState, envelope: BraidEventEnvelope) => export interface SendInput { readonly operationId: string readonly text: string + /** One-run execution mode override. It does not mutate the branch selection. */ + readonly mode?: string readonly conversationId?: string readonly branchId?: string readonly sessionId?: string diff --git a/src/app/application.ts b/src/app/application.ts index af839e2..d7f921c 100644 --- a/src/app/application.ts +++ b/src/app/application.ts @@ -44,8 +44,8 @@ import { ConversationOperationCoordinator } from './conversation-operation-coord import type { ConversationActions } from './conversations.js' import { createDurableSender } from './durable-send.js' import { SerializedEffectCoordinator } from './effect-coordinator.js' -import { effectiveRunConfiguration } from './effective-run-configuration.js' import { projectEffectRecord } from './effect-projection.js' +import { effectiveRunConfiguration } from './effective-run-configuration.js' import { AppError } from './errors.js' import { FailClosedJournal } from './fail-closed-journal.js' import { createIntelligenceActions, type IntelligenceActions } from './intelligence-actions.js' @@ -393,6 +393,7 @@ export class BraidApplication { this.#state, configuration.profile, configuration.connectionId, + configuration.mode, ) validateNativeProof(this.#portViews.admission, snapshot) if (this.#asynchronousJournal || admissionIsAsync(this.#execution)) { @@ -486,8 +487,8 @@ export class BraidApplication { ) } - canRespondToInteractions(): boolean { - return this.#interactions.canRespond() + canRespondToInteractions(runId?: string): boolean { + return this.#interactions.canRespond(runId) } async detachRun(input: { diff --git a/src/app/automation-actions.ts b/src/app/automation-actions.ts index 1be34d8..48eec37 100644 --- a/src/app/automation-actions.ts +++ b/src/app/automation-actions.ts @@ -7,8 +7,8 @@ import type { AutomationRuleMatcher, AutomationRuleRecord } from '../domain/enti import type { BraidEventEnvelope } from '../domain/events.js' import type { BraidInteraction } from '../domain/runtime-projection.js' import type { BraidState } from '../domain/state.js' -import type { AutomationRuleMetadata } from './automation-matching.js' import { SerializedActionQueue } from './action-serialization.js' +import type { AutomationRuleMetadata } from './automation-matching.js' import type { ApplyAutomationReceipt, AutomationContext, @@ -103,7 +103,7 @@ export function createAutomationActions(options: { readonly automationRule?: AutomationRuleRecord }) => Promise readonly reconcilePending?: () => Promise - readonly canRespond: () => boolean + readonly canRespond: (runId?: string) => boolean }): AutomationActions { const queue = new SerializedActionQueue() const store = (): AutomationStoreInput => ({ @@ -117,6 +117,7 @@ export function createAutomationActions(options: { create: async (input) => { const receipt = await queue.run(async () => { const target = targetFor(options.state(), input.runId, input.interactionId) + assertCanRespond(options, target) const request = requestFor(target, input.request) return createAutomationRule({ ...store(), @@ -131,6 +132,7 @@ export function createAutomationActions(options: { update: async (input) => { const receipt = await queue.run(async () => { const target = targetFor(options.state(), input.runId, input.interactionId) + assertCanRespond(options, target) const request = input.request ?? target?.request return updateAutomationRule({ ...store(), @@ -148,6 +150,7 @@ export function createAutomationActions(options: { const target = targetFor(state, input.runId, input.interactionId) if (target === undefined) throw new AppError('UNKNOWN_INTERACTION', 'The interaction is no longer available') + assertCanRespond(options, target) return dryRunAutomation({ ...store(), operationId: input.operationId, @@ -157,15 +160,11 @@ export function createAutomationActions(options: { }), apply: (input) => queue.run(async () => { - if (!options.canRespond()) - throw new AppError( - 'CAPABILITY_UNAVAILABLE', - 'The current runtime cannot acknowledge interaction responses', - ) const state = options.state() const target = targetFor(state, input.runId, input.interactionId) if (target === undefined) throw new AppError('UNKNOWN_INTERACTION', 'The interaction is no longer available') + assertCanRespond(options, target) return applyAutomation({ ...store(), operationId: input.operationId, @@ -195,6 +194,17 @@ export function createAutomationActions(options: { } } +function assertCanRespond( + options: { readonly canRespond: (runId?: string) => boolean }, + target: BraidInteraction | undefined, +): void { + if (target === undefined || options.canRespond(target.runId)) return + throw new AppError( + 'CAPABILITY_UNAVAILABLE', + 'The current runtime cannot acknowledge interaction responses', + ) +} + function targetFor( state: BraidState, runId: string | undefined, diff --git a/src/app/composition.ts b/src/app/composition.ts index 3effb62..bbfbadc 100644 --- a/src/app/composition.ts +++ b/src/app/composition.ts @@ -1,5 +1,5 @@ -import type { AgentProfile } from '@tangle-network/agent-interface' import { performance } from 'node:perf_hooks' +import type { AgentProfile } from '@tangle-network/agent-interface' import { defineAgentProfile } from '../adapters/agent-interface/profile-runtime.js' import type { HeadlessKeySource } from '../adapters/credentials/headless-key.js' import { createOperatingSystemCredentialStore } from '../adapters/credentials/os.js' @@ -18,8 +18,10 @@ import { type CredentialPort, credentialRef } from '../ports/credentials.js' import type { EffectStoragePort, JournalPort } from '../ports/effect-storage.js' import type { ExecutionPort } from '../ports/execution.js' import { type IdSource, RandomIds, SequenceIds } from '../ports/ids.js' +import type { NativeInteractiveExecutionControl } from '../ports/native-interactive-execution.js' import { deterministicBackend } from '../testing/deterministic-backend.js' import { BraidApplication } from './application.js' +import type { ConnectionRegistry } from './connections.js' import type { SerializedEffectCoordinator } from './effect-coordinator.js' import { FailClosedJournal } from './fail-closed-journal.js' import type { IntelligenceActionsOptions } from './intelligence-actions.js' @@ -29,7 +31,6 @@ import { type ProductionCompositionConfig, ProductionCompositionError, } from './production-composition.js' -import type { ConnectionRegistry } from './connections.js' import { StorageJournal } from './storage-journal.js' import { UnavailableAnalyst } from './unavailable-analyst.js' @@ -97,6 +98,7 @@ export interface DurableStartupStage { export interface DurableBraidApplication { readonly app: BraidApplication readonly storage: SqliteStorage + readonly nativeInteractive?: NativeInteractiveExecutionControl } export type ProductionBraidApplicationOptions = Omit @@ -277,7 +279,13 @@ export async function createDurableBraidApplication( : { interactionResponseTimeoutMs: options.interactionResponseTimeoutMs }), }) reportStartupStage(options.startupObserver, 'application-create', stageStarted) - return { app, storage } + return { + app, + storage, + ...(production?.nativeInteractive === undefined + ? {} + : { nativeInteractive: production.nativeInteractive }), + } } catch (error) { await storage.close().catch(() => undefined) throw error diff --git a/src/app/control-effects.ts b/src/app/control-effects.ts index 16fd34e..52463b2 100644 --- a/src/app/control-effects.ts +++ b/src/app/control-effects.ts @@ -27,7 +27,7 @@ export async function executeControlEffect(input: { operationId: request.operationId, effectKind: `run.control.${request.control}`, request: { - ...request, + ...durableControlRequest(request), providerSessionId: request.providerSessionId ?? null, reason: request.reason ?? null, text: request.text ?? null, @@ -91,6 +91,7 @@ async function dispatchControl( ? {} : { providerSessionId: request.providerSessionId }), ...(request.controlRef === undefined ? {} : { controlRef: request.controlRef }), + ...(request.recovery ?? {}), signal, ...(request.reason === undefined ? {} : { reason: request.reason }), }), @@ -113,6 +114,7 @@ async function dispatchControl( ? {} : { providerSessionId: request.providerSessionId }), ...(request.controlRef === undefined ? {} : { controlRef: request.controlRef }), + ...(request.recovery ?? {}), signal, ...(request.cursor === undefined ? {} : { cursor: request.cursor }), }) @@ -134,6 +136,7 @@ async function reconcileControl( ? {} : { providerSessionId: request.providerSessionId }), ...(request.controlRef === undefined ? {} : { controlRef: request.controlRef }), + ...(request.recovery ?? {}), }) } catch { return undefined @@ -151,6 +154,13 @@ async function reconcileControl( return { status: 'terminal', detail: 'CONTROL_RECONCILED_TERMINAL' } } +function durableControlRequest( + request: ControlEffectRequest, +): Omit { + const { recovery: _recovery, ...durable } = request + return durable +} + function effectResult(value: ControlAcknowledgement) { if (value.outcome === 'accepted') return { diff --git a/src/app/conversation-import-runs.ts b/src/app/conversation-import-runs.ts index ab90459..80893bb 100644 --- a/src/app/conversation-import-runs.ts +++ b/src/app/conversation-import-runs.ts @@ -249,6 +249,7 @@ function importReceipt( branchId: input.branchId, admittedAt: canonicalDateTime(receipt.admittedAt, `${input.label}.admittedAt`), profile, + ...(typeof requested.mode === 'string' ? { mode: requested.mode } : {}), text: stringValue(requested.text, `${input.label}.requested.text`), capabilities: UNKNOWN_RUN_CAPABILITIES, warnings: ['IMPORTED_OFFLINE'], diff --git a/src/app/effective-run-configuration.ts b/src/app/effective-run-configuration.ts index b8906a0..bded418 100644 --- a/src/app/effective-run-configuration.ts +++ b/src/app/effective-run-configuration.ts @@ -37,7 +37,7 @@ export function selectedRunConfiguration( export function effectiveRunConfiguration( state: BraidState, authoredProfile: Readonly, - input: Pick, + input: Pick, ): EffectiveRunConfiguration { const conversationId = parseConversationId(input.conversationId ?? state.conversationId) const conversation = state.conversations.find( @@ -53,6 +53,7 @@ export function effectiveRunConfiguration( throw new AppError('UNKNOWN_BRANCH', 'The requested branch is unavailable') const resolved = resolveEffectiveProfile({ profile: { profile: authoredProfile }, + ...(input.mode === undefined ? {} : { nextRunOverrides: { mode: input.mode } }), branchOverrides: { ...(branch.overrides.runner === undefined ? {} : { harness: branch.overrides.runner }), ...(branch.overrides.model === undefined ? {} : { model: branch.overrides.model }), diff --git a/src/app/interaction-controller.ts b/src/app/interaction-controller.ts index 169bd80..11e6624 100644 --- a/src/app/interaction-controller.ts +++ b/src/app/interaction-controller.ts @@ -5,7 +5,7 @@ import type { AutomationRuleRecord } from '../domain/entities-runtime.js' import type { BraidEventEnvelope } from '../domain/events.js' import { createFeedbackDecisionId } from '../domain/ids.js' import { interactionRemainingMs } from '../domain/interaction-timeout.js' -import type { ExecutionPort } from '../ports/execution.js' +import { type ExecutionPort, supportsInteractionResponse } from '../ports/execution.js' import type { JournalWriter, StateReader } from './application-ports.js' import type { InteractionReceipt } from './application-types.js' import type { SerializedEffectCoordinator } from './effect-coordinator.js' @@ -24,6 +24,7 @@ import { recordedInteractionOwner, } from './interaction-response-replay.js' import type { RunLedger } from './run-ledger.js' +import { retainedExecutionRecoveryContext } from './run-recovery-context.js' import { findRun } from './run-status.js' export interface InteractionControllerInput { @@ -114,6 +115,11 @@ export async function respondInteraction( completion, } } + if (!supportsInteractionResponse(run.receipt.capabilities)) + throw new AppError( + 'CAPABILITY_UNAVAILABLE', + 'The current runtime cannot acknowledge interaction responses', + ) if (input.execution.respondInteraction === undefined) throw new AppError( 'CAPABILITY_UNAVAILABLE', @@ -154,6 +160,7 @@ export async function respondInteraction( owner: input.owner, timeoutMs: input.responseTimeoutMs, whenDurable: input.whenDurable, + recovery: retainedExecutionRecoveryContext(run, input.state.currentState().workspace), }) const completion = effectCompletion.then(async (result) => { await input.commitAndWait({ diff --git a/src/app/interaction-effects.ts b/src/app/interaction-effects.ts index 05aeca2..58bbf3b 100644 --- a/src/app/interaction-effects.ts +++ b/src/app/interaction-effects.ts @@ -1,5 +1,9 @@ import type { InteractionResponseCommand } from '@tangle-network/agent-interface' -import type { ControlAcknowledgement, ExecutionPort } from '../ports/execution.js' +import type { + ControlAcknowledgement, + ExecutionPort, + RetainedExecutionRecoveryContext, +} from '../ports/execution.js' import type { SerializedEffectCoordinator } from './effect-coordinator.js' export type InteractionEffectRequest = InteractionResponseCommand @@ -11,6 +15,7 @@ export async function executeInteractionEffect(input: { readonly owner: string readonly timeoutMs: number readonly whenDurable: () => Promise + readonly recovery?: RetainedExecutionRecoveryContext }): Promise { const { request } = input const effect = input.effects.start( @@ -27,9 +32,10 @@ export async function executeInteractionEffect(input: { }, { dispatch: async () => { - return dispatchResponse(input.execution, request, input.timeoutMs) + return dispatchResponse(input.execution, request, input.timeoutMs, input.recovery) }, - reconcile: async () => dispatchResponse(input.execution, request, input.timeoutMs), + reconcile: async () => + dispatchResponse(input.execution, request, input.timeoutMs, input.recovery), }, ) const record = await effect.completion @@ -59,6 +65,7 @@ async function dispatchResponse( execution: ExecutionPort, request: InteractionEffectRequest, timeoutMs: number, + recovery?: RetainedExecutionRecoveryContext, ): Promise<{ readonly status: 'acknowledged' | 'failed' | 'unknown' readonly detail: string @@ -68,7 +75,11 @@ async function dispatchResponse( try { const controller = new AbortController() const result = await interactionResponseDeadline( - execution.respondInteraction({ command: request, signal: controller.signal }), + execution.respondInteraction({ + command: request, + signal: controller.signal, + ...(recovery === undefined ? {} : { recovery }), + }), controller, request.operationId, timeoutMs, diff --git a/src/app/production-composition.ts b/src/app/production-composition.ts index e4ee309..d0bdc0c 100644 --- a/src/app/production-composition.ts +++ b/src/app/production-composition.ts @@ -6,6 +6,8 @@ import { type AgentTurnBackendResolver, } from '../adapters/runtime/agent-runtime-execution.js' import { CliBridgeRetainedExecutionPort } from '../adapters/runtime/cli-bridge-retained-execution.js' +import { ModeRoutingExecutionPort } from '../adapters/runtime/mode-routing-execution.js' +import { NativeInteractiveRunBroker } from '../adapters/runtime/native-interactive-run-broker.js' import { createProductionBackendResolver, type ProductionBackendResolverOptions, @@ -13,8 +15,15 @@ import { resolveProductionTangleRetainedConnection, } from '../adapters/runtime/production-backend-resolver.js' import { TangleRetainedExecutionPort } from '../adapters/runtime/tangle-retained-execution.js' +import { TangleRetainedInteractiveExecutionPort } from '../adapters/runtime/tangle-retained-interactive-execution.js' import type { ConnectionRecord } from '../domain/entities.js' -import type { ExecutionPort } from '../ports/execution.js' +import type { + ExecuteTurnInput, + ExecutionPort, + RetainedExecutionRecoveryContext, + RetainedRunAdmissionRecord, +} from '../ports/execution.js' +import type { NativeInteractiveExecutionControl } from '../ports/native-interactive-execution.js' import { ConnectionError } from './connection-errors.js' import { ConnectionRegistry } from './connections.js' import { assertValidProfile } from './profile-validation.js' @@ -58,6 +67,7 @@ export interface ProductionComposition { readonly connections: ConnectionRegistry readonly connection: ConnectionRecord readonly execution: ExecutionPort + readonly nativeInteractive?: NativeInteractiveExecutionControl readonly backendResolver: AgentTurnBackendResolver } @@ -179,24 +189,38 @@ export function createProductionComposition( }), } const backendResolver = createProductionBackendResolver(resolverOptions) - const recoveryInput = (runId: string, providerSessionId?: string) => ({ - operationId: `recover-${runId}`, - runId, - text: '', - profile, - connectionId: connection.id, - ...(providerSessionId === undefined ? {} : { sessionId: providerSessionId }), - ...(config.workspaceRoot === undefined ? {} : { workspaceRoot: config.workspaceRoot }), - signal: new AbortController().signal, - }) + const recoveryInput = ( + runId: string, + providerSessionId: string | undefined, + recovery: RetainedExecutionRecoveryContext, + signal?: AbortSignal, + ): ExecuteTurnInput => { + const requested = recovery.receipt?.requested + const admissionSessionId = retainedAdmissionSessionId(recovery.retainedAdmission) + const sessionId = providerSessionId ?? admissionSessionId + const workspaceRoot = recovery.workspaceRoot ?? config.workspaceRoot + return { + operationId: recovery.receipt?.operationId ?? `recover-${runId}`, + runId, + text: requested?.text ?? '', + profile: requested?.profile ?? profile, + ...(requested?.mode === undefined ? {} : { mode: requested.mode }), + ...(requested?.interactions === undefined ? {} : { interactions: requested.interactions }), + connectionId: requested?.connectionId ?? connection.id, + ...(sessionId === undefined ? {} : { sessionId }), + ...(workspaceRoot === undefined ? {} : { workspaceRoot }), + signal: signal ?? new AbortController().signal, + } + } + let nativeInteractive: NativeInteractiveExecutionControl | undefined const execution = (() => { if (connection.kind === 'cli-bridge') { return new CliBridgeRetainedExecutionPort({ resolve: (input) => resolveProductionCliBridgeConnection(resolverOptions, input), - recover: ({ runId, providerSessionId }) => + recover: ({ runId, providerSessionId, signal, ...recovery }) => resolveProductionCliBridgeConnection( resolverOptions, - recoveryInput(runId, providerSessionId), + recoveryInput(runId, providerSessionId, recovery, signal), ), }) } @@ -204,14 +228,22 @@ export function createProductionComposition( connection.kind === 'tangle-sandbox' && connection.providerOptions.lifecycle === 'retained' ) { - return new TangleRetainedExecutionPort({ + const headless = new TangleRetainedExecutionPort({ resolve: (input) => resolveProductionTangleRetainedConnection(resolverOptions, input), - recover: ({ runId, providerSessionId }) => + recover: ({ runId, providerSessionId, signal, ...recovery }) => resolveProductionTangleRetainedConnection( resolverOptions, - recoveryInput(runId, providerSessionId), + recoveryInput(runId, providerSessionId, recovery, signal), ), }) + const broker = new NativeInteractiveRunBroker() + const interactive = new TangleRetainedInteractiveExecutionPort({ + resolve: (input) => resolveProductionTangleRetainedConnection(resolverOptions, input), + recover: (input) => resolveProductionTangleRetainedConnection(resolverOptions, input), + broker, + }) + nativeInteractive = broker + return new ModeRoutingExecutionPort({ headless, interactive }) } return new AgentRuntimeExecutionPort(backendResolver) })() @@ -221,6 +253,26 @@ export function createProductionComposition( connections, connection, execution, + ...(nativeInteractive === undefined ? {} : { nativeInteractive }), backendResolver, }) } + +function retainedAdmissionSessionId( + admission: RetainedRunAdmissionRecord | undefined, +): string | undefined { + switch (admission?.phase) { + case 'intent': + case 'environment': + case 'interactive_intent': + return admission.sessionId + case 'dispatched': + return admission.controlRef.sessionId + case 'interactive_environment': + return admission.request.run.sessionId + case 'interactive_started': + return admission.ref.run.sessionId + default: + return undefined + } +} diff --git a/src/app/profile-model-settings.ts b/src/app/profile-model-settings.ts index 8acb457..75d5926 100644 --- a/src/app/profile-model-settings.ts +++ b/src/app/profile-model-settings.ts @@ -2,19 +2,25 @@ import type { AgentProfile } from '@tangle-network/agent-interface' export interface ProfileModelSettings { readonly reasoningEffort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number } /** Projects only public model controls that Braid can explain to a user. */ export function profileModelSettings(profile: Readonly): ProfileModelSettings { const reasoningEffort = profile.model?.reasoningEffort?.trim() - const maxTokens = profile.model?.metadata?.maxTokens - const maxOutputTokens = - typeof maxTokens === 'number' && Number.isSafeInteger(maxTokens) && maxTokens > 0 - ? maxTokens - : undefined + const maxVisibleOutputTokens = positiveLimit(profile.model?.maxVisibleOutputTokens) + const maxReasoningTokens = positiveLimit(profile.model?.maxReasoningTokens) + const maxTotalOutputTokens = positiveLimit(profile.model?.maxTotalOutputTokens) return { ...(reasoningEffort === undefined || reasoningEffort.length === 0 ? {} : { reasoningEffort }), - ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(maxVisibleOutputTokens === undefined ? {} : { maxVisibleOutputTokens }), + ...(maxReasoningTokens === undefined ? {} : { maxReasoningTokens }), + ...(maxTotalOutputTokens === undefined ? {} : { maxTotalOutputTokens }), } } + +function positiveLimit(value: number | undefined): number | undefined { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined +} diff --git a/src/app/profiles.ts b/src/app/profiles.ts index dc023cd..16b2ce1 100644 --- a/src/app/profiles.ts +++ b/src/app/profiles.ts @@ -91,7 +91,9 @@ export interface ProfileSummary { readonly runner?: string readonly model?: string readonly reasoningEffort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly tools: readonly string[] readonly skills: readonly string[] readonly connections: readonly string[] diff --git a/src/app/run-admission-dispatch.ts b/src/app/run-admission-dispatch.ts index b361599..4676cfa 100644 --- a/src/app/run-admission-dispatch.ts +++ b/src/app/run-admission-dispatch.ts @@ -43,6 +43,7 @@ export function sendRun(context: AdmissionPort, input: RunExecutionSnapshot): Se runId, text: input.text, profile: input.profile, + ...(input.mode === undefined ? {} : { mode: input.mode }), ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), signal: new AbortController().signal, @@ -128,6 +129,7 @@ export async function sendRunAsync( runId: ids.runId, text: input.text, profile: input.profile, + ...(input.mode === undefined ? {} : { mode: input.mode }), ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), signal, diff --git a/src/app/run-admission-receipt.ts b/src/app/run-admission-receipt.ts index 2eb64b4..2d6e382 100644 --- a/src/app/run-admission-receipt.ts +++ b/src/app/run-admission-receipt.ts @@ -1,14 +1,14 @@ -import { createAdmissionReceipt } from '../domain/receipts.js' import type { ContextTransferReceipt, NativeContextBoundaryProof, PortableContextPlan, RunAdmissionReceipt, } from '../domain/receipts.js' +import { createAdmissionReceipt } from '../domain/receipts.js' +import { requestedInteractionsForRun } from '../domain/run-interactions.js' import type { ExecuteTurnInput } from '../ports/execution.js' import { UNKNOWN_RUN_CAPABILITIES } from '../ports/execution.js' import type { AdmissionPort, AsyncAdmissionPort } from './application-ports.js' -import type { RunExecutionSnapshot } from './run-execution-snapshot.js' import { exactAdmissionRequestDigest } from './run-admission-request.js' import { admissionProfileDigest, @@ -20,6 +20,7 @@ import { validateExecutionContext, validateProfile, } from './run-admission-validation.js' +import type { RunExecutionSnapshot } from './run-execution-snapshot.js' export function admitRun( context: AdmissionPort, @@ -37,6 +38,7 @@ export function admitRun( const resolved = rawAdmission ?? {} validateExecutionContext(context.currentState(), input, resolved) const capabilities = resolved.capabilities ?? resolveSyncCapabilities(context, input) + const interactions = requestedInteractionsForRun(input.mode, capabilities) const receipt = createAdmissionReceipt({ runId: input.runId, turnId: turnId ?? context.ids.next('receipt'), @@ -46,6 +48,8 @@ export function admitRun( admittedAt: context.clock.now(), profile: input.profile, ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.mode === undefined ? {} : { mode: input.mode }), + interactions, text: input.text, ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), capabilities, @@ -86,6 +90,7 @@ export async function admitRunAsync( const resolved = await resolveAsyncAdmission(context, input) validateExecutionContext(context.currentState(), input, resolved) const capabilities = resolved?.capabilities ?? (await resolveAsyncCapabilities(context, input)) + const interactions = requestedInteractionsForRun(input.mode, capabilities) const receipt = createAdmissionReceipt({ runId: input.runId, turnId: turnId ?? context.ids.next('receipt'), @@ -95,6 +100,8 @@ export async function admitRunAsync( admittedAt: context.clock.now(), profile: input.profile, ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.mode === undefined ? {} : { mode: input.mode }), + interactions, text: input.text, ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }), capabilities, @@ -135,6 +142,8 @@ export function pendingAdmissionReceipt( admittedAt: new Date(0).toISOString(), profile: input.profile, ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.mode === undefined ? {} : { mode: input.mode }), + interactions: {}, text: input.text, capabilities: UNKNOWN_RUN_CAPABILITIES, admissionStatus: 'pending', diff --git a/src/app/run-admission-request.ts b/src/app/run-admission-request.ts index 84f71ff..a7e53d7 100644 --- a/src/app/run-admission-request.ts +++ b/src/app/run-admission-request.ts @@ -13,6 +13,7 @@ export function runEffectRequest(input: RunExecutionSnapshot): Readonly + readonly mode?: string readonly connectionId?: string readonly workspaceRoot?: string readonly sessionId?: string @@ -36,6 +37,7 @@ export function snapshotRunExecution( state: BraidState, profile: Readonly, connectionId: string | undefined, + mode?: string, ): RunExecutionSnapshot { const snapshot = { operationId: input.operationId, @@ -43,6 +45,7 @@ export function snapshotRunExecution( conversationId: input.conversationId ?? state.conversationId, branchId: input.branchId ?? state.branchId, profile: snapshotAgentProfile(profile), + ...(mode === undefined ? {} : { mode }), ...(connectionId === undefined ? {} : { connectionId }), ...(state.workspace === null ? {} : { workspaceRoot: state.workspace }), ...(input.sessionId === undefined diff --git a/src/app/run-execution.ts b/src/app/run-execution.ts index 59b7b16..c576271 100644 --- a/src/app/run-execution.ts +++ b/src/app/run-execution.ts @@ -22,6 +22,8 @@ export async function executeRun( runId: admission.runId, text: input.text, profile: input.profile, + ...(input.mode === undefined ? {} : { mode: input.mode }), + interactions: admission.requested.interactions ?? {}, ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }), signal: abort.signal, diff --git a/src/app/run-recovery-context.ts b/src/app/run-recovery-context.ts new file mode 100644 index 0000000..6038f88 --- /dev/null +++ b/src/app/run-recovery-context.ts @@ -0,0 +1,16 @@ +import type { RunRecord } from '../domain/entities.js' +import type { RetainedExecutionRecoveryContext } from '../ports/execution.js' + +/** Snapshot the public run material a provider needs to recover after process loss. */ +export function retainedExecutionRecoveryContext( + run: RunRecord, + workspaceRoot: string | null, +): RetainedExecutionRecoveryContext { + return { + ...(run.retainedAdmission === undefined + ? {} + : { retainedAdmission: structuredClone(run.retainedAdmission) }), + receipt: structuredClone(run.receipt), + ...(workspaceRoot === null ? {} : { workspaceRoot }), + } +} diff --git a/src/app/run-replay.ts b/src/app/run-replay.ts index 0c2fd0e..5d898c6 100644 --- a/src/app/run-replay.ts +++ b/src/app/run-replay.ts @@ -4,6 +4,7 @@ import type { ProviderRunSnapshot } from '../ports/execution.js' import type { ReconnectInput, ReplayPort } from './application-ports.js' import { AppError } from './errors.js' import { safeSnapshotDetail, safeSnapshotText, safeSnapshotUsage } from './provider-snapshot.js' +import { retainedExecutionRecoveryContext } from './run-recovery-context.js' interface RecoveryReconnectInput extends ReconnectInput { readonly priorFailureDetail?: string @@ -41,6 +42,15 @@ export async function reconnectRun( afterSequence: run.lastProviderSequence, ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), ...(run.controlRef === undefined ? {} : { controlRef: run.controlRef }), + ...retainedExecutionRecoveryContext(run, context.currentState().workspace), + onRetainedAdmission: async (admission) => { + const committed = context.commitAndWait({ + kind: 'run.retained.admitted', + runId: run.id, + admission, + }) + if (committed !== undefined) await committed + }, signal: abort.signal, })) { const result = await context.ingestRuntimeEvent(envelope) @@ -88,6 +98,7 @@ export async function reconcileRun( runId: run.id, ...(run.providerSessionId === undefined ? {} : { providerSessionId: run.providerSessionId }), ...(run.controlRef === undefined ? {} : { controlRef: run.controlRef }), + ...retainedExecutionRecoveryContext(run, context.currentState().workspace), }) } catch (error) { if (!context.isTerminal(run.status)) diff --git a/src/bin/braid-runtime.ts b/src/bin/braid-runtime.ts index 36be042..08e017e 100644 --- a/src/bin/braid-runtime.ts +++ b/src/bin/braid-runtime.ts @@ -34,6 +34,9 @@ export async function runBraid(options: CliOptions): Promise { app: opened.app, close: opened.close, ...(opened.connections === undefined ? {} : { connections: opened.connections }), + ...(opened.nativeInteractive === undefined + ? {} + : { nativeInteractive: opened.nativeInteractive }), }, } let startupPreview: StartupPreview | undefined @@ -76,6 +79,7 @@ async function openApplication( readonly app: BraidApplication readonly close: () => Promise readonly connections?: ConnectionRegistry + readonly nativeInteractive?: import('../ports/native-interactive-execution.js').NativeInteractiveExecutionControl readonly setup?: ProductionStartupSetup readonly startupOptions?: ProductionStartupLoadOptions readonly profileConnectionOptions?: ProfileConnectionDispatchOptions @@ -177,11 +181,7 @@ async function openApplication( async function openConfiguredApplication( startupOptions: ProductionStartupLoadOptions, production?: import('../app/production-composition.js').ProductionCompositionConfig, -): Promise<{ - readonly app: BraidApplication - readonly connections: ConnectionRegistry - readonly close: () => Promise -}> { +): ReturnType { const configured = production ?? (await loadProductionStartup(startupOptions)) const effectiveStartupOptions = startupOptions.databaseKeyFile === undefined && configured.databaseKeyFile !== undefined diff --git a/src/bin/interface-runner.ts b/src/bin/interface-runner.ts index c4d9bb6..9809157 100644 --- a/src/bin/interface-runner.ts +++ b/src/bin/interface-runner.ts @@ -22,6 +22,7 @@ import type { CliOptions } from './args.js' import { createInterfaceSignalLifecycle } from './interface-signal-lifecycle.js' import { recordInterfaceState } from './interface-state-recorder.js' import { runPlain } from './plain.js' +import { createNativeInteractiveUiActions } from './native-interactive-actions.js' import { activateProductionConnection, productionConfigForSelection, @@ -205,6 +206,23 @@ export async function runInterface(input: InterfaceRunnerInput): Promise }) } } + const nativeLifecycle: { + view?: BraidTerminalApp + signals?: ReturnType + } = {} + const nativeInteractive = createNativeInteractiveUiActions({ + current: () => active.current, + terminal: tui.terminal, + signals: () => { + if (nativeLifecycle.signals === undefined) + throw new Error('Terminal signal ownership is not initialized') + return nativeLifecycle.signals + }, + suspend: () => nativeLifecycle.view?.suspend(), + resume: () => nativeLifecycle.view?.resume(), + nextOperationId, + holderId: `braid-terminal-${randomUUID()}`, + }) const view = new BraidTerminalApp({ controller, tui, @@ -222,7 +240,9 @@ export async function runInterface(input: InterfaceRunnerInput): Promise ...(input.startupPreview?.outputPolicyCleanup === undefined ? {} : { preinstalledOutputPolicyCleanup: input.startupPreview.outputPolicyCleanup }), + nativeInteractive, }) + nativeLifecycle.view = view const signals = createInterfaceSignalLifecycle({ controller, view, @@ -231,6 +251,7 @@ export async function runInterface(input: InterfaceRunnerInput): Promise ...(options.recordState === undefined ? {} : { recordState: options.recordState }), ...(input.startupPreview === undefined ? {} : { startupPreview: input.startupPreview }), }) + nativeLifecycle.signals = signals try { if (!signals.interrupted()) { const startupInput = input.startupPreview?.adopt().input ?? [] diff --git a/src/bin/interface-signal-lifecycle.ts b/src/bin/interface-signal-lifecycle.ts index e0f67db..27d1816 100644 --- a/src/bin/interface-signal-lifecycle.ts +++ b/src/bin/interface-signal-lifecycle.ts @@ -1,7 +1,7 @@ import type { BraidApplication } from '../app/application.js' import type { StartupPreview } from '../startup/preview-runtime.js' -import type { BraidTerminalApp } from '../views/tui/terminal-app.js' import type { BraidUiController } from '../views/shared/intents.js' +import type { BraidTerminalApp } from '../views/tui/terminal-app.js' import { recordInterfaceState } from './interface-state-recorder.js' interface InterfaceSignalLifecycleInput { @@ -16,6 +16,8 @@ interface InterfaceSignalLifecycleInput { export interface InterfaceSignalLifecycle { readonly interrupted: () => boolean readonly settle: () => Promise + /** Temporarily route termination signals to a native terminal attachment. */ + readonly takeOver: (handler: (exitCode: number) => void) => () => void readonly dispose: () => void readonly exitCode: () => number } @@ -28,6 +30,8 @@ export function createInterfaceSignalLifecycle( let frameSnapshot = Promise.resolve() let signalCleanup: Promise | undefined let disposed = false + let signalHandler: (exitCode: number) => void + let signalTakeoverActive = false const shutdownMode = process.env.BRAID_SHUTDOWN_MODE === 'detach' ? ('detach' as const) : ('cancel' as const) const configuredDeadline = Number(process.env.BRAID_SHUTDOWN_DEADLINE_MS ?? 5_000) @@ -66,16 +70,19 @@ export function createInterfaceSignalLifecycle( } })() } + signalHandler = stopFromSignal const onFrameSnapshot = () => { if (input.recordState) frameSnapshot = frameSnapshot.then(() => recordInterfaceState(`${input.recordState}.frame`, input.controller, 'atomic-signal-frame'), ) } - const onInterrupt = () => stopFromSignal(130) - const onTerminate = () => stopFromSignal(143) - const onHangup = () => stopFromSignal(129) - const releaseTerminationSignals = input.startupPreview?.takeSignalOwnership(stopFromSignal) + const onInterrupt = () => signalHandler(130) + const onTerminate = () => signalHandler(143) + const onHangup = () => signalHandler(129) + const releaseTerminationSignals = input.startupPreview?.takeSignalOwnership((exitCode) => + signalHandler(exitCode), + ) if (releaseTerminationSignals === undefined) { process.once('SIGINT', onInterrupt) process.once('SIGTERM', onTerminate) @@ -90,6 +97,25 @@ export function createInterfaceSignalLifecycle( await signalSnapshot await frameSnapshot }, + takeOver: (handler: (exitCode: number) => void) => { + if (disposed) throw new Error('Termination signal lifecycle is closed') + if (signalTakeoverActive) throw new Error('Termination signals already have an active owner') + signalTakeoverActive = true + let pendingExitCode: number | undefined + let released = false + signalHandler = (exitCode) => { + if (pendingExitCode !== undefined) return + pendingExitCode = exitCode + handler(exitCode) + } + return () => { + if (released) return + released = true + signalTakeoverActive = false + signalHandler = stopFromSignal + if (pendingExitCode !== undefined) stopFromSignal(pendingExitCode) + } + }, dispose: () => { if (disposed) return disposed = true diff --git a/src/bin/native-interactive-actions.ts b/src/bin/native-interactive-actions.ts new file mode 100644 index 0000000..d8104a3 --- /dev/null +++ b/src/bin/native-interactive-actions.ts @@ -0,0 +1,232 @@ +import type { AgentInteractiveTerminalSession } from '@tangle-network/agent-interface' +import { claimRetainedInteractiveControl } from '@tangle-network/agent-runtime/kernel' +import { createNativeTerminalTransport } from '../adapters/tui/native-terminal-transport.js' +import type { BraidApplication } from '../app/application.js' +import type { BraidRun } from '../domain/state.js' +import type { NativeInteractiveExecutionControl } from '../ports/native-interactive-execution.js' +import type { + NativeTerminalHost, + NativeTerminalSignalPort, + NativeTerminalTransportResult, +} from '../ports/native-terminal-transport.js' +import type { + NativeInteractiveAvailability, + NativeInteractiveCommand, + NativeInteractiveCommandResult, + NativeInteractiveUiActions, +} from '../views/shared/native-interactive-actions.js' + +interface InteractiveApplicationHandle { + readonly app: Pick + readonly nativeInteractive?: NativeInteractiveExecutionControl +} + +export interface NativeInteractiveActionsOptions { + readonly current: () => InteractiveApplicationHandle + readonly terminal: NativeTerminalHost + readonly signals: () => NativeTerminalSignalPort + readonly suspend: () => void + readonly resume: () => void + readonly nextOperationId: () => string + readonly holderId: string +} + +/** Coordinates one terminal viewer. Runtime and the application retain all durable state. */ +export function createNativeInteractiveUiActions( + options: NativeInteractiveActionsOptions, +): NativeInteractiveUiActions { + let busy = false + const actions: NativeInteractiveUiActions = { + availability: (action: NativeInteractiveCommand['action']) => + availability(options.current(), action, busy), + run: async (command: NativeInteractiveCommand): Promise => { + if (busy) return { kind: 'unavailable', reason: 'A native terminal is already open' } + const available = availability(options.current(), command.action, false) + if (!available.available) { + return { kind: 'unavailable', reason: available.reason ?? 'Native terminal unavailable' } + } + busy = true + try { + return await runCommand(options, command) + } catch (error) { + return { kind: 'error', message: errorMessage(error) } + } finally { + busy = false + } + }, + } + return Object.freeze(actions) +} + +function availability( + current: InteractiveApplicationHandle, + action: NativeInteractiveCommand['action'], + busy: boolean, +): NativeInteractiveAvailability { + if (busy) return { available: false, reason: 'A native terminal is already open' } + if (current.nativeInteractive === undefined) { + return { + available: false, + reason: 'Select a retained Tangle Sandbox connection with native terminal support', + } + } + const state = current.app.state() + if (action === 'start') { + return state.activeRunId === null + ? { available: true } + : { available: false, reason: 'Detach or finish the active run first' } + } + return latestAttachableRun(state.runs) === undefined + ? { available: false, reason: 'No retained native session is available' } + : { available: true } +} + +async function runCommand( + options: NativeInteractiveActionsOptions, + command: NativeInteractiveCommand, +): Promise { + const current = options.current() + const execution = current.nativeInteractive + if (execution === undefined) { + return { kind: 'unavailable', reason: 'Native terminal support is unavailable' } + } + if (command.action === 'start') { + const prompt = command.initialPrompt?.trim() + if (!prompt) return { kind: 'error', message: 'Usage: /interactive ' } + const receipt = current.app.send({ + operationId: options.nextOperationId(), + text: prompt, + mode: 'interactive', + }) + await receipt.admissionReady + return present(options, current, execution, receipt.runId, receipt.completion) + } + + const runId = command.runId ?? latestAttachableRun(current.app.state().runs)?.id + if (runId === undefined) { + return { kind: 'unavailable', reason: 'No retained native session is available' } + } + const run = current.app.state().runs.find((candidate) => candidate.id === runId) + if (run === undefined || !isInteractiveRun(run)) { + return { kind: 'error', message: `Run ${runId} is not a retained native session` } + } + const reconnect = current.app.reconnectRun({ + operationId: options.nextOperationId(), + runId, + }) + return present(options, current, execution, runId, reconnect) +} + +async function present( + options: NativeInteractiveActionsOptions, + current: InteractiveApplicationHandle, + execution: NativeInteractiveExecutionControl, + runId: string, + runCompletion: Promise, +): Promise { + void runCompletion.catch(() => undefined) + const handle = await execution.waitForHandle(runId) + let terminalSession: AgentInteractiveTerminalSession + try { + const control = await claimRetainedInteractiveControl({ + handle, + holderId: options.holderId, + }) + terminalSession = await handle.attach({ + control, + cols: positiveDimension(options.terminal.columns), + rows: positiveDimension(options.terminal.rows), + }) + } catch (error) { + await detachAfterViewerFailure(current.app, runId, options.nextOperationId, error) + throw error + } + + options.suspend() + let result: NativeTerminalTransportResult + try { + result = await createNativeTerminalTransport({ + session: terminalSession, + terminal: options.terminal, + signals: options.signals(), + }).run() + } finally { + options.resume() + } + + const outcome = result.outcome + if (outcome.kind === 'remote-exit') { + execution.settle(runId, { + kind: 'exited', + ...(outcome.exitCode === undefined ? {} : { exitCode: outcome.exitCode }), + ...(outcome.exitSignal === undefined ? {} : { exitSignal: outcome.exitSignal }), + }) + await runCompletion + const cleanup = cleanupMessage(result) + return cleanup === undefined + ? { kind: 'returned', runId, outcome: 'exited' } + : { kind: 'error', message: cleanup } + } + + await current.app.detachRun({ operationId: options.nextOperationId(), runId }) + await runCompletion.catch(() => undefined) + if (outcome.kind === 'detached') { + const cleanup = cleanupMessage(result) + return cleanup === undefined + ? { kind: 'returned', runId, outcome: 'detached' } + : { kind: 'error', message: cleanup } + } + return { + kind: 'error', + message: + outcome.kind === 'transport-error' + ? `Native terminal ${outcome.phase} failed: ${outcome.message}` + : 'Native terminal closed after an interrupt', + } +} + +async function detachAfterViewerFailure( + app: InteractiveApplicationHandle['app'], + runId: string, + nextOperationId: () => string, + original: unknown, +): Promise { + try { + await app.detachRun({ operationId: nextOperationId(), runId }) + } catch (detachError) { + throw new Error( + `${errorMessage(original)}; the retained run could not be detached: ${errorMessage(detachError)}`, + { cause: original }, + ) + } +} + +function latestAttachableRun(runs: readonly BraidRun[]): BraidRun | undefined { + for (let index = runs.length - 1; index >= 0; index -= 1) { + const run = runs[index] + if (run !== undefined && isInteractiveRun(run) && attachableStatus(run.status)) return run + } + return undefined +} + +function isInteractiveRun(run: BraidRun): boolean { + return run.retainedAdmission?.phase.startsWith('interactive_') === true +} + +function attachableStatus(status: BraidRun['status']): boolean { + return !['completed', 'failed', 'aborted', 'cancelled', 'blocked', 'expired'].includes(status) +} + +function positiveDimension(value: number): number { + return Number.isInteger(value) && value > 0 ? value : 1 +} + +function cleanupMessage(result: NativeTerminalTransportResult): string | undefined { + if (result.cleanup.issues.length === 0) return undefined + return result.cleanup.issues.map((issue) => `${issue.phase}: ${issue.message}`).join('; ') +} + +function errorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return message.length > 512 ? `${message.slice(0, 509)}...` : message +} diff --git a/src/bin/production-application.ts b/src/bin/production-application.ts index f82256a..28afdb8 100644 --- a/src/bin/production-application.ts +++ b/src/bin/production-application.ts @@ -17,6 +17,7 @@ import { import { profileModelSettings } from '../app/profile-model-settings.js' import { canonicalDigest } from '../domain/canonical.js' import type { CredentialPort } from '../ports/credentials.js' +import type { NativeInteractiveExecutionControl } from '../ports/native-interactive-execution.js' import { createDurableBraidApplication } from '../startup/durable-runtime.js' import { createProductionCredentialContext, @@ -44,6 +45,9 @@ export function productionConfigForSelection( ...(options.credentialRefResolver === undefined ? {} : { credentialRefResolver: options.credentialRefResolver }), + ...(options.bridgeModelCredential === undefined + ? {} + : { bridgeModelCredential: options.bridgeModelCredential }), } return { profile: selection.profile.profile, @@ -104,9 +108,15 @@ async function productionIntelligence(production: ProductionCompositionConfig): const analysis = await createTraceAnalysisAdapter({ profile: selected.profile, connection: selected.connection, - ...(modelSettings.maxOutputTokens === undefined + ...(modelSettings.maxVisibleOutputTokens === undefined + ? {} + : { maxOutputTokens: modelSettings.maxVisibleOutputTokens }), + ...(modelSettings.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: modelSettings.maxReasoningTokens }), + ...(modelSettings.maxTotalOutputTokens === undefined ? {} - : { maxOutputTokens: modelSettings.maxOutputTokens }), + : { maxTotalOutputTokens: modelSettings.maxTotalOutputTokens }), ...(production.connectionOptions ?? {}), }) return { @@ -148,6 +158,7 @@ export async function openProductionApplication( ): Promise<{ readonly app: BraidApplication readonly connections: ConnectionRegistry + readonly nativeInteractive?: NativeInteractiveExecutionControl readonly close: () => Promise }> { const keyFile = options.startupOptions.databaseKeyFile ?? options.production.databaseKeyFile @@ -168,7 +179,7 @@ export async function openProductionApplication( const connections = new ConnectionRegistry(prepared.production.connections) try { const intelligence = await productionIntelligence(prepared.production) - const { app, storage } = await createDurableBraidApplication({ + const { app, storage, nativeInteractive } = await createDurableBraidApplication({ path: options.statePath, storageRoot: dirname(resolve(options.statePath)), workspaceRoot: resolve(options.workspace), @@ -191,6 +202,7 @@ export async function openProductionApplication( return { app, connections, + ...(nativeInteractive === undefined ? {} : { nativeInteractive }), close: async () => { try { await app.close() diff --git a/src/bin/production-setup-transition.ts b/src/bin/production-setup-transition.ts index 7388272..90c0678 100644 --- a/src/bin/production-setup-transition.ts +++ b/src/bin/production-setup-transition.ts @@ -1,6 +1,7 @@ import type { BraidApplication } from '../app/application.js' import type { ConnectionRegistry } from '../app/connections.js' import type { ConfigurationSelection } from '../app/configuration-session.js' +import type { NativeInteractiveExecutionControl } from '../ports/native-interactive-execution.js' import { prepareProductionSelection } from './production-setup-credentials.js' import { type ProductionStartupPersistence, @@ -16,6 +17,7 @@ import type { ProductionStartupLoadOptions } from './production-startup.js' export interface ProductionApplicationHandle { readonly app: BraidApplication readonly connections?: ConnectionRegistry + readonly nativeInteractive?: NativeInteractiveExecutionControl readonly close: () => Promise } diff --git a/src/bin/production-setup-types.ts b/src/bin/production-setup-types.ts index aec20b1..32af8a9 100644 --- a/src/bin/production-setup-types.ts +++ b/src/bin/production-setup-types.ts @@ -19,6 +19,8 @@ export interface ProductionStartupLoadOptions { readonly cliBridgeEndpoint?: string /** Optional in-memory bridge authorization; never persisted in startup config. */ readonly bridgeAuth?: string + /** Optional request-scoped model credential; key names only, never persisted in startup config. */ + readonly bridgeModelCredential?: ProductionConnectionOptions['bridgeModelCredential'] /** Optional in-memory Tangle authorization; never persisted in startup config. */ readonly tangleAuth?: string /** Discovery timeout for /health and /v1/models. */ diff --git a/src/bin/production-startup.ts b/src/bin/production-startup.ts index 5a3bc8d..8a80c02 100644 --- a/src/bin/production-startup.ts +++ b/src/bin/production-startup.ts @@ -415,6 +415,9 @@ export async function loadProductionStartup( credentialRefResolver: options.credentialRefResolver ?? defaultProductionCredentialRefResolver, }), + ...(options.bridgeModelCredential === undefined + ? {} + : { bridgeModelCredential: options.bridgeModelCredential }), } return { profile, diff --git a/src/bin/runtime-startup-options.ts b/src/bin/runtime-startup-options.ts index efbe785..7b285c9 100644 --- a/src/bin/runtime-startup-options.ts +++ b/src/bin/runtime-startup-options.ts @@ -1,3 +1,4 @@ +import { envKeyProvider } from '@tangle-network/agent-runtime/kernel' import type { CliOptions } from './args.js' import { createProductionCredentialContext, @@ -10,12 +11,24 @@ export interface RuntimeStartupOptions { readonly credentialContext?: ProductionCredentialContext } +const BRIDGE_MODEL_TOKEN_KEY = 'BRAID_CLI_BRIDGE_MODEL_TOKEN' +const BRIDGE_MODEL_BASE_URL_KEY = 'BRAID_CLI_BRIDGE_MODEL_BASE_URL' + export function createRuntimeStartupOptions( options: CliOptions, workspace: string, ): RuntimeStartupOptions { const discoveryTimeoutMs = optionalMilliseconds('BRAID_DISCOVERY_TIMEOUT_MS') const modelValidationTimeoutMs = optionalMilliseconds('BRAID_MODEL_VALIDATION_TIMEOUT_MS') + const bridgeModelToken = process.env[BRIDGE_MODEL_TOKEN_KEY]?.trim() + const bridgeModelCredential = + bridgeModelToken === undefined || bridgeModelToken.length === 0 + ? undefined + : { + key: BRIDGE_MODEL_TOKEN_KEY, + baseUrlKey: BRIDGE_MODEL_BASE_URL_KEY, + provider: envKeyProvider(), + } const base: ProductionStartupLoadOptions = { workspace, ...(options.config === undefined ? {} : { configPath: options.config }), @@ -31,6 +44,7 @@ export function createRuntimeStartupOptions( ...(process.env.BRAID_CLI_BRIDGE_AUTH === undefined ? {} : { bridgeAuth: process.env.BRAID_CLI_BRIDGE_AUTH }), + ...(bridgeModelCredential === undefined ? {} : { bridgeModelCredential }), ...(process.env.BRAID_TANGLE_AUTH === undefined ? process.env.TANGLE_INTELLIGENCE_API_KEY === undefined ? {} diff --git a/src/domain/bounded-structured.ts b/src/domain/bounded-structured.ts index df48acb..210a80d 100644 --- a/src/domain/bounded-structured.ts +++ b/src/domain/bounded-structured.ts @@ -26,6 +26,8 @@ const SAFE_NUMERIC_FIELDS = new Set([ 'maxcompletiontokens', 'maxoutputtokens', 'maxreasoningtokens', + 'maxvisibleoutputtokens', + 'maxtotaloutputtokens', 'mintokens', 'tokencount', 'tokenestimate', diff --git a/src/domain/invariants-retained-admission.ts b/src/domain/invariants-retained-admission.ts index f3689f4..aabca83 100644 --- a/src/domain/invariants-retained-admission.ts +++ b/src/domain/invariants-retained-admission.ts @@ -1,46 +1,118 @@ -import { AgentExactRunControlRefSchema } from '@tangle-network/agent-interface' -import type { RunRecord } from './entities.js' +import { + AgentExactRunControlRefSchema, + AgentInteractiveSessionRefSchema, + AgentInteractiveSessionStartSchema, +} from '@tangle-network/agent-interface' import { canonicalDigest } from './canonical.js' +import type { RunRecord } from './entities.js' import { fail, nonEmpty } from './invariants-base.js' function assertControlRef(value: unknown, label: string): void { if (!AgentExactRunControlRefSchema.safeParse(value).success) fail(`${label} is invalid`) } +function assertProviderSession(record: RunRecord, sessionId: string): void { + if (record.providerSessionId !== undefined && sessionId !== record.providerSessionId) { + fail('run.retainedAdmission session must match run.providerSessionId') + } +} + +function assertNoControlRef(record: RunRecord, phase: string): void { + if (record.controlRef !== undefined) { + fail(`run.controlRef is invalid during retained ${phase} admission`) + } +} + +function assertStoredControlRef(record: RunRecord, value: unknown): void { + assertControlRef(value, 'run.retainedAdmission.controlRef') + if (record.controlRef === undefined) fail('run.controlRef is required after retained start') + if (canonicalDigest(value) !== canonicalDigest(record.controlRef)) { + fail('run.controlRef must match run.retainedAdmission control reference') + } +} + export function assertRetainedRunAdmission(record: RunRecord): void { const admission = record.retainedAdmission if (admission === undefined) return nonEmpty(admission.idempotencyKey, 'run.retainedAdmission.idempotencyKey') - nonEmpty(admission.turnId, 'run.retainedAdmission.turnId') - if (admission.phase === 'environment') { - nonEmpty(admission.provider, 'run.retainedAdmission.provider') - nonEmpty(admission.environmentId, 'run.retainedAdmission.environmentId') - nonEmpty(admission.sessionId, 'run.retainedAdmission.sessionId') - nonEmpty(admission.executionId, 'run.retainedAdmission.executionId') - if ( - record.providerSessionId !== undefined && - admission.sessionId !== record.providerSessionId - ) { - fail('run.retainedAdmission.sessionId must match run.providerSessionId') + switch (admission.phase) { + case 'intent': + nonEmpty(admission.provider, 'run.retainedAdmission.provider') + nonEmpty(admission.turnId, 'run.retainedAdmission.turnId') + nonEmpty(admission.sessionId, 'run.retainedAdmission.sessionId') + nonEmpty(admission.executionId, 'run.retainedAdmission.executionId') + nonEmpty(admission.runId, 'run.retainedAdmission.runId') + nonEmpty(admission.requestedProfileDigest, 'run.retainedAdmission.requestedProfileDigest') + nonEmpty(admission.requestDigest, 'run.retainedAdmission.requestDigest') + assertProviderSession(record, admission.sessionId) + assertNoControlRef(record, admission.phase) + return + case 'environment': + nonEmpty(admission.provider, 'run.retainedAdmission.provider') + nonEmpty(admission.environmentId, 'run.retainedAdmission.environmentId') + nonEmpty(admission.turnId, 'run.retainedAdmission.turnId') + nonEmpty(admission.sessionId, 'run.retainedAdmission.sessionId') + nonEmpty(admission.executionId, 'run.retainedAdmission.executionId') + assertProviderSession(record, admission.sessionId) + if ( + record.controlRef !== undefined && + (admission.provider !== record.controlRef.provider || + admission.environmentId !== record.controlRef.environmentId || + admission.sessionId !== record.controlRef.sessionId || + admission.executionId !== record.controlRef.executionId) + ) { + fail('run.controlRef must match run.retainedAdmission recovery coordinates') + } + return + case 'dispatched': + nonEmpty(admission.turnId, 'run.retainedAdmission.turnId') + assertProviderSession(record, admission.controlRef.sessionId) + assertStoredControlRef(record, admission.controlRef) + return + case 'interactive_intent': + nonEmpty(admission.provider, 'run.retainedAdmission.provider') + nonEmpty( + admission.interactiveIdempotencyKey, + 'run.retainedAdmission.interactiveIdempotencyKey', + ) + nonEmpty(admission.sessionId, 'run.retainedAdmission.sessionId') + nonEmpty(admission.executionId, 'run.retainedAdmission.executionId') + nonEmpty(admission.runId, 'run.retainedAdmission.runId') + nonEmpty(admission.requestedProfileDigest, 'run.retainedAdmission.requestedProfileDigest') + nonEmpty(admission.requestDigest, 'run.retainedAdmission.requestDigest') + assertProviderSession(record, admission.sessionId) + assertNoControlRef(record, admission.phase) + return + case 'interactive_environment': { + nonEmpty(admission.provider, 'run.retainedAdmission.provider') + nonEmpty(admission.environmentId, 'run.retainedAdmission.environmentId') + nonEmpty( + admission.interactiveIdempotencyKey, + 'run.retainedAdmission.interactiveIdempotencyKey', + ) + const request = AgentInteractiveSessionStartSchema.safeParse(admission.request) + if (!request.success) fail('run.retainedAdmission.request is invalid') + if ( + request.data.run.provider !== admission.provider || + request.data.run.environmentId !== admission.environmentId + ) { + fail('run.retainedAdmission.request must match its environment') + } + assertProviderSession(record, request.data.run.sessionId) + assertNoControlRef(record, admission.phase) + return } - if ( - record.controlRef !== undefined && - (admission.provider !== record.controlRef.provider || - admission.environmentId !== record.controlRef.environmentId || - admission.sessionId !== record.controlRef.sessionId || - admission.executionId !== record.controlRef.executionId) - ) { - fail('run.controlRef must match run.retainedAdmission recovery coordinates') + case 'interactive_started': { + nonEmpty( + admission.interactiveIdempotencyKey, + 'run.retainedAdmission.interactiveIdempotencyKey', + ) + const ref = AgentInteractiveSessionRefSchema.safeParse(admission.ref) + if (!ref.success) fail('run.retainedAdmission.ref is invalid') + assertProviderSession(record, ref.data.run.sessionId) + assertStoredControlRef(record, ref.data.run) + return } - return - } - - assertControlRef(admission.controlRef, 'run.retainedAdmission.controlRef') - if (record.controlRef === undefined) { - fail('run.controlRef is required after retained dispatch admission') - } - if (canonicalDigest(admission.controlRef) !== canonicalDigest(record.controlRef)) { - fail('run.controlRef must match run.retainedAdmission.controlRef') } } diff --git a/src/domain/receipts.ts b/src/domain/receipts.ts index 84e0bc3..54c4633 100644 --- a/src/domain/receipts.ts +++ b/src/domain/receipts.ts @@ -12,6 +12,7 @@ import type { PortableAnalysisAttachment, PortableContextMessage, PortableContextPlan, + RequestedInteractions, RunAdmissionReceipt, RunCapabilities, } from './run-contracts.js' @@ -23,6 +24,7 @@ export type { PortableContextMessage, PortableContextPart, PortableContextPlan, + RequestedInteractions, RunAdmissionReceipt, RunCapabilities, RunControlCapabilities, @@ -120,6 +122,8 @@ export function createAdmissionReceipt(input: { readonly admittedAt: string readonly profile: Readonly readonly connectionId?: string + readonly mode?: string + readonly interactions?: RequestedInteractions readonly text: string readonly sessionId?: string readonly capabilities: RunCapabilities @@ -153,6 +157,8 @@ export function createAdmissionReceipt(input: { text, profile: structuredClone(profile), ...(input.connectionId === undefined ? {} : { connectionId: input.connectionId }), + ...(input.mode === undefined ? {} : { mode: input.mode }), + ...(input.interactions === undefined ? {} : { interactions: input.interactions }), ...(profile.model?.default === undefined ? {} : { model: profile.model.default }), ...(profile.harness === undefined ? {} : { runner: profile.harness }), ...(contextPlanDigest === undefined ? {} : { contextPlanDigest }), @@ -166,6 +172,8 @@ export function createAdmissionReceipt(input: { text, profileDigest, connectionId: input.connectionId ?? null, + mode: input.mode ?? null, + interactions: input.interactions ?? {}, contextPlanDigest: contextPlanDigest ?? null, }) const capabilitiesDigest = canonicalDigest(capabilities) diff --git a/src/domain/redaction.ts b/src/domain/redaction.ts index ef2996a..7126c9c 100644 --- a/src/domain/redaction.ts +++ b/src/domain/redaction.ts @@ -1,6 +1,5 @@ import type { AgentProfile } from '@tangle-network/agent-interface' import { - redactStructuredValue, redactStructuredValueWithNumericTelemetry, STRUCTURED_REDACTION_MARKER, } from './bounded-structured.js' @@ -19,7 +18,7 @@ export { redactSensitiveText, redactSensitiveUrls } from './secret-sanitizer.js' export function redactProfile(profile: Readonly): Readonly { const modelMetadata = publicModelMetadata(profile.model?.metadata) return removeProfileMetadata( - redactStructuredValue(profile, undefined, { maxBytes: MAX_PROFILE_BYTES }), + redactStructuredValueWithNumericTelemetry(profile, undefined, { maxBytes: MAX_PROFILE_BYTES }), [], modelMetadata, ) as Readonly diff --git a/src/domain/reducer-retained-admission.ts b/src/domain/reducer-retained-admission.ts index c65e915..d98d0cf 100644 --- a/src/domain/reducer-retained-admission.ts +++ b/src/domain/reducer-retained-admission.ts @@ -1,3 +1,4 @@ +import { agentInteractiveSessionRefMatchesStart } from '@tangle-network/agent-interface' import { canonicalDigest } from './canonical.js' import type { BraidEvent } from './events.js' import { DomainInvariantError } from './invariants.js' @@ -6,11 +7,58 @@ import type { BraidState } from './state.js' type RetainedAdmissionEvent = Extract type Admission = RetainedAdmissionEvent['admission'] - function sameAdmission(left: Admission, right: Admission): boolean { return canonicalDigest(left) === canonicalDigest(right) } +function admissionFamily(admission: Admission): 'headless' | 'interactive' { + return admission.phase.startsWith('interactive_') ? 'interactive' : 'headless' +} + +function admissionSessionId(admission: Admission): string { + switch (admission.phase) { + case 'intent': + case 'environment': + case 'interactive_intent': + return admission.sessionId + case 'dispatched': + return admission.controlRef.sessionId + case 'interactive_environment': + return admission.request.run.sessionId + case 'interactive_started': + return admission.ref.run.sessionId + } +} + +function admissionRank(admission: Admission): number { + switch (admission.phase) { + case 'intent': + case 'interactive_intent': + return 0 + case 'environment': + case 'interactive_environment': + return 1 + case 'dispatched': + case 'interactive_started': + return 2 + } +} + +function assertIntentMatchesEnvironment( + intent: Extract, + environment: Extract, +): void { + if ( + intent.provider !== environment.provider || + intent.idempotencyKey !== environment.idempotencyKey || + intent.turnId !== environment.turnId || + intent.sessionId !== environment.sessionId || + intent.executionId !== environment.executionId + ) { + throw new DomainInvariantError('Retained environment conflicts with its intent admission') + } +} + function assertEnvironmentMatchesDispatch( environment: Extract, dispatched: Extract, @@ -28,6 +76,85 @@ function assertEnvironmentMatchesDispatch( } } +function assertInteractiveIntentMatchesEnvironment( + intent: Extract, + environment: Extract, +): void { + const exact = environment.request.run + if ( + intent.provider !== environment.provider || + intent.provider !== exact.provider || + environment.environmentId !== exact.environmentId || + intent.idempotencyKey !== environment.idempotencyKey || + intent.interactiveIdempotencyKey !== environment.interactiveIdempotencyKey || + intent.sessionId !== exact.sessionId || + intent.executionId !== exact.executionId || + intent.requestedProfileDigest !== environment.request.requestedProfileDigest + ) { + throw new DomainInvariantError( + 'Retained interactive environment conflicts with its intent admission', + ) + } +} + +function assertInteractiveEnvironmentMatchesStarted( + environment: Extract, + started: Extract, +): void { + if ( + environment.idempotencyKey !== started.idempotencyKey || + environment.interactiveIdempotencyKey !== started.interactiveIdempotencyKey || + !agentInteractiveSessionRefMatchesStart(environment.request, started.ref) + ) { + throw new DomainInvariantError( + 'Retained interactive process conflicts with its environment admission', + ) + } +} + +function assertAdjacentAdmissions(first: Admission, second: Admission): void { + if (first.phase === 'intent' && second.phase === 'environment') { + assertIntentMatchesEnvironment(first, second) + return + } + if (first.phase === 'environment' && second.phase === 'dispatched') { + assertEnvironmentMatchesDispatch(first, second) + return + } + if (first.phase === 'interactive_intent' && second.phase === 'interactive_environment') { + assertInteractiveIntentMatchesEnvironment(first, second) + return + } + if (first.phase === 'interactive_environment' && second.phase === 'interactive_started') { + assertInteractiveEnvironmentMatchesStarted(first, second) + return + } + throw new DomainInvariantError('Retained admission phases do not form one recovery sequence') +} + +function assertTransition(current: Admission, next: Admission): 'advance' | 'replay' { + if (admissionFamily(current) !== admissionFamily(next)) { + throw new DomainInvariantError('Retained admission changed execution mode after persistence') + } + const currentRank = admissionRank(current) + const nextRank = admissionRank(next) + if (currentRank === nextRank) { + if (!sameAdmission(current, next)) { + throw new DomainInvariantError('Retained admission changed after persistence') + } + return 'replay' + } + if (nextRank === currentRank + 1) { + assertAdjacentAdmissions(current, next) + return 'advance' + } + if (nextRank === currentRank - 1) { + assertAdjacentAdmissions(next, current) + return 'replay' + } + throw new DomainInvariantError('Retained admission skipped a required durable phase') +} + export function applyRetainedAdmission( state: BraidState, event: RetainedAdmissionEvent, @@ -36,39 +163,39 @@ export function applyRetainedAdmission( const run = find(state.runs, event.runId, 'Run') const current = run.retainedAdmission const next = structuredClone(event.admission) + const sessionId = admissionSessionId(next) - if (run.providerSessionId !== undefined) { - const sessionId = next.phase === 'environment' ? next.sessionId : next.controlRef.sessionId - if (sessionId !== run.providerSessionId) { - throw new DomainInvariantError( - `Retained admission for ${run.id} conflicts with its provider session`, - ) - } + if (run.providerSessionId !== undefined && sessionId !== run.providerSessionId) { + throw new DomainInvariantError( + `Retained admission for ${run.id} conflicts with its provider session`, + ) } - if (current?.phase === 'environment' && next.phase === 'dispatched') { - assertEnvironmentMatchesDispatch(current, next) - } else if (current?.phase === 'dispatched' && next.phase === 'environment') { - assertEnvironmentMatchesDispatch(next, current) - return state - } else if (current !== undefined) { - if (!sameAdmission(current, next)) { - throw new DomainInvariantError(`Retained admission for ${run.id} changed after persistence`) - } - return state - } else if (next.phase === 'dispatched') { + if (current !== undefined && assertTransition(current, next) === 'replay') return state + if ( + current === undefined && + (next.phase === 'dispatched' || + next.phase === 'interactive_environment' || + next.phase === 'interactive_started') + ) { throw new DomainInvariantError( - `Retained dispatch for ${run.id} has no durable environment admission`, + `Retained ${next.phase} admission for ${run.id} has no prior durable admission`, ) } + const controlRef = + next.phase === 'dispatched' + ? next.controlRef + : next.phase === 'interactive_started' + ? next.ref.run + : undefined return { ...state, runs: upsert(state.runs, { ...run, - providerSessionId: next.phase === 'environment' ? next.sessionId : next.controlRef.sessionId, + providerSessionId: sessionId, retainedAdmission: next, - ...(next.phase === 'dispatched' ? { controlRef: structuredClone(next.controlRef) } : {}), + ...(controlRef === undefined ? {} : { controlRef: structuredClone(controlRef) }), updatedAt: at, }), } diff --git a/src/domain/run-contracts.ts b/src/domain/run-contracts.ts index eb30fb7..488600e 100644 --- a/src/domain/run-contracts.ts +++ b/src/domain/run-contracts.ts @@ -1,30 +1,17 @@ import type { AgentEnvironmentCapabilities, - AgentExactRunControlRef, AgentProfile, + RequestedInteractions, } from '@tangle-network/agent-interface' +import type { + RetainedInteractiveAdmission, + RetainedRunAdmission, +} from '@tangle-network/agent-runtime/kernel' -export interface RetainedRunEnvironmentAdmissionRecord { - readonly phase: 'environment' - readonly provider: string - readonly environmentId: string - readonly idempotencyKey: string - readonly turnId: string - readonly sessionId: string - readonly executionId: string -} - -export interface RetainedRunDispatchedAdmissionRecord { - readonly phase: 'dispatched' - readonly controlRef: AgentExactRunControlRef - readonly idempotencyKey: string - readonly turnId: string -} +export type { RequestedInteractions } from '@tangle-network/agent-interface' -/** Product-owned recovery data that Runtime requires Braid to persist before proceeding. */ -export type RetainedRunAdmissionRecord = - | RetainedRunEnvironmentAdmissionRecord - | RetainedRunDispatchedAdmissionRecord +/** Canonical Runtime recovery data that Braid persists before Runtime may proceed. */ +export type RetainedRunAdmissionRecord = RetainedRunAdmission | RetainedInteractiveAdmission export type RetainedRunAdmissionRecorder = (admission: RetainedRunAdmissionRecord) => Promise @@ -152,6 +139,8 @@ export interface RunAdmissionReceipt { readonly text: string readonly profile: Readonly readonly connectionId?: string + readonly mode?: string + readonly interactions?: RequestedInteractions readonly model?: string readonly runner?: string readonly contextPlanDigest?: string diff --git a/src/domain/run-interactions.ts b/src/domain/run-interactions.ts new file mode 100644 index 0000000..e94e4df --- /dev/null +++ b/src/domain/run-interactions.ts @@ -0,0 +1,17 @@ +import type { RequestedInteractions, RunCapabilities } from './run-contracts.js' + +/** Select the interaction posture for one admitted turn from provider capabilities. */ +export function requestedInteractionsForRun( + mode: string | undefined, + capabilities: Pick, +): RequestedInteractions { + const advertised = capabilities.environment?.interactions + if (advertised?.responseIdempotency !== true) return Object.freeze({}) + + const requested: RequestedInteractions = { + ...(advertised.kinds.includes('permission') ? { permission: true } : {}), + ...(advertised.kinds.includes('question') ? { question: true } : {}), + ...(mode === 'plan' && advertised.kinds.includes('plan') ? { plan: true } : {}), + } + return Object.freeze(requested) +} diff --git a/src/index.ts b/src/index.ts index 7369d09..57da55f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,34 @@ +export { + closeHeadlessKeyFile, + openHeadlessKeyFile, + readHeadlessKey, + rejectEnvironmentKeySource, +} from './adapters/credentials/headless-key.js' +export { MemoryCredentialStore } from './adapters/credentials/memory.js' +export { + createOperatingSystemCredentialStore, + LinuxSecretServiceCredentialStore, + MacOsKeychainCredentialStore, + WindowsCredentialManagerStore, +} from './adapters/credentials/os.js' export { AgentRuntimeExecutionPort, type AgentTurnBackendResolver, } from './adapters/runtime/agent-runtime-execution.js' +export { MemoryStorage } from './adapters/storage/memory.js' +export type { DurableBoundaryHook, SqliteStorageOptions } from './adapters/storage/sqlite.js' +export { + openSqliteStorage, + SqliteStorage, +} from './adapters/storage/sqlite.js' +export { SQLITE_DRIVER_PACKAGE, SQLITE_DRIVER_VERSION } from './adapters/storage/sqlite-driver.js' +export { StorageError } from './adapters/storage/sqlite-errors.js' +export { + ApplicationUiController, + buildBraidViewModel, + createApplicationUiController, +} from './adapters/tui/application-ui-controller.js' +export { createNativeTerminalTransport } from './adapters/tui/native-terminal-transport.js' export { AppError, BraidApplication, @@ -9,56 +36,46 @@ export { type SendReceipt, } from './app/application.js' export { + type CompositionOptions, createBraidApplication, createDurableBraidApplication, - type CompositionOptions, type DurableBraidApplication, type DurableCompositionOptions, STARTER_PROFILE, } from './app/composition.js' -export { MemoryJournal } from './app/journal.js' export { + type EffectContext, EffectCoordinator, EffectCoordinatorError, - effectRequestDigest, - SerializedEffectCoordinator, - type EffectContext, type EffectDispatchResult, type EffectHandle, type EffectHandler, type EffectIntent, + effectRequestDigest, + SerializedEffectCoordinator, } from './app/effect-coordinator.js' -export type { - BraidEvent, - BraidEventEnvelope, - DomainBraidEventMap, - JournalEventEnvelope, - LegacyBraidEvent, - TurnUsage, -} from './domain/events.js' -export { - DuplicateEventConflictError, - initialDomainState, - reduceEvent, - replayEvents, - replayJournal, - SequenceGapError, -} from './domain/reducer.js' +export { FailClosedJournal } from './app/fail-closed-journal.js' +export { MemoryJournal } from './app/journal.js' +export { StorageJournal } from './app/storage-journal.js' export { canonicalDigest, canonicalJson, } from './domain/canonical.js' -export { canonicalProjectionChecksum } from './domain/projection-checksum.js' -export type { - BraidMessage, - BraidRun, - BraidState, -} from './domain/state.js' export type * from './domain/entities.js' export type { EffectRecord as DomainEffectRecord, OperationRecord as DomainOperationRecord, } from './domain/entities.js' +export type { + BraidEvent, + BraidEventEnvelope, + DomainBraidEventMap, + JournalEventEnvelope, + LegacyBraidEvent, + ProviderEventMeta, + RunTerminalStatus, + TurnUsage, +} from './domain/events.js' export * from './domain/ids.js' export { assertBraidState, @@ -66,73 +83,25 @@ export { assertJsonValue, DomainInvariantError, } from './domain/invariants.js' -export type { - EffectOutcomeStatus, - EffectRecord, - EffectStatus, - EffectStoragePort, - JournalPort, -} from './ports/effect-storage.js' -export type { - CredentialPort, - CredentialRef, - CredentialStoreInput, - SecretHandle, -} from './ports/credentials.js' -export { CredentialError, credentialRef } from './ports/credentials.js' -export type { - AppendResult, - BackupReport, - DestructionReport, - IntegrityReport, - JournalEvent, - MigrationReport, - MissingHistory, - OperationIntent, - OperationRecord, - ProjectionSnapshot, - RedactionReport, - ReplayResult, - RetentionReport, - StoragePort, - StorageArtifacts, - StoredJournalEvent, - RestoreReport, -} from './ports/storage.js' -export { MemoryStorage } from './adapters/storage/memory.js' -export { - SqliteStorage, - openSqliteStorage, -} from './adapters/storage/sqlite.js' -export type { DurableBoundaryHook, SqliteStorageOptions } from './adapters/storage/sqlite.js' -export { SQLITE_DRIVER_PACKAGE, SQLITE_DRIVER_VERSION } from './adapters/storage/sqlite-driver.js' -export { StorageError } from './adapters/storage/sqlite-errors.js' -export { MemoryCredentialStore } from './adapters/credentials/memory.js' -export { - createOperatingSystemCredentialStore, - LinuxSecretServiceCredentialStore, - MacOsKeychainCredentialStore, - WindowsCredentialManagerStore, -} from './adapters/credentials/os.js' -export { - closeHeadlessKeyFile, - openHeadlessKeyFile, - readHeadlessKey, - rejectEnvironmentKeySource, -} from './adapters/credentials/headless-key.js' -export type { - ProviderEventMeta, - RunTerminalStatus, -} from './domain/events.js' +export { canonicalProjectionChecksum } from './domain/projection-checksum.js' export { type ContextTransferReceipt, createAdmissionReceipt, createPortableContextPlan, type NativeContextBoundaryProof, type PortableContextPlan, + type RequestedInteractions, type RunAdmissionReceipt, type RunCapabilities, } from './domain/receipts.js' +export { + DuplicateEventConflictError, + initialDomainState, + reduceEvent, + replayEvents, + replayJournal, + SequenceGapError, +} from './domain/reducer.js' export type { BraidRuntimeEvent, RuntimeEventEnvelope, @@ -141,9 +110,26 @@ export type { export type { BraidActivity, BraidInteraction, + BraidMessage, BraidMessagePart, + BraidRun, + BraidState, QueuedInput, } from './domain/state.js' +export type { + CredentialPort, + CredentialRef, + CredentialStoreInput, + SecretHandle, +} from './ports/credentials.js' +export { CredentialError, credentialRef } from './ports/credentials.js' +export type { + EffectOutcomeStatus, + EffectRecord, + EffectStatus, + EffectStoragePort, + JournalPort, +} from './ports/effect-storage.js' export type { ControlAcknowledgement, ExecuteTurnInput, @@ -156,18 +142,46 @@ export { DEFAULT_RUN_CAPABILITIES, UNKNOWN_RUN_CAPABILITIES, } from './ports/execution.js' +export type { + NativeInteractiveExecutionControl, + NativeInteractiveRunOutcome, +} from './ports/native-interactive-execution.js' +export type { + NativeTerminalCleanup, + NativeTerminalCleanupIssue, + NativeTerminalCleanupPhase, + NativeTerminalHost, + NativeTerminalSignalPort, + NativeTerminalTransport, + NativeTerminalTransportInput, + NativeTerminalTransportOutcome, + NativeTerminalTransportPhase, + NativeTerminalTransportResult, +} from './ports/native-terminal-transport.js' +export type { + AppendResult, + BackupReport, + DestructionReport, + IntegrityReport, + JournalEvent, + MigrationReport, + MissingHistory, + OperationIntent, + OperationRecord, + ProjectionSnapshot, + RedactionReport, + ReplayResult, + RestoreReport, + RetentionReport, + StorageArtifacts, + StoragePort, + StoredJournalEvent, +} from './ports/storage.js' export { BRAID_PROTOCOL_VERSION, type BraidRequest, type BraidResponse, } from './views/headless/protocol.js' -export { StorageJournal } from './app/storage-journal.js' -export { FailClosedJournal } from './app/fail-closed-journal.js' -export { - createApplicationUiController, - ApplicationUiController, - buildBraidViewModel, -} from './adapters/tui/application-ui-controller.js' export { COMMAND_DEFINITIONS, COMMAND_NAMES, @@ -193,6 +207,12 @@ export type { InteractionView, ViewStatus, } from './views/shared/models.js' +export type { + NativeInteractiveAvailability, + NativeInteractiveCommand, + NativeInteractiveCommandResult, + NativeInteractiveUiActions, +} from './views/shared/native-interactive-actions.js' export { sanitizeClipboardText, sanitizeDiff, diff --git a/src/ports/execution.ts b/src/ports/execution.ts index 2280d36..ecfd60f 100644 --- a/src/ports/execution.ts +++ b/src/ports/execution.ts @@ -1,25 +1,31 @@ import type { - AgentExactRunControlRef, AgentEnvironmentCapabilities, + AgentExactRunControlRef, AgentProfile, InteractionResponseCommand, } from '@tangle-network/agent-interface' +import type { TurnUsage } from '../domain/entities.js' +import type { RunAdmissionReceipt } from '../domain/receipts.js' import type { + RequestedInteractions, RetainedRunAdmissionRecord, RetainedRunAdmissionRecorder, RunCapabilities, } from '../domain/run-contracts.js' import type { BraidRuntimeEvent, RuntimeEventEnvelope } from '../domain/runtime-events.js' -import type { TurnUsage } from '../domain/entities.js' import type { RunStatus } from '../domain/state.js' -export type { RunCapabilities } from '../domain/receipts.js' +export type { RequestedInteractions, RunCapabilities } from '../domain/receipts.js' export interface ExecuteTurnInput { readonly operationId: string readonly runId: string readonly text: string readonly profile: Readonly + /** Selected branch mode captured before admission. */ + readonly mode?: string + /** Exact per-turn interaction posture derived from admitted capabilities. */ + readonly interactions?: RequestedInteractions readonly connectionId?: string readonly workspaceRoot?: string readonly signal: AbortSignal @@ -56,13 +62,20 @@ export interface ProviderRunSnapshot { readonly detail?: string } +/** Durable public data needed to reconstruct one retained execution after restart. */ +export interface RetainedExecutionRecoveryContext { + readonly retainedAdmission?: RetainedRunAdmissionRecord + readonly receipt?: RunAdmissionReceipt + readonly workspaceRoot?: string +} + export interface ControlAcknowledgement { readonly operationId: string readonly outcome: 'accepted' | 'already-applied' | 'rejected' | 'unknown' readonly detail?: string } -export interface CancelRunInput { +export interface CancelRunInput extends RetainedExecutionRecoveryContext { readonly operationId: string readonly runId: string readonly providerSessionId?: string @@ -90,38 +103,46 @@ export interface ExecutionPort { cancelRun?( input: CancelRunInput & { readonly reason?: string; readonly signal?: AbortSignal }, ): Promise - detachRun?(input: { - readonly runId: string - readonly operationId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly cursor?: string - readonly signal?: AbortSignal - }): Promise + detachRun?( + input: { + readonly runId: string + readonly operationId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly cursor?: string + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ): Promise steerRun?(input: { readonly runId: string readonly operationId: string readonly text: string readonly signal?: AbortSignal }): Promise - status?(input: { - readonly runId: string - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly signal?: AbortSignal - }): Promise + status?( + input: { + readonly runId: string + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal?: AbortSignal + } & RetainedExecutionRecoveryContext, + ): Promise respondInteraction?(input: { readonly command: InteractionResponseCommand readonly signal?: AbortSignal + readonly recovery?: RetainedExecutionRecoveryContext }): Promise - reconnect?(input: { - readonly runId: string - readonly after?: string - readonly afterSequence?: number - readonly providerSessionId?: string - readonly controlRef?: AgentExactRunControlRef - readonly signal: AbortSignal - }): AsyncIterable + reconnect?( + input: { + readonly runId: string + readonly after?: string + readonly afterSequence?: number + readonly providerSessionId?: string + readonly controlRef?: AgentExactRunControlRef + readonly signal: AbortSignal + readonly onRetainedAdmission?: RetainedRunAdmissionRecorder + } & RetainedExecutionRecoveryContext, + ): AsyncIterable nativeBoundary?(input: { readonly runId: string readonly sessionId: string @@ -150,6 +171,11 @@ export const UNKNOWN_RUN_CAPABILITIES: RunCapabilities = Object.freeze({ usage: false, }) +/** Treat a published environment capability document as authoritative. */ +export function supportsInteractionResponse(capabilities: RunCapabilities): boolean { + return capabilities.environment?.interactions?.responseIdempotency === true +} + export function capabilitiesFromEnvironment( capabilities: AgentEnvironmentCapabilities, cancellationSupported: boolean, diff --git a/src/ports/native-interactive-execution.ts b/src/ports/native-interactive-execution.ts new file mode 100644 index 0000000..d2f06f6 --- /dev/null +++ b/src/ports/native-interactive-execution.ts @@ -0,0 +1,15 @@ +import type { RetainedInteractiveRunHandle } from '@tangle-network/agent-runtime/kernel' + +export type NativeInteractiveRunOutcome = + | { readonly kind: 'detached' } + | { readonly kind: 'exited'; readonly exitCode?: number; readonly exitSignal?: string } + | { readonly kind: 'failed'; readonly message: string } + +/** Rendezvous between Braid's durable run and the terminal-only native viewer. */ +export interface NativeInteractiveExecutionControl { + waitForHandle( + runId: string, + options?: { readonly signal?: AbortSignal }, + ): Promise + settle(runId: string, outcome: NativeInteractiveRunOutcome): void +} diff --git a/src/ports/native-terminal-transport.ts b/src/ports/native-terminal-transport.ts new file mode 100644 index 0000000..ac5672b --- /dev/null +++ b/src/ports/native-terminal-transport.ts @@ -0,0 +1,82 @@ +import type { AgentTerminalSession } from '@tangle-network/agent-interface' + +/** The existing local terminal boundary used by ProcessTerminal and tests. */ +export interface NativeTerminalHost { + readonly columns: number + readonly rows: number + start(onInput: (data: string) => void, onResize: () => void): void + stop(): void + write(data: string): void +} + +/** Optional ownership boundary for the process signal listeners. */ +export interface NativeTerminalSignalPort { + takeOver(handler: (exitCode: number) => void): () => void +} + +export type NativeTerminalTransportPhase = + | 'terminal-start' + | 'signal-install' + | 'output' + | 'input' + | 'resize' + | 'events' + +export type NativeTerminalCleanupPhase = 'remote-detach' | 'terminal-stop' | 'signal-release' + +export type NativeTerminalTransportOutcome = + | { + readonly kind: 'detached' + readonly sessionId: string + readonly trigger: 'user' + } + | { + readonly kind: 'remote-exit' + readonly sessionId: string + readonly reason: 'exited' | 'closed-during-detach' + readonly exitCode?: number + readonly exitSignal?: string + } + | { + readonly kind: 'transport-error' + readonly sessionId: string + readonly phase: NativeTerminalTransportPhase + readonly message: string + } + | { + readonly kind: 'aborted' + readonly sessionId: string + readonly source: 'caller' | 'signal' + readonly exitCode?: number + } + +export interface NativeTerminalCleanupIssue { + readonly phase: NativeTerminalCleanupPhase + readonly message: string +} + +export interface NativeTerminalCleanup { + readonly terminal: 'restored' | 'not-started' | 'failed' + readonly signal: 'restored' | 'not-installed' | 'failed' + readonly remote: 'detached' | 'closed' | 'not-required' | 'unknown' + readonly issues: readonly NativeTerminalCleanupIssue[] +} + +export interface NativeTerminalTransportResult { + readonly outcome: NativeTerminalTransportOutcome + readonly cleanup: NativeTerminalCleanup +} + +export interface NativeTerminalTransportInput { + readonly session: AgentTerminalSession + readonly terminal: NativeTerminalHost + readonly signals?: NativeTerminalSignalPort + /** Ctrl+] (ASCII GS) is intentionally not forwarded to the remote PTY. */ + readonly detachChord?: string + /** Bounds cleanup waits without delaying local terminal restoration forever. */ + readonly cleanupTimeoutMs?: number +} + +export interface NativeTerminalTransport { + run(options?: { readonly signal?: AbortSignal }): Promise +} diff --git a/src/startup/preview-runtime.ts b/src/startup/preview-runtime.ts index 038a6bc..75d6a62 100644 --- a/src/startup/preview-runtime.ts +++ b/src/startup/preview-runtime.ts @@ -37,7 +37,9 @@ export interface StartupFrameState { readonly model?: { readonly default?: string readonly reasoningEffort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number } } readonly selectedConnectionId: string | null @@ -99,9 +101,15 @@ class StartupFrame implements Component { ...(state.profile.model?.reasoningEffort === undefined ? {} : { effort: state.profile.model.reasoningEffort }), - ...(state.profile.model?.maxOutputTokens === undefined + ...(state.profile.model?.maxVisibleOutputTokens === undefined ? {} - : { maxOutputTokens: state.profile.model.maxOutputTokens }), + : { maxVisibleOutputTokens: state.profile.model.maxVisibleOutputTokens }), + ...(state.profile.model?.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: state.profile.model.maxReasoningTokens }), + ...(state.profile.model?.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: state.profile.model.maxTotalOutputTokens }), } this.#theme = theme this.#rows = rows diff --git a/src/views/shared/analysis-model-call-presentation.ts b/src/views/shared/analysis-model-call-presentation.ts index 7e89d34..0b9897e 100644 --- a/src/views/shared/analysis-model-call-presentation.ts +++ b/src/views/shared/analysis-model-call-presentation.ts @@ -42,6 +42,48 @@ export function analysisModelCallLine(call: AnalysisModelCallView): string { return `#${call.sequence} ${route} · ${tokens(call)} · ${cost(call)} · ${latency}` } +/** Renders only measured model-call facts for compact terminal surfaces. */ +export function analysisMeasuredModelCallLine(call: AnalysisModelCallView): string { + const route = + call.model === 'unknown-model' + ? `#${call.sequence}` + : `#${call.sequence} ${call.provider === undefined ? call.model : `${call.provider}/${call.model}`}` + const measured = [measuredTokens(call), measuredCost(call), measuredLatency(call)].filter( + (value): value is string => value !== undefined, + ) + return [route, ...measured].join(' · ') +} + +/** Summarizes only measured model-call facts without filling gaps with labels. */ +export function analysisMeasuredModelCallSummary(calls: readonly AnalysisModelCallView[]): string { + const parts = [`${calls.length} ${calls.length === 1 ? 'call' : 'calls'}`] + const input = calls.filter((call) => call.inputTokens !== undefined) + const output = calls.filter((call) => call.outputTokens !== undefined) + if (input.length > 0 || output.length > 0) { + parts.push( + `tokens ${input.length === calls.length ? '' : '≥'}${sum(input.map((call) => call.inputTokens))} in / ${output.length === calls.length ? '' : '≥'}${sum(output.map((call) => call.outputTokens))} out`, + ) + } + const knownCost = calls.filter( + (call) => call.costUsd !== undefined && call.costStatus !== 'unknown', + ) + if (knownCost.length > 0) { + const estimated = knownCost.some((call) => call.costStatus === 'estimated') + parts.push( + `cost ${estimated ? '~' : ''}$${sum(knownCost.map((call) => call.costUsd)).toFixed(4)}`, + ) + } + const latency = calls.filter((call) => call.latencyMs !== undefined) + if (latency.length > 0) { + parts.push( + `latency ${latency.length === calls.length ? '' : '≥'}${sum(latency.map((call) => call.latencyMs))}ms`, + ) + } + const failures = calls.filter((call) => call.outcome === 'failed').length + if (failures > 0) parts.push(`${failures} failed`) + return parts.join(' · ') +} + export function analysisModelCallSummary(calls: readonly AnalysisModelCallView[]): string { if (calls.length === 0) return '0 model calls reported.' const input = calls.reduce((sum, call) => sum + (call.inputTokens ?? 0), 0) @@ -65,7 +107,7 @@ export function analysisModelCallSummary(calls: readonly AnalysisModelCallView[] : `cost ${costPrefix(estimatedCosts, costGaps)}$${cost.toFixed(4)}${gap(costGaps)}`, latency === 0 && latencyGaps > 0 ? `latency unknown${gap(latencyGaps)}` - : `model ${latencyGaps === 0 ? '' : '≥'}${latency}ms${gap(latencyGaps)}`, + : `latency ${latencyGaps === 0 ? '' : '≥'}${latency}ms${gap(latencyGaps)}`, ...(failures === 0 ? [] : [`${failures} failed`]), ].join(' · ') } @@ -101,6 +143,27 @@ function usd(value: number): string { return `$${value.toFixed(6).replace(/0+$/u, '').replace(/\.$/u, '')}` } +function measuredTokens(call: AnalysisModelCallView): string | undefined { + if (call.inputTokens === undefined && call.outputTokens === undefined) return undefined + const prefix = call.tokensKnown ? '' : '≥' + const input = call.inputTokens === undefined ? undefined : `${prefix}${call.inputTokens} in` + const output = call.outputTokens === undefined ? undefined : `${prefix}${call.outputTokens} out` + return ['tokens', input, output].filter((value): value is string => value !== undefined).join(' ') +} + +function measuredCost(call: AnalysisModelCallView): string | undefined { + if (call.costUsd === undefined || call.costStatus === 'unknown') return undefined + return `cost ${call.costStatus === 'estimated' ? '~' : ''}${usd(call.costUsd)}` +} + +function measuredLatency(call: AnalysisModelCallView): string | undefined { + return call.latencyMs === undefined ? undefined : `latency ${call.latencyMs}ms` +} + +function sum(values: readonly (number | undefined)[]): number { + return values.reduce((total, value) => total + (value ?? 0), 0) +} + function safeModel(value: string | undefined): string | undefined { if (value === undefined) return undefined const safe = sanitizeTerminalText(value).trim() diff --git a/src/views/shared/analysis-presentation.ts b/src/views/shared/analysis-presentation.ts index 0cb75e5..d6084d4 100644 --- a/src/views/shared/analysis-presentation.ts +++ b/src/views/shared/analysis-presentation.ts @@ -1,8 +1,8 @@ import type { AnalysisRecord } from '../../domain/entities.js' import { analysisExecutionView, - analysisModelCallLine, - analysisModelCallSummary, + analysisMeasuredModelCallLine, + analysisMeasuredModelCallSummary, } from './analysis-model-call-presentation.js' import type { AnalysisView } from './models.js' import { sanitizeTerminalText } from './sanitize.js' @@ -169,17 +169,13 @@ export function analysisDocument(analysis: AnalysisView): AnalysisDocument { details.push(`next: ${nextAction}`) } - details.push(section('model use')) const execution = analysis.execution const route = analysisRouteLine(execution) - if (execution?.modelCalls === undefined) { - details.push('Model calls were not reported.') - } else if (execution.modelCalls.length === 0) { - details.push('0 model calls reported.') - } else { - details.push(analysisModelCallSummary(execution.modelCalls)) + if (execution?.modelCalls !== undefined && execution.modelCalls.length > 0) { + details.push(section('model use')) + details.push(analysisMeasuredModelCallSummary(execution.modelCalls)) for (const call of execution.modelCalls) - details.push(`model call ${analysisModelCallLine(call)}`) + details.push(`model call ${analysisMeasuredModelCallLine(call)}`) } if (analysis.error) details.push(`! ${sanitizeTerminalText(analysis.error)}`) diff --git a/src/views/shared/command-registry.ts b/src/views/shared/command-registry.ts index 94d863f..7e3378a 100644 --- a/src/views/shared/command-registry.ts +++ b/src/views/shared/command-registry.ts @@ -80,6 +80,8 @@ const INTENTS_FOR: Readonly> = { detach: runCommandIntent('detach'), reconnect: runCommandIntent('reconnect'), reconcile: runCommandIntent('reconcile'), + interactive: runCommandIntent('interactive'), + attach: runCommandIntent('attach'), activity: () => ({ type: 'open-surface', surface: 'activity' }), export: runCommandIntent('export'), import: runCommandIntent('import'), @@ -109,7 +111,7 @@ const DEFINITIONS: readonly CommandDefinition[] = [ [ 'connection', [], - 'Inspect, create, test, select, or remove a connection', + 'Inspect and manage connections', '/connection [list|create|test|select|remove] [id]', 'connection.select', 'explicit', @@ -217,6 +219,22 @@ const DEFINITIONS: readonly CommandDefinition[] = [ 'run.reconcile', 'explicit', ], + [ + 'interactive', + [], + "Open the selected runner's native terminal", + '/interactive ', + 'run.interactive', + 'explicit', + ], + [ + 'attach', + [], + 'Attach to a retained native terminal', + '/attach [run-id]', + 'run.attach', + 'explicit', + ], [ 'activity', [], diff --git a/src/views/shared/command-table.ts b/src/views/shared/command-table.ts index 7bfc895..d4de129 100644 --- a/src/views/shared/command-table.ts +++ b/src/views/shared/command-table.ts @@ -161,6 +161,18 @@ export const SHARED_COMMAND_TABLE = [ headlessCommands: ['reconcile'], mutatingHeadlessCommands: ['reconcile'], }, + { + name: 'interactive', + requiresOperationId: false, + headlessCommands: [], + mutatingHeadlessCommands: [], + }, + { + name: 'attach', + requiresOperationId: false, + headlessCommands: [], + mutatingHeadlessCommands: [], + }, { name: 'activity', requiresOperationId: false, diff --git a/src/views/shared/models.ts b/src/views/shared/models.ts index cb2b6d8..99c053a 100644 --- a/src/views/shared/models.ts +++ b/src/views/shared/models.ts @@ -201,7 +201,9 @@ export interface RunView { readonly profileDigest?: string readonly model?: string readonly effort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly usage?: UsageView readonly error?: string readonly cursor?: string @@ -546,7 +548,9 @@ export interface BraidViewModel { readonly effort?: string readonly mode?: string } - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly connection: string readonly conversationId: string readonly conversationTitle: string diff --git a/src/views/shared/native-interactive-actions.ts b/src/views/shared/native-interactive-actions.ts new file mode 100644 index 0000000..e78f0cc --- /dev/null +++ b/src/views/shared/native-interactive-actions.ts @@ -0,0 +1,35 @@ +export type NativeInteractiveCommand = + | { + readonly action: 'start' + readonly initialPrompt?: string + } + | { + readonly action: 'attach' + readonly runId?: string + } + +export interface NativeInteractiveAvailability { + readonly available: boolean + readonly reason?: string +} + +export type NativeInteractiveCommandResult = + | { + readonly kind: 'returned' + readonly runId: string + readonly outcome: 'detached' | 'exited' + } + | { + readonly kind: 'unavailable' + readonly reason: string + } + | { + readonly kind: 'error' + readonly message: string + } + +/** Terminal-only actions. Runtime owns admission and recovery; this port only requests them. */ +export interface NativeInteractiveUiActions { + availability(action: NativeInteractiveCommand['action']): NativeInteractiveAvailability + run(command: NativeInteractiveCommand): Promise +} diff --git a/src/views/tui/activity-browser.ts b/src/views/tui/activity-browser.ts index 531e97d..89ca610 100644 --- a/src/views/tui/activity-browser.ts +++ b/src/views/tui/activity-browser.ts @@ -8,8 +8,7 @@ import { type EntityBrowserDocument, type EntityBrowserRow, } from './entity-browser.js' -import { executionTargetFor, executionTargetForEntity } from './execution-target.js' -import { metricsFor } from './terminal-usage.js' +import { executionTargetFor } from './execution-target.js' import type { BraidTheme } from './theme.js' export type ActivityBrowserScope = 'all' | 'runs' | 'analyses' | 'workers' @@ -34,14 +33,13 @@ export class ActivityBrowserPanel extends EntityBrowser { constructor(theme: BraidTheme, options: ActivityBrowserOptions) { const scopeState: { scope: ActivityBrowserScope } = { scope: options.scope ?? 'all' } super(theme, { - document: (selectedId) => + document: () => activityDocument( options.view(), scopeState.scope, options.notice?.(), options.emptyMessage, options.pinned, - selectedId, ), rows: options.rows, onClose: options.onClose, @@ -68,7 +66,6 @@ export function activityDocument( notice?: string, emptyMessage?: string, pinned?: string, - selectedId?: string, ): EntityBrowserDocument { const details = new Map( (view.entityDetails ?? []).map((detail) => [detailKey(detail), detail] as const), @@ -78,17 +75,8 @@ export function activityDocument( .items.filter((item) => included(item, scope)) .slice() .reverse() - const selected = items.find((item) => item.id === selectedId) - const target = - selected?.runId !== undefined - ? executionTargetFor(view, selected.runId) - : selected?.source?.entityType !== undefined && selected.source.entityId !== undefined - ? executionTargetForEntity(view, selected.source.entityType, selected.source.entityId) - : executionTargetFor(view) - const usage = metricsFor(view) return { title: scope === 'all' ? 'activity' : scope, - context: [target.profileName, target.runner, target.model, ...usage].join(' · '), filterHint: `tab filter: ${scope}`, ...(pinned === undefined ? {} : { pinned }), ...(notice === undefined ? {} : { notice }), @@ -176,17 +164,18 @@ function runContext( : [`execution environment: ${sanitizeTerminalText(run.environmentId)}`]), `model: ${sanitizeTerminalText(target.model)}`, ...(target.effort === undefined ? [] : [`thinking: ${sanitizeTerminalText(target.effort)}`]), - ...(target.maxOutputTokens === undefined + ...(target.maxVisibleOutputTokens === undefined ? [] - : [`max output tokens: ${target.maxOutputTokens}`]), + : [`max visible output tokens: ${target.maxVisibleOutputTokens}`]), + ...(target.maxReasoningTokens === undefined + ? [] + : [`max reasoning tokens: ${target.maxReasoningTokens}`]), + ...(target.maxTotalOutputTokens === undefined + ? [] + : [`max total output tokens: ${target.maxTotalOutputTokens}`]), ...(metrics.length === 0 ? [] : [`usage: ${metrics.join(' · ')}`]), - ...measurementValue('model calls', usage?.llmCalls, usage !== undefined), - ...measurementValue( - 'model latency', - usage?.llmLatencyMs, - usage !== undefined, - (value) => `${Math.round(value)}ms`, - ), + ...measurementValue('model calls', usage?.llmCalls), + ...measurementValue('model latency', usage?.llmLatencyMs, (value) => `${Math.round(value)}ms`), ...measurementStatus('token measurement', usage?.tokenStatus), ...measurementStatus('cost measurement', usage?.costStatus), `history: ${sanitizeTerminalText(run.completeness)}`, @@ -197,16 +186,14 @@ function runContext( function measurementValue( label: string, value: number | undefined, - measurementExists: boolean, format: (value: number) => string = (measured) => String(measured), ): readonly string[] { - if (value !== undefined) return [`${label}: ${format(value)}`] - return measurementExists ? [`${label}: not reported`] : [] + return value === undefined ? [] : [`${label}: ${format(value)}`] } function measurementStatus(label: string, status: string | undefined): readonly string[] { - if (status === undefined) return [] - return [`${label}: ${status === 'unknown' ? 'not reported' : status}`] + if (status === undefined || status === 'unknown') return [] + return [`${label}: ${status}`] } function costLabel(usage: RunView['usage']): string | undefined { diff --git a/src/views/tui/automation-rule-panel.ts b/src/views/tui/automation-rule-panel.ts index 82bba8b..cf1b445 100644 --- a/src/views/tui/automation-rule-panel.ts +++ b/src/views/tui/automation-rule-panel.ts @@ -114,14 +114,14 @@ export class AutomationRulePanel extends Container implements Focusable { this.#interaction !== undefined && ruleCreationReason(this.#interaction) === undefined if (this.#rules.length === 0 && !canCreate) return this.#interaction === undefined - ? 'create from a pending request with Alt+A · esc close' - : 'this request stays manual · esc close' + ? 'create from a pending request with Alt+A · ←/esc close' + : 'this request stays manual · ←/esc close' return [ 'type to filter', ...(this.#onSelect === undefined ? [] : ['enter select']), ...(canCreate ? ['ctrl+n new'] : []), ...(this.#rules.length === 0 ? [] : ['ctrl+a off', 'ctrl+d del']), - 'esc close', + '←/esc close', ].join(' · ') } @@ -168,7 +168,7 @@ export class AutomationRulePanel extends Container implements Focusable { items: scopes.map(scopeItem), theme: this.#theme, maxVisible: 3, - footer: 'type to filter · enter choose · esc back', + footer: 'type to filter · enter choose · ←/esc back', onSelect: (item) => { const scope = parseScope(item.value) if (scope === undefined) return diff --git a/src/views/tui/composer-view.ts b/src/views/tui/composer-view.ts index 9c63cde..45a38dc 100644 --- a/src/views/tui/composer-view.ts +++ b/src/views/tui/composer-view.ts @@ -43,7 +43,7 @@ export function composerProjectionFor( return { action: send ? 'send' : 'unavailable', actionLabel: send ? 'send' : 'send unavailable', - hint: 'alt+enter newline · paste', + hint: 'type / for commands · Alt+Enter newline · paste', } } @@ -68,7 +68,7 @@ export function composerProjectionFor( action, actionLabel, ...(queuePosition === undefined ? {} : { queuePosition }), - hint: 'alt+enter newline · paste', + hint: 'type / for commands · Alt+Enter newline · paste', } } @@ -89,7 +89,7 @@ export class ComposerView extends Container { #projection: ComposerProjection = { action: 'send', actionLabel: 'send', - hint: 'alt+enter newline · paste', + hint: 'type / for commands · Alt+Enter newline · paste', } #mode: ComposerMode = 'queue' @@ -123,11 +123,18 @@ export class ComposerView extends Container { body[0] = promptBodyLine(body[0], this.#theme, promptActionLabel(this.#projection)) } const autocomplete = lines.slice(bottomBorder + 1) - if (autocomplete.length === 0) return body - return [...body, composerBorderLine(width, this.#projection.hint, this.#theme), ...autocomplete] + const topRule = composerTopRule(width, this.#editor.borderColor) + const bottomRule = composerBorderLine(width, this.#projection.hint, this.#theme) + if (autocomplete.length === 0) return [topRule, ...body, bottomRule] + return [topRule, ...body, bottomRule, ...autocomplete] } } +function composerTopRule(width: number, borderColor: (text: string) => string): string { + const safeWidth = Math.max(1, Math.floor(width)) + return borderColor('─'.repeat(safeWidth)) +} + function boundedEditorBody(body: readonly string[], terminalRows: number, width: number): string[] { const bodyBudget = Math.max(MIN_USABLE_ROWS, composerRowBudget(terminalRows) - 2) const minimumBodyRows = Math.min(MIN_USABLE_ROWS, bodyBudget) @@ -168,7 +175,7 @@ export function composerRowBudget(terminalRows: number): number { export function composerBorderLine(width: number, label: string, theme: BraidTheme): string { const safeWidth = Math.max(1, Math.floor(width)) - const padded = truncateToWidth(` ${label} `, safeWidth, '…') + const padded = truncateToWidth(` ${composerHintForWidth(label, safeWidth)} `, safeWidth, '…') const labelWidth = visibleWidth(padded) const remaining = Math.max(0, safeWidth - labelWidth) const left = Math.floor(remaining / 2) @@ -176,8 +183,14 @@ export function composerBorderLine(width: number, label: string, theme: BraidThe return `${theme.editor.borderColor('─'.repeat(left))}${theme.accent(padded)}${theme.editor.borderColor('─'.repeat(right))}` } +function composerHintForWidth(label: string, width: number): string { + if (width < 60) return 'type / for commands · Alt+Enter' + if (width < 100) return 'type / for commands · Alt+Enter · paste' + return label +} + function promptActionLabel(projection: ComposerProjection): string | undefined { - if (projection.action === 'send') return undefined + if (projection.action === 'send') return 'new message' if (projection.action === 'queue') { return projection.queuePosition === undefined ? 'queue' : `queue #${projection.queuePosition}` } diff --git a/src/views/tui/configuration-presenters.ts b/src/views/tui/configuration-presenters.ts index c1161ab..5d0f581 100644 --- a/src/views/tui/configuration-presenters.ts +++ b/src/views/tui/configuration-presenters.ts @@ -26,13 +26,10 @@ export function profileItems( (activeId === undefined && matchingNames.length === 1 && profile.name === activeName) ? `✓ ${safe(profile.name)}` : safe(profile.name), - description: [ + description: `· ${[ profile.source.trusted ? 'trusted' : 'untrusted source', profile.source.writable ? 'writable' : 'read-only', - safe(profile.source.label), - profile.runner === undefined ? 'runner default' : safe(profile.runner), - profile.model === undefined ? 'model default' : safe(profile.model), - ].join(' · '), + ].join(' · ')}`, })) } @@ -64,8 +61,9 @@ export function connectionItems( export function profileDetailLines(profile: ProfileSummary | undefined): string[] { if (profile === undefined) return ['No profile details loaded.'] + const source = profileSourceDetail(profile) return [ - `source ${safe(profile.source.label)} · ${profile.source.trusted ? 'trusted' : 'untrusted'} · ${profile.source.writable ? 'writable' : 'read-only'}`, + `${source === undefined ? '' : `${source} · `}${profile.source.trusted ? 'trusted' : 'untrusted'} · ${profile.source.writable ? 'writable' : 'read-only'}`, `runner ${safe(profile.runner ?? 'provider default')} · model ${safe(profile.model ?? 'provider default')}`, profileModelControls(profile), `digest ${shortDigest(profile.digest)}`, @@ -74,16 +72,28 @@ export function profileDetailLines(profile: ProfileSummary | undefined): string[ export function profileCompactDetail(profile: ProfileSummary | undefined): string { if (profile === undefined) return 'No profile details loaded.' + const source = profileSourceDetail(profile) return [ `runner ${safe(profile.runner ?? 'provider default')}`, `model ${safe(profile.model ?? 'provider default')}`, profileModelControls(profile), + ...(source === undefined ? [] : [source]), profile.source.trusted ? 'trusted' : 'untrusted source', profile.source.writable ? 'writable' : 'read-only', `digest ${shortDigest(profile.digest)}`, ].join(' · ') } +function profileSourceDetail(profile: ProfileSummary): string | undefined { + if ( + profile.source.kind === 'inline' && + profile.source.reference === 'braid:active' && + profile.source.label === profile.name + ) + return undefined + return `source ${safe(profile.source.label)}` +} + export function connectionDetailLines(connection: ConnectionSummary | undefined): string[] { if (connection === undefined) return ['No connection details loaded.'] const verification = connection.modelVerification @@ -173,6 +183,12 @@ export function actionMessage( export const REFRESH_TIMEOUT = Symbol('refresh-timeout') +const tokenLimitKeys = [ + 'maxVisibleOutputTokens', + 'maxReasoningTokens', + 'maxTotalOutputTokens', +] as const + export async function within( promise: Promise, timeoutMs: number, @@ -202,10 +218,11 @@ function isProfileSummary(value: unknown): value is ProfileSummary { (value.runner !== undefined && typeof value.runner !== 'string') || (value.model !== undefined && typeof value.model !== 'string') || (value.reasoningEffort !== undefined && typeof value.reasoningEffort !== 'string') || - (value.maxOutputTokens !== undefined && - (typeof value.maxOutputTokens !== 'number' || - !Number.isSafeInteger(value.maxOutputTokens) || - value.maxOutputTokens <= 0)) + tokenLimitKeys.some( + (key) => + value[key] !== undefined && + (typeof value[key] !== 'number' || !Number.isSafeInteger(value[key]) || value[key] <= 0), + ) ) return false const source = value.source @@ -219,11 +236,21 @@ function isProfileSummary(value: unknown): value is ProfileSummary { function profileModelControls(profile: ProfileSummary): string { const effort = safe(profile.reasoningEffort ?? 'provider default') - const output = - profile.maxOutputTokens === undefined - ? 'provider default' - : `${profile.maxOutputTokens.toLocaleString('en-US')} tokens` - return `thinking ${effort} · max output ${output}` + const limits = [ + profile.maxVisibleOutputTokens === undefined + ? undefined + : `visible ${profile.maxVisibleOutputTokens.toLocaleString('en-US')}`, + profile.maxReasoningTokens === undefined + ? undefined + : `reasoning ${profile.maxReasoningTokens.toLocaleString('en-US')}`, + profile.maxTotalOutputTokens === undefined + ? undefined + : `total ${profile.maxTotalOutputTokens.toLocaleString('en-US')}`, + ].filter((value): value is string => value !== undefined) + return [ + `thinking ${effort}`, + ...(limits.length === 0 ? ['limits provider default'] : [`limits ${limits.join(' · ')}`]), + ].join(' · ') } function isConnectionSummary(value: unknown): value is ConnectionSummary { diff --git a/src/views/tui/configuration-review.ts b/src/views/tui/configuration-review.ts index 252a7f2..36fef53 100644 --- a/src/views/tui/configuration-review.ts +++ b/src/views/tui/configuration-review.ts @@ -2,6 +2,7 @@ import { type Component, Container, type Focusable, + matchesKey, type SelectItem, SelectList, Text, @@ -27,6 +28,7 @@ export interface ConfigurationReviewOptions { */ export class ConfigurationReview extends Container implements Focusable { readonly #list: SelectList + readonly #onCancel: () => void #focused = false constructor(options: ConfigurationReviewOptions) { @@ -34,13 +36,14 @@ export class ConfigurationReview extends Container implements Focusable { this.#list = new SelectList([...options.items], 4, options.theme.select) this.#list.onSelect = options.onSelect this.#list.onCancel = options.onCancel + this.#onCancel = options.onCancel this.addChild(new Text(options.theme.brand(options.title ?? 'review and start'), 1, 0)) if (options.error !== undefined) { this.addChild(new Text(options.theme.danger(sanitizeTerminalText(options.error)), 1, 0)) } this.addChild(new ReviewSummary(options.theme, options.summary, options.compactSummary)) this.addChild(this.#list) - this.addChild(new Text(options.theme.muted('enter choose · ↑↓ move · esc cancel'), 1, 0)) + this.addChild(new Text(options.theme.muted('enter choose · ↑↓ move · ←/esc cancel'), 1, 0)) } get focused(): boolean { @@ -52,6 +55,10 @@ export class ConfigurationReview extends Container implements Focusable { } handleInput(data: string): void { + if (matchesKey(data, 'left')) { + this.#onCancel() + return + } this.#list.handleInput(data) } } diff --git a/src/views/tui/configuration-wizard-presentation.ts b/src/views/tui/configuration-wizard-presentation.ts index 3b34a06..02b8d97 100644 --- a/src/views/tui/configuration-wizard-presentation.ts +++ b/src/views/tui/configuration-wizard-presentation.ts @@ -42,8 +42,8 @@ export function configurationExplanation(state: ConfigurationSessionState): stri export function configurationFooter(state: ConfigurationSessionState, busy: boolean): string { if (busy) return 'waiting for the selected connection' - if (state.step === 'confirm') return 'enter apply · arrows · esc cancel' - return 'filter · enter choose · esc cancel' + if (state.step === 'confirm') return 'enter apply · arrows · ←/esc cancel' + return 'filter · enter choose · ←/esc cancel' } export function configurationItems( @@ -54,7 +54,11 @@ export function configurationItems( if (state.step === 'profile') { if (state.profiles.length === 0) { return [ - { value: CANCEL_CONFIGURATION, label: 'No profiles available', description: 'esc close' }, + { + value: CANCEL_CONFIGURATION, + label: 'No profiles available', + description: '←/esc close', + }, ] } return state.profiles.map((profile) => ({ @@ -86,7 +90,7 @@ export function configurationItems( } if (state.step === 'confirm' || state.step === 'complete') { if (state.step === 'complete' && !busy && commitError === undefined) { - return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] + return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: '←/esc close' }] } return [ { @@ -111,7 +115,7 @@ export function configurationItems( }, ] } - return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] + return [{ value: CANCEL_CONFIGURATION, label: 'Close', description: '←/esc close' }] } export function reviewSummary( diff --git a/src/views/tui/configuration-wizard.ts b/src/views/tui/configuration-wizard.ts index 21dd195..1f3f024 100644 --- a/src/views/tui/configuration-wizard.ts +++ b/src/views/tui/configuration-wizard.ts @@ -7,14 +7,14 @@ import { type ConfigurationSessionState, } from '../../app/configuration-session.js' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { ConfigurationReview } from './configuration-review.js' import { - ConfigurationCredential, type ConfigurationCommit, + ConfigurationCredential, configurationNeedsCredential, mountConfigurationCredential, PreparedCredential, } from './configuration-credential.js' +import { ConfigurationReview } from './configuration-review.js' import { APPLY_SELECTION, BACK_TO_CONNECTION, @@ -134,7 +134,7 @@ export class ConfigurationWizard extends Container implements Focusable { : configurationTitle(state, this.#busy, this.#commitError), ...(this.#commitError === undefined ? {} : { error: this.#commitError }), items: applied - ? [{ value: CANCEL_CONFIGURATION, label: 'Close', description: 'esc close' }] + ? [{ value: CANCEL_CONFIGURATION, label: 'Close', description: '←/esc close' }] : configurationItems(state, this.#busy, this.#commitError), onSelect: (item) => this.#select(item.value), onCancel: () => this.#cancel(), @@ -179,8 +179,8 @@ export class ConfigurationWizard extends Container implements Focusable { if (state.profiles.length === 0 || state.connections.length === 0) { this.#commitError = state.profiles.length === 0 - ? 'No AgentProfiles are available. Press esc to leave setup.' - : 'No connections are available. Press esc to leave setup.' + ? 'No AgentProfiles are available. Press ←/esc to leave setup.' + : 'No connections are available. Press ←/esc to leave setup.' this.#renderStage(state) return } diff --git a/src/views/tui/connection-metadata-editor-actions.ts b/src/views/tui/connection-metadata-editor-actions.ts index 4a75202..87698a4 100644 --- a/src/views/tui/connection-metadata-editor-actions.ts +++ b/src/views/tui/connection-metadata-editor-actions.ts @@ -20,7 +20,7 @@ export function createConnectionMetadataReviewList( options: ConnectionMetadataReviewListOptions, ): SelectList { const items: readonly SelectItem[] = options.applied - ? [{ value: CLOSE_EDITOR, label: 'Close', description: 'esc close' }] + ? [{ value: CLOSE_EDITOR, label: 'Close', description: '←/esc close' }] : [ { value: APPLY_DRAFT, diff --git a/src/views/tui/connection-metadata-editor-rendering.ts b/src/views/tui/connection-metadata-editor-rendering.ts index e09edd5..1b7cd4e 100644 --- a/src/views/tui/connection-metadata-editor-rendering.ts +++ b/src/views/tui/connection-metadata-editor-rendering.ts @@ -57,7 +57,7 @@ function kindChildren(state: ConnectionMetadataEditorRenderState): readonly Comp new Text(state.theme.accent('connection kind'), 1, 0), state.kindList, new Spacer(1), - new Text(state.theme.muted('↑↓ choose · enter next · esc cancel'), 1, 0), + new Text(state.theme.muted('↑↓ choose · enter next · ←/esc cancel'), 1, 0), ] } @@ -97,10 +97,10 @@ function reviewChildren(state: ConnectionMetadataEditorRenderState): readonly Co new Text( state.theme.muted( state.applied - ? 'enter close · esc close' + ? 'enter close · ←/esc close' : state.busy ? 'saving…' - : '↑↓ choose · enter continue / back · esc cancel', + : '↑↓ choose · enter continue / back · ←/esc cancel', ), 1, 0, diff --git a/src/views/tui/connection-metadata-editor.ts b/src/views/tui/connection-metadata-editor.ts index 4405f71..09333d1 100644 --- a/src/views/tui/connection-metadata-editor.ts +++ b/src/views/tui/connection-metadata-editor.ts @@ -1,4 +1,10 @@ -import { Container, type Focusable, type Input, SelectList } from '@earendil-works/pi-tui' +import { + Container, + type Focusable, + type Input, + matchesKey, + SelectList, +} from '@earendil-works/pi-tui' import { sanitizeTerminalText } from '../shared/sanitize.js' import { APPLY_DRAFT, @@ -89,10 +95,18 @@ export class ConnectionMetadataEditor extends Container implements Focusable { handleInput(data: string): void { if (this.#closed || this.#busy) return if (this.#stage === 'kind') { + if (matchesKey(data, 'left')) { + this.#cancel() + return + } this.#kindList.handleInput(data) return } if (this.#stage === 'review') { + if (matchesKey(data, 'left')) { + this.#cancel() + return + } this.#reviewList?.handleInput(data) return } diff --git a/src/views/tui/connection-setup.ts b/src/views/tui/connection-setup.ts index d09f20d..cd40b07 100644 --- a/src/views/tui/connection-setup.ts +++ b/src/views/tui/connection-setup.ts @@ -101,8 +101,8 @@ export class ConnectionSetupViewPanel extends Container implements Focusable { maxVisible: 5, footer: this.#onCreate === undefined - ? 'enter select · ^T test · esc close' - : 'enter select · ^N new · ^T test · ^D remove · esc close', + ? 'enter select · ^T test · ←/esc close' + : 'enter select · ^N new · ^T test · ^D remove · ←/esc close', theme: this.#theme, onSelect: (item) => void this.#select(item), onAction: (key, item) => { diff --git a/src/views/tui/conversation-dialogs.ts b/src/views/tui/conversation-dialogs.ts index ec167d6..e9e4a8f 100644 --- a/src/views/tui/conversation-dialogs.ts +++ b/src/views/tui/conversation-dialogs.ts @@ -52,7 +52,7 @@ export class ConversationConfirmation extends Container implements Focusable { this.addChild(this.#detail) this.addChild(this.#error) this.addChild(new Spacer(1)) - this.addChild(new TruncatedText(this.#theme.muted('enter/y confirm · n/esc cancel'), 1, 0)) + this.addChild(new TruncatedText(this.#theme.muted('enter/y confirm · n/←/esc cancel'), 1, 0)) } get focused(): boolean { @@ -71,7 +71,12 @@ export class ConversationConfirmation extends Container implements Focusable { handleInput(data: string): void { if (this.#submitted) return - if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'n')) { + if ( + matchesKey(data, 'escape') || + matchesKey(data, 'ctrl+c') || + matchesKey(data, 'left') || + matchesKey(data, 'n') + ) { this.#onCancel() return } diff --git a/src/views/tui/conversation-overlay-actions.ts b/src/views/tui/conversation-overlay-actions.ts index 83a2039..039cdae 100644 --- a/src/views/tui/conversation-overlay-actions.ts +++ b/src/views/tui/conversation-overlay-actions.ts @@ -44,12 +44,12 @@ export class ConversationOverlayActions { conversationId: string | undefined, ): void { if (conversationId === undefined) { - selector.setFooter('select a conversation first · esc close') + selector.setFooter('select a conversation first · ←/esc close') return } const conversation = this.#conversation(conversationId) if (conversation === undefined) { - selector.setFooter('selected conversation disappeared · refresh with esc and ctrl+o') + selector.setFooter('selected conversation disappeared · refresh with ←/esc and ctrl+o') return } if (action === 'rename') this.#openRename(selector, conversation) @@ -60,7 +60,7 @@ export class ConversationOverlayActions { async openConversation(selector: SearchableSelector, conversationId: string): Promise { const conversation = this.#conversation(conversationId) if (conversation === undefined) { - selector.setFooter('conversation is no longer available · esc close') + selector.setFooter('conversation is no longer available · ←/esc close') return } selector.setFooter('opening conversation…') @@ -70,7 +70,7 @@ export class ConversationOverlayActions { }) if (result.kind === 'accepted') this.#modals.closeTop() else { - selector.setFooter('open failed · query and selection preserved · esc close') + selector.setFooter('open failed · query and selection preserved · ←/esc close') this.#showResultError('Open failed', result, false) } } diff --git a/src/views/tui/conversation-overlays.ts b/src/views/tui/conversation-overlays.ts index a60a0b1..90e7f6b 100644 --- a/src/views/tui/conversation-overlays.ts +++ b/src/views/tui/conversation-overlays.ts @@ -33,8 +33,7 @@ export class ConversationOverlayController { items: conversationItems(view), theme: this.#actions.theme, maxVisible: 8, - footer: - 'type to filter · enter to choose · esc to close · ^R rename · ^A archive · ^D delete', + footer: 'type to filter · enter to choose · ←/esc close · ^R rename · ^A archive · ^D delete', onSelect: (item) => void this.#actions.openConversation(selector, item.value), onAction: (key, item) => this.#actions.conversationAction(selector, key, item?.value), onCancel: () => this.#modals.closeTop(), @@ -55,7 +54,7 @@ export class ConversationOverlayController { items, theme: this.#actions.theme, maxVisible: 8, - footer: 'filter · enter switch branch · esc close', + footer: 'filter · enter switch branch · ←/esc close', onSelect: (item) => void this.#actions.openBranch(selector, item.value), onCancel: () => this.#modals.closeTop(), }) diff --git a/src/views/tui/entity-browser-layout.ts b/src/views/tui/entity-browser-layout.ts new file mode 100644 index 0000000..664483e --- /dev/null +++ b/src/views/tui/entity-browser-layout.ts @@ -0,0 +1,85 @@ +import { visibleWidth } from '@earendil-works/pi-tui' + +export interface EntityBrowserFooterOptions { + readonly mode: 'wide' | 'list' | 'detail' + readonly width: number + readonly rowCount: number + readonly bodyRows: number + readonly selectedIndex: number + readonly pages: number + readonly page: number + readonly filterHint?: string +} + +export function entityBrowserFooter(options: EntityBrowserFooterOptions): string { + const filters = filterVariants(options.filterHint) + if (options.mode === 'wide') { + const page = options.pages > 1 ? `page ${options.page + 1}/${options.pages}` : undefined + const keys = + options.rowCount === 1 + ? ['PgUp/PgDn detail · ←/esc close', 'PgUp/PgDn · ←/esc close', '←/esc close'] + : options.width < 120 + ? ['↑↓ select · PgUp/PgDn detail · ←/esc close', '↑↓ · Pg · ←/esc close', '←/esc close'] + : [ + '↑↓ select · PgUp/PgDn detail · home/end jump · ←/esc close', + '↑↓ select · PgUp/PgDn detail · ←/esc close', + '↑↓ · Pg · ←/esc close', + '←/esc close', + ] + return fitFooter(candidates(keys, page, filters), options.width) + } + if (options.mode === 'list') { + const suffix = + options.rowCount > options.bodyRows + ? `${options.selectedIndex + 1}/${options.rowCount}` + : undefined + const keys = + options.width < 52 + ? ['↑↓ move · → open · ←/esc close', '↑↓ · → open · ←/esc close', '←/esc close'] + : ['↑↓ move · enter/→ open · ←/esc close', '↑↓ move · → open · ←/esc close', '←/esc close'] + return fitFooter(candidates(keys, suffix, filters), options.width) + } + const page = `page ${options.page + 1}/${options.pages}` + const keys = + options.width < 52 + ? options.pages > 1 + ? ['↑↓ · Pg · ←/esc back', '←/esc back'] + : ['↑↓ item · ←/esc back', '↑↓ · ←/esc back', '←/esc back'] + : options.width < 64 + ? options.pages > 1 + ? ['↑↓ · PgUp/PgDn · ←/esc back', '↑↓ · Pg · ←/esc back', '←/esc back'] + : ['↑↓ previous/next · ←/esc back', '↑↓ · ←/esc back', '←/esc back'] + : [ + '↑↓ previous/next · PgUp/PgDn page · ←/esc back', + '↑↓ previous/next · PgUp/PgDn · ←/esc back', + '↑↓ · Pg · ←/esc back', + '←/esc back', + ] + return fitFooter(candidates(keys, options.pages > 1 ? page : undefined, filters), options.width) +} + +function filterVariants(filter: string | undefined): readonly (string | undefined)[] { + if (filter === undefined || filter.length === 0) return [undefined] + const compact = filter.replace(/^tab filter:/u, 'tab:') + return compact === filter ? [filter, undefined] : [filter, compact, undefined] +} + +function candidates( + keys: readonly string[], + page: string | undefined, + filters: readonly (string | undefined)[], +): string[] { + return keys.flatMap((key) => [ + ...(page === undefined ? [] : filters.map((filter) => joinParts(key, page, filter))), + ...filters.map((filter) => joinParts(key, undefined, filter)), + ]) +} + +function joinParts(...parts: readonly (string | undefined)[]): string { + return parts.filter((part): part is string => part !== undefined && part.length > 0).join(' · ') +} + +function fitFooter(values: readonly string[], width: number): string { + const available = Math.max(1, width - 2) + return values.find((value) => visibleWidth(value) <= available) ?? values.at(-1) ?? '' +} diff --git a/src/views/tui/entity-browser.ts b/src/views/tui/entity-browser.ts index 55dd924..9c0b00a 100644 --- a/src/views/tui/entity-browser.ts +++ b/src/views/tui/entity-browser.ts @@ -7,6 +7,7 @@ import { wrapTextWithAnsi, } from '@earendil-works/pi-tui' import { sanitizeTerminalText } from '../shared/sanitize.js' +import { entityBrowserFooter } from './entity-browser-layout.js' import { omitUnreportedCostAndLatency } from './measurement-display.js' import type { ModalBackTarget } from './modal-coordinator.js' import type { BraidTheme } from './theme.js' @@ -283,48 +284,34 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg ? this.#theme.brand(`${title} · ${document.rows.length}`) : `${this.#theme.brand(title)} ${this.#theme.muted('›')} ${this.#theme.accent(sanitizeTerminalText(selected.title))}` const context = - document.context === undefined - ? undefined - : this.#theme.muted(sanitizeTerminalText(document.context)) + document.context === undefined ? undefined : sanitizeTerminalText(document.context) return this.#line(this.#withContext(primary, context, width), width) } #footer(document: EntityBrowserDocument, width: number): string { - const filter = - document.filterHint === undefined ? '' : ` · ${sanitizeTerminalText(document.filterHint)}` - if (this.#usesWideBrowser(document, width)) { - const pages = this.#detailPageCount(document, width) - const page = pages > 1 ? ` · page ${this.#page + 1}/${pages}` : '' - const keys = - document.rows.length === 1 - ? 'PgUp/PgDn detail · ←/esc close' - : width < 120 - ? '↑↓ select · PgUp/PgDn detail · ←/esc close' - : '↑↓ select · PgUp/PgDn detail · home/end jump · ←/esc close' - return this.#line(this.#theme.muted(`${keys}${page}${filter}`), width) - } - if (this.#mode === 'list') { - const suffix = - document.rows.length > this.#bodyRows() - ? ` · ${this.#selectedIndex + 1}/${document.rows.length}` - : '' - const keys = - width < 52 ? '↑↓ move · → open · ←/esc close' : '↑↓ move · enter/→ open · ←/esc close' - return this.#line(this.#theme.muted(`${keys}${suffix}${filter}`), width) - } const pages = this.#detailPageCount(document, width) - const page = `page ${this.#page + 1}/${pages}` - const keys = - width < 52 - ? pages > 1 - ? `${page} · Pg · ↑↓ item · ←/esc back` - : '↑↓ item · ←/esc back' - : width < 64 - ? pages > 1 - ? `${page} · PgUp/PgDn · ↑↓ item · ←/esc back` - : '↑↓ previous/next · ←/esc back' - : `↑↓ previous/next · PgUp/PgDn page · ←/esc back${pages > 1 ? ` · ${page}` : ''}` - return this.#line(this.#theme.muted(`${keys}${filter}`), width) + const mode = this.#usesWideBrowser(document, width) + ? 'wide' + : this.#mode === 'list' + ? 'list' + : 'detail' + return this.#line( + this.#theme.muted( + entityBrowserFooter({ + mode, + width, + rowCount: document.rows.length, + bodyRows: this.#bodyRows(), + selectedIndex: this.#selectedIndex, + pages, + page: this.#page, + ...(document.filterHint === undefined + ? {} + : { filterHint: sanitizeTerminalText(document.filterHint) }), + }), + ), + width, + ) } #bodyRows(): number { @@ -381,9 +368,14 @@ export class EntityBrowser extends Container implements Focusable, ModalBackTarg #withContext(primary: string, context: string | undefined, width: number): string { if (context === undefined) return primary - const available = Math.max(1, width - 1) - const gap = available - visibleWidth(primary) - visibleWidth(context) - return gap >= 3 ? `${primary}${' '.repeat(gap)}${context}` : primary + const available = Math.max(1, width - 2) + const parts = context.split(' · ').filter((part) => part.length > 0) + for (let count = parts.length; count > 0; count -= 1) { + const candidate = this.#theme.muted(parts.slice(0, count).join(' · ')) + const gap = available - visibleWidth(primary) - visibleWidth(candidate) + if (gap >= 3) return `${primary}${' '.repeat(gap)}${candidate}` + } + return primary } #usesSplitView(width: number): boolean { diff --git a/src/views/tui/execution-target.ts b/src/views/tui/execution-target.ts index 0b28e49..d9f452e 100644 --- a/src/views/tui/execution-target.ts +++ b/src/views/tui/execution-target.ts @@ -7,8 +7,11 @@ export interface ExecutionTargetView { readonly profileDigest?: string readonly runner: string readonly model: string + readonly backend?: string readonly effort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly connection: string readonly connectionId?: string readonly environment?: EnvironmentView @@ -98,7 +101,15 @@ function profileTarget(view: BraidViewModel): ExecutionTargetView { runner: view.runner, model: view.model, ...(view.effort === undefined ? {} : { effort: view.effort }), - ...(view.maxOutputTokens === undefined ? {} : { maxOutputTokens: view.maxOutputTokens }), + ...(view.maxVisibleOutputTokens === undefined + ? {} + : { maxVisibleOutputTokens: view.maxVisibleOutputTokens }), + ...(view.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: view.maxReasoningTokens }), + ...(view.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: view.maxTotalOutputTokens }), connection: view.connection, }) } @@ -108,6 +119,7 @@ function runTarget(view: BraidViewModel, run: RunView): ExecutionTargetView { run.environmentId === undefined ? undefined : view.environments.find((candidate) => candidate.id === run.environmentId) + const backend = run.provider ?? environment?.provider return Object.freeze({ source: 'run', runId: run.id, @@ -115,8 +127,15 @@ function runTarget(view: BraidViewModel, run: RunView): ExecutionTargetView { ...(run.profileDigest === undefined ? {} : { profileDigest: run.profileDigest }), runner: run.runner ?? view.runner, model: run.model ?? run.usage?.model ?? view.model, + ...(backend === undefined ? {} : { backend }), ...(run.effort === undefined ? {} : { effort: run.effort }), - ...(run.maxOutputTokens === undefined ? {} : { maxOutputTokens: run.maxOutputTokens }), + ...(run.maxVisibleOutputTokens === undefined + ? {} + : { maxVisibleOutputTokens: run.maxVisibleOutputTokens }), + ...(run.maxReasoningTokens === undefined ? {} : { maxReasoningTokens: run.maxReasoningTokens }), + ...(run.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: run.maxTotalOutputTokens }), connection: run.connection ?? run.connectionId ?? 'not connected', ...(run.connectionId === undefined ? {} : { connectionId: run.connectionId }), ...(environment === undefined ? {} : { environment }), diff --git a/src/views/tui/interaction-presentation.ts b/src/views/tui/interaction-presentation.ts index 1fbe577..f66f673 100644 --- a/src/views/tui/interaction-presentation.ts +++ b/src/views/tui/interaction-presentation.ts @@ -118,11 +118,10 @@ export function interactionHeading(interaction: InteractionView): string { } export function runContext(interaction: InteractionView): string { - const requester = [interaction.profileName, interaction.runner] + return [interaction.profileName, interaction.runner] .map((value) => (value === undefined ? '' : sanitizeTerminalText(value))) .filter((value) => value.length > 0) - .join(' @ ') - return [requester, `run ${shortIdentifier(interaction.runId)}`].filter(Boolean).join(' · ') + .join(' · ') } export function interactionSubjectComponents( @@ -171,7 +170,7 @@ export function answerHelp(interaction: InteractionView): string { if (hasApproval && rejection) return 'answer: y approve · n reject' if (hasApproval) return 'answer: y yes' if (rejection) return 'answer: n reject' - return 'answer: esc cancel' + return 'answer: ←/esc cancel' } if (spec.kind === 'unknown') return `answer: ${sanitizeTerminalText(spec.label)}` if (spec.kind === 'form') return `answer: JSON · ${spec.fields.length} field(s)` @@ -192,7 +191,7 @@ export function interactionFooter(interaction: InteractionView, canAutomate: boo : interaction.answerSpec.kind === 'select' ? ['↑↓ move', 'enter choose'] : ['enter submit'] - if (cancellationOutcome(interaction) !== undefined) parts.push('esc cancel') + if (cancellationOutcome(interaction) !== undefined) parts.push('←/esc cancel') if (canAutomate && !isSecretInteraction(interaction)) parts.push('alt+a automate') return parts.join(' · ') } @@ -207,8 +206,3 @@ function outcomeKeyLabel(outcome: InteractionOutcome): string { if (outcome === 'revise') return 'revise' return 'cancel' } - -function shortIdentifier(value: string): string { - const safe = sanitizeTerminalText(value) - return safe.length <= 20 ? safe : `${safe.slice(0, 12)}…${safe.slice(-6)}` -} diff --git a/src/views/tui/interaction.ts b/src/views/tui/interaction.ts index f03fb46..5c213e0 100644 --- a/src/views/tui/interaction.ts +++ b/src/views/tui/interaction.ts @@ -155,11 +155,7 @@ export class InteractionShell extends Container implements Focusable { this.#onAutomate(response) return } - if ( - matchesKey(data, 'escape') || - matchesKey(data, 'ctrl+c') || - (matchesKey(data, 'left') && this.#decisions) - ) { + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'left')) { this.#cancel() return } diff --git a/src/views/tui/modal-coordinator.ts b/src/views/tui/modal-coordinator.ts index 552227b..53fe0ea 100644 --- a/src/views/tui/modal-coordinator.ts +++ b/src/views/tui/modal-coordinator.ts @@ -14,6 +14,7 @@ export interface ModalBackTarget { interface ModalEntry { readonly component: Component readonly handle: OverlayHandle + readonly fullScreen: boolean readonly onClose?: () => void } @@ -32,13 +33,19 @@ export class ModalCoordinator { if (preempt) this.closeTop() const { fullScreenBelow = 80, onClose, ...overlayOptions } = options const layout = layoutFor(this.#tui.terminal.columns, this.#tui.terminal.rows) + const fullScreen = layout.overlayFullScreen || layout.columns < fullScreenBelow const handle = this.#tui.showOverlay(component, { ...overlayOptions, - ...(layout.overlayFullScreen || layout.columns < fullScreenBelow + ...(fullScreen ? { width: '100%', maxHeight: '100%', anchor: 'top-left' as const, margin: 0 } : {}), }) - this.#entries.push({ component, handle, ...(onClose === undefined ? {} : { onClose }) }) + this.#entries.push({ + component, + handle, + fullScreen, + ...(onClose === undefined ? {} : { onClose }), + }) this.#notifyVisibility() return handle } @@ -67,6 +74,13 @@ export class ModalCoordinator { return true } + backOrCloseIfFullScreen(): boolean { + const entry = this.#top() + if (entry === undefined || !entry.fullScreen) return false + this.backOrClose() + return true + } + closeAll(): void { while (this.#entries.length > 0) this.closeTop() } diff --git a/src/views/tui/profile-editor-rendering.ts b/src/views/tui/profile-editor-rendering.ts index 6a57dc9..55547e0 100644 --- a/src/views/tui/profile-editor-rendering.ts +++ b/src/views/tui/profile-editor-rendering.ts @@ -1,4 +1,4 @@ -import { type Component, Spacer, Text } from '@earendil-works/pi-tui' +import { type Component, Text } from '@earendil-works/pi-tui' import type { BraidViewModel } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' import { safeFieldValue } from './configuration-presenters.js' @@ -8,7 +8,7 @@ export function profileEditorChildren( theme: BraidTheme, view: BraidViewModel, ): readonly Component[] { - const children: Component[] = [new Text(theme.brand('profile'), 1, 0), new Spacer(1)] + const children: Component[] = [] const editor = view.profileEditor if (editor === undefined) { children.push(new Text(theme.muted('No profile details loaded.'), 1, 0)) diff --git a/src/views/tui/profile-editor.ts b/src/views/tui/profile-editor.ts index e2f7641..f02f646 100644 --- a/src/views/tui/profile-editor.ts +++ b/src/views/tui/profile-editor.ts @@ -1,4 +1,4 @@ -import { Container, type Focusable, type SelectItem } from '@earendil-works/pi-tui' +import { Container, type Focusable, matchesKey, type SelectItem } from '@earendil-works/pi-tui' import type { ProfileSummary } from '../../app/profiles.js' import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' import type { BraidViewModel } from '../shared/models.js' @@ -14,6 +14,8 @@ import { within, } from './configuration-presenters.js' import { ResponsiveText } from './configuration-responsive-text.js' +import { focusedSurfaceLines } from './focused-surface.js' +import { profileCompatibilityTextLines } from './profile-compatibility.js' import { profileErrorMessage, profileItemName, @@ -23,7 +25,6 @@ import { validateProfileIntent, } from './profile-editor-actions.js' import { profileEditorChildren } from './profile-editor-rendering.js' -import { profileCompatibilityTextLines } from './profile-compatibility.js' import { SearchableSelector } from './selector.js' import type { BraidTheme } from './theme.js' @@ -32,6 +33,7 @@ interface ProfileEditorOptions { readonly nextOperationId?: () => string readonly query?: string readonly onCancel?: () => void + readonly rows?: () => number } const PROFILE_REFRESH_TIMEOUT_MS = 2_000 @@ -41,6 +43,7 @@ export class ProfileEditorViewPanel extends Container implements Focusable { readonly #controller: BraidUiController | undefined readonly #nextOperationId: (() => string) | undefined readonly #onCancel: (() => void) | undefined + readonly #rows: () => number readonly #status = new ResponsiveText('', 1, 0) readonly #detail = new ResponsiveText('', 1, 0) #selector: SearchableSelector | undefined @@ -56,6 +59,7 @@ export class ProfileEditorViewPanel extends Container implements Focusable { this.#controller = options.controller this.#nextOperationId = options.nextOperationId this.#onCancel = options.onCancel + this.#rows = options.rows ?? (() => 12) if (this.#controller !== undefined) { this.#buildSelector(options.query ?? '') void this.#refresh().catch((error: unknown) => { @@ -78,9 +82,25 @@ export class ProfileEditorViewPanel extends Container implements Focusable { } handleInput(data: string): void { + if (matchesKey(data, 'escape') || matchesKey(data, 'left')) { + this.#onCancel?.() + return + } this.#selector?.handleInput(data) } + override render(width: number): string[] { + return focusedSurfaceLines({ + theme: this.#theme, + title: 'profile', + body: super.render(width), + footer: profileEditorFooter(this.#controller !== undefined, this.#profiles.length, width), + width, + rows: this.#rows(), + preserveTailRows: 5, + }) + } + setView(view: BraidViewModel): void { if (this.#controller !== undefined) return this.clear() @@ -91,11 +111,12 @@ export class ProfileEditorViewPanel extends Container implements Focusable { #buildSelector(query: string): void { if (this.#controller === undefined) return this.#selector = new SearchableSelector({ - title: 'profiles', + title: 'switch profile', items: [], query, maxVisible: 5, - footer: 'enter select · ^V validate · esc close', + footer: 'enter select · ^V validate · ←/esc close', + embedded: true, theme: this.#theme, onSelect: (item) => void this.#select(item), onAction: (key, item) => { @@ -149,7 +170,7 @@ export class ProfileEditorViewPanel extends Container implements Focusable { ? 'No profiles found' : active === undefined ? 'Profiles ready · choose one' - : `Active profile · ${safe(active.name)}`), + : safe(active.name)), ) } @@ -247,7 +268,7 @@ export class ProfileEditorViewPanel extends Container implements Focusable { this.clear() this.addChild(this.#status) this.addChild(this.#detail) - if (this.#selector) this.addChild(this.#selector) + if (this.#selector && this.#profiles.length !== 1) this.addChild(this.#selector) this.invalidate() } @@ -264,3 +285,9 @@ export class ProfileEditorViewPanel extends Container implements Focusable { this.#renderController() } } + +function profileEditorFooter(controller: boolean, profileCount: number, width: number): string { + if (!controller || profileCount === 1) return '←/esc close' + if (width < 60) return 'enter · ^V validate · ←/esc close' + return 'enter select · ^V validate · ←/esc close' +} diff --git a/src/views/tui/selector.ts b/src/views/tui/selector.ts index e6e8dbc..39fc1db 100644 --- a/src/views/tui/selector.ts +++ b/src/views/tui/selector.ts @@ -72,7 +72,7 @@ export class SearchableSelector extends Container implements Focusable { this.#title = new Text(options.theme.brand(sanitizeTerminalText(options.title)), 1, 0) this.#footer = new Text( options.theme.muted( - sanitizeTerminalText(options.footer ?? 'type to filter · enter to choose · esc to close'), + sanitizeTerminalText(options.footer ?? 'type to filter · enter to choose · ←/esc close'), ), 1, 0, @@ -150,7 +150,7 @@ export class SearchableSelector extends Container implements Focusable { this.#list.handleInput(data) return } - if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { + if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c') || matchesKey(data, 'left')) { this.#onCancel() return } diff --git a/src/views/tui/terminal-app.ts b/src/views/tui/terminal-app.ts index a709173..e3777b7 100644 --- a/src/views/tui/terminal-app.ts +++ b/src/views/tui/terminal-app.ts @@ -8,6 +8,7 @@ import type { UiFrameTiming, } from '../shared/intents.js' import type { BraidViewModel } from '../shared/models.js' +import type { NativeInteractiveUiActions } from '../shared/native-interactive-actions.js' import { sanitizeTitle } from '../shared/sanitize.js' import { GuardedAutocompleteProvider } from './autocomplete-guard.js' import type { TerminalConfigurationOptions } from './configuration-wizard.js' @@ -36,6 +37,7 @@ export interface BraidTerminalOptions { readonly onFrameTiming?: (timing: UiFrameTiming) => void readonly tuiStarted?: boolean readonly preinstalledOutputPolicyCleanup?: () => void + readonly nativeInteractive?: NativeInteractiveUiActions } export class BraidTerminalApp { @@ -57,6 +59,7 @@ export class BraidTerminalApp { #removeInputListener: (() => void) | undefined #stopped = false #started = false + #suspended = false #openConfigurationOnStart = false readonly #restoreTerminalOutputPolicy: () => void readonly #tuiStarted: boolean @@ -95,7 +98,21 @@ export class BraidTerminalApp { const autocomplete = new GuardedAutocompleteProvider( new DynamicAutocompleteProvider({ commands: () => - commandItems(this.#controller.view().capabilities).map((item) => ({ + commandItems({ + ...this.#controller.view().capabilities, + ...(options.nativeInteractive === undefined + ? {} + : { + 'run.interactive': { + ...options.nativeInteractive.availability('start'), + source: 'runtime' as const, + }, + 'run.attach': { + ...options.nativeInteractive.availability('attach'), + source: 'runtime' as const, + }, + }), + }).map((item) => ({ name: item.value, description: item.description ?? '', })), @@ -135,6 +152,7 @@ export class BraidTerminalApp { : { connectionLifecycle: options.connectionLifecycle }), dispatchCommand: (command, args) => this.#commands.dispatchCommand(command, args), requestRender: () => this.#tui.requestRender(), + columns: () => this.#tui.terminal.columns, rows: () => this.#tui.terminal.rows, }) this.#interactions = new TerminalInteractionController({ @@ -172,6 +190,9 @@ export class BraidTerminalApp { isStopped: () => this.#stopped, stop: () => this.stop(), composerMode: () => this.#input.composerMode, + ...(options.nativeInteractive === undefined + ? {} + : { nativeInteractive: options.nativeInteractive }), }) this.#shell.editor.setAutocompleteProvider(autocomplete) options.tui.addChild(this.#shell) @@ -208,9 +229,31 @@ export class BraidTerminalApp { return this.#done } + /** Release the local terminal while keeping application state and subscriptions alive. */ + suspend(): void { + if (this.#stopped || !this.#started || this.#suspended) return + this.#suspended = true + this.#removeInputListener?.() + this.#removeInputListener = undefined + this.#tui.stop({ preserveScreen: true }) + } + + /** Reclaim the local terminal and redraw the same Braid application state. */ + resume(): void { + if (this.#stopped || !this.#started || !this.#suspended) return + this.#suspended = false + this.#removeInputListener = this.#tui.addInputListener((data) => this.#input.handle(data)) + this.#tui.start() + if (this.#modals.hasOpen()) this.#modals.focusTop() + else this.#tui.setFocus(this.#shell.editor) + this.#render(this.#controller.view()) + this.#tui.requestRender(true) + } + stop(): void { if (this.#stopped) return this.#stopped = true + this.#suspended = false this.#overlays.dispose() this.#interactions.dispose() this.#input.close() @@ -227,7 +270,6 @@ export class BraidTerminalApp { #render(view: BraidViewModel): void { this.#drafts.restore(view) this.#shell.setView(view, this.#input.quitArmed) - this.#shell.setActivityVisible(this.#input.activityVisible) this.#interactions.sync(view) this.#tui.requestRender() } diff --git a/src/views/tui/terminal-chrome.ts b/src/views/tui/terminal-chrome.ts index 7042155..0e37d3f 100644 --- a/src/views/tui/terminal-chrome.ts +++ b/src/views/tui/terminal-chrome.ts @@ -1,12 +1,18 @@ import type { Component } from '@earendil-works/pi-tui' -import type { BraidViewModel } from '../shared/models.js' +import type { BraidViewModel, EnvironmentView } from '../shared/models.js' import { sanitizeNotification } from '../shared/sanitize.js' -import { executionTargetFor, type ExecutionTargetView } from './execution-target.js' -import { fitTerminalAtomic, renderTerminalContext, terminalValuePart } from './terminal-identity.js' -import { modeForColumns } from './layout.js' -import type { BraidTheme } from './theme.js' -import { metricsFor } from './terminal-usage.js' import type { ComposerMode } from './composer-view.js' +import { type ExecutionTargetView, executionTargetFor } from './execution-target.js' +import { + fitTerminalAtomic, + fitTerminalColumns, + isSyntheticFixture, + renderTerminalContext, + terminalContextModeForColumns, + terminalValuePart, +} from './terminal-identity.js' +import { footerMetricsFor } from './terminal-usage.js' +import type { BraidTheme } from './theme.js' export interface TerminalChromeState { readonly view: BraidViewModel @@ -44,72 +50,141 @@ export class TerminalChrome implements Component { if (!state) return [] const safeWidth = Math.max(1, Math.floor(width)) const { view } = state - const mode = modeForColumns(safeWidth) + const mode = terminalContextModeForColumns(safeWidth) const target = executionTargetFor(view) const transientNotice = state.quitArmed ? undefined : transientNoticeFor(view) - if (transientNotice !== undefined) { - return [fitTerminalAtomic(statusText(this.#theme, view, transientNotice), safeWidth)] + if (mode === 'narrow' && transientNotice !== undefined) { + return boundedTerminalRows([statusText(this.#theme, view, transientNotice)], safeWidth) } - const status = statusText(this.#theme, view, conciseStatus(view, state.quitArmed, mode)) - const hint = terminalValuePart( - this.#theme, - navigationHint(view, state.navigationHint, state.composerMode), - ) - const metrics = - mode === 'wide' - ? metricsFor(view).map((metric) => terminalValuePart(this.#theme, metric)) + const statusValue = transientNotice ?? conciseStatus(view, state.quitArmed, mode) + const status = statusText(this.#theme, view, statusValue) + const navigation = + mode === 'narrow' || transientNotice !== undefined + ? '' + : navigationHint(view, state.navigationHint, state.composerMode, mode) + const hint = terminalValuePart(this.#theme, navigation) + const executionFacts = executionFactsFor(target) + const measured = + mode === 'wide' && !isSyntheticFixture(target.model) + ? footerMetricsFor(view).map((metric) => terminalValuePart(this.#theme, metric)) : [] - const prioritizesAction = state.quitArmed || view.activeRunId !== undefined || status.length > 0 + const identity = { + workspace: view.workspace, + conversationTitle: view.conversationTitle, + branch: view.branch, + profileName: target.profileName, + runner: target.runner, + model: target.model, + ...(target.backend === undefined ? {} : { backend: target.backend }), + connection: target.connection, + ...(target.effort === undefined ? {} : { effort: target.effort }), + ...(target.maxVisibleOutputTokens === undefined + ? {} + : { maxVisibleOutputTokens: target.maxVisibleOutputTokens }), + ...(target.maxReasoningTokens === undefined + ? {} + : { maxReasoningTokens: target.maxReasoningTokens }), + ...(target.maxTotalOutputTokens === undefined + ? {} + : { maxTotalOutputTokens: target.maxTotalOutputTokens }), + } as const + const noticeRow = + transientNotice === undefined ? undefined : fitTerminalAtomic(status, safeWidth) + const idleHint = navigation.startsWith('/ commands') ? [] : [hint] const right = state.quitArmed ? [status] - : view.activeRunId !== undefined - ? [status, hint] - : status.length > 0 + : mode === 'standard' && view.activeRunId !== undefined + ? [status] + : view.activeRunId !== undefined || status.length > 0 ? [status, hint] - : metrics.length > 0 - ? metrics - : [status, hint] - return [ - renderTerminalContext( - this.#theme, - { - workspace: view.workspace, - conversationTitle: view.conversationTitle, - branch: view.branch, - profileName: target.profileName, - runner: target.runner, - model: target.model, - connection: target.connection, - ...(target.effort === undefined ? {} : { effort: target.effort }), - ...(target.maxOutputTokens === undefined - ? {} - : { maxOutputTokens: target.maxOutputTokens }), - execution: executionLabel(target), - }, - right, - safeWidth, - prioritizesAction ? 'right' : 'left', - ), - ] + : idleHint + const identityRow = renderTerminalContext( + this.#theme, + identity, + noticeRow === undefined ? right : [], + safeWidth, + mode === 'narrow' || state.quitArmed ? 'right' : 'left', + ) + const identityRows = noticeRow === undefined ? [identityRow] : [identityRow, noticeRow] + if (mode !== 'wide') return boundedTerminalRows(identityRows, safeWidth) + const detailFacts = executionFacts.map((fact) => terminalValuePart(this.#theme, fact)) + if (detailFacts.length === 0 && measured.length === 0) { + return boundedTerminalRows(identityRows, safeWidth) + } + const detailRow = fitTerminalColumns(detailFacts, measured, safeWidth, 'right') + return boundedTerminalRows([...identityRows, ...(detailRow ? [detailRow] : [])], safeWidth) } } -function executionLabel(target: ExecutionTargetView): string { +function boundedTerminalRows(rows: readonly string[], width: number): string[] { + return rows.map((row) => fitTerminalAtomic(row, width)) +} + +function executionFactsFor(target: ExecutionTargetView): readonly string[] { const environment = target.environment - if (environment === undefined) return '' - if (environment.location === 'local' && environment.provider === 'cli-bridge') return '' - const location = environment.location === 'unknown' ? undefined : environment.location - const executionKind = environment.kind === 'sandbox' ? 'sandbox' : environment.provider - const lifecycle = - environment.lifecycle === 'active' || environment.lifecycle === 'ready' + if (environment?.kind !== 'sandbox') return [] + const facts: string[] = [] + if (environment.runtimeEndpointHost !== undefined) + facts.push(`host ${environment.runtimeEndpointHost}`) + if (environment.machineId !== undefined) facts.push(`machine ${environment.machineId}`) + if (environment.verifiedRegion !== undefined) facts.push(`region ${environment.verifiedRegion}`) + const sample = environment.resourceSample + if (sample !== undefined) { + const currentMemory = finiteNonNegative(sample.memoryCurrentMb) + if (currentMemory !== undefined) facts.push(`mem ${currentMemory}MB`) + const peakMemory = finiteNonNegative(sample.memoryPeakMb) + if (peakMemory !== undefined) facts.push(`peak ${peakMemory}MB`) + const memoryLimit = finiteNonNegative(sample.memoryLimitMb) + if (memoryLimit !== undefined) facts.push(`limit ${memoryLimit}MB`) + const cpuUsec = finiteNonNegative(sample.cpuUsageUsec) + if (cpuUsec !== undefined) facts.push(`cpu ${Math.round(cpuUsec / 1_000)}ms`) + } + const requested = requestedResourceLabel(environment) + if (requested !== undefined) facts.push(requested) + const gpu = environment.gpu + if (gpu !== undefined) { + let gpuFact = `gpu ${gpu.count}× ${gpu.accelerator}` + const measuredCost = finiteNonNegative(gpu.billedCustomerCostUsd) + if (measuredCost !== undefined) { + gpuFact += ` $${measuredCost.toFixed(4)}` + } + facts.push(gpuFact) + } + return facts +} + +function requestedResourceLabel(environment: EnvironmentView): string | undefined { + const resources = environment.requestedResources + if (resources === undefined) return undefined + const parts = [ + finitePositive(resources.cpuCores) === undefined ? undefined : `${resources.cpuCores}cpu`, + memoryLabel(resources.memoryMB), + finitePositive(resources.diskGB) === undefined ? undefined : `${resources.diskGB}GB`, + resources.accelerator === undefined ? undefined - : environment.lifecycle - return [location, executionKind, lifecycle].filter(Boolean).join(' ') + : `${resources.accelerator.count}×${resources.accelerator.kind}`, + ].filter((value): value is string => value !== undefined) + return parts.length === 0 ? undefined : `size ${parts.join(' · ')}` +} + +function memoryLabel(value: number | undefined): string | undefined { + if (finitePositive(value) === undefined) return undefined + return value !== undefined && value >= 1024 && value % 1024 === 0 + ? `${value / 1024}GB` + : `${value}MB` +} + +function finitePositive(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : undefined +} + +function finiteNonNegative(value: number | undefined): number | undefined { + return value !== undefined && Number.isFinite(value) && value >= 0 ? value : undefined } function statusText(theme: BraidTheme, view: BraidViewModel, value: string): string { const safe = sanitizeNotification(value) - if (value === 'outcome unknown') return theme.warning(safe) + if (value === 'outcome unverified') return theme.warning(safe) if (view.status === 'failed' || view.status === 'storage-failure') return theme.danger(safe) if (view.status === 'running' || view.status === 'waiting' || view.status === 'cancelling') return theme.warning(safe) @@ -120,15 +195,15 @@ function statusText(theme: BraidTheme, view: BraidViewModel, value: string): str function conciseStatus( view: BraidViewModel, quitArmed: boolean, - mode: ReturnType, + mode: ReturnType, ): string { if (quitArmed) return 'Ctrl+C again to quit' - if (terminalOutcomeIsUnknown(view)) return 'outcome unknown' + if (terminalOutcomeIsUnknown(view)) return 'outcome unverified' const status = view.status if (status === 'starting') return mode === 'narrow' ? 'starting · Ctrl+C stop' : 'starting · Ctrl+C cancel' if (status === 'streaming' || status === 'running') - return mode === 'narrow' ? 'working · Ctrl+C stop' : 'working · Ctrl+C cancel' + return mode === 'narrow' ? 'working · Ctrl+C stop' : 'Ctrl+C cancel' if (status === 'waiting') return 'waiting for input' if (status === 'detached') return 'running remotely' if (status === 'reconnecting') return 'reconnecting' @@ -147,6 +222,7 @@ function navigationHint( view: BraidViewModel, fallback: string, composerMode: ComposerMode, + mode: ReturnType, ): string { if (view.activeRunId !== undefined) { const queue = view.capabilities['run.queue']?.available === true @@ -161,7 +237,11 @@ function navigationHint( if (view.status === 'failed' || view.status === 'unknown') { return '/activity details · /new' } - return fallback + return mode === 'standard' ? compactNavigationHint(fallback) : fallback +} + +function compactNavigationHint(value: string): string { + return value.includes('/ commands') ? '/ commands' : value } function terminalOutcomeIsUnknown(view: BraidViewModel): boolean { diff --git a/src/views/tui/terminal-command-controller.ts b/src/views/tui/terminal-command-controller.ts index c12659d..06cf055 100644 --- a/src/views/tui/terminal-command-controller.ts +++ b/src/views/tui/terminal-command-controller.ts @@ -12,10 +12,11 @@ import { } from '../shared/command-registry.js' import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' import type { BraidViewModel } from '../shared/models.js' +import type { NativeInteractiveUiActions } from '../shared/native-interactive-actions.js' +import type { ComposerMode } from './composer-view.js' import { executionTargetFor } from './execution-target.js' import type { TerminalDraftController } from './terminal-drafts.js' import type { TerminalOverlayController } from './terminal-overlays.js' -import type { ComposerMode } from './composer-view.js' export interface TerminalCommandControllerOptions { readonly controller: BraidUiController @@ -27,6 +28,7 @@ export interface TerminalCommandControllerOptions { readonly isStopped: () => boolean readonly stop: () => void readonly composerMode: () => ComposerMode + readonly nativeInteractive?: NativeInteractiveUiActions } /** Owns prompt parsing and command routing; it never owns terminal layout. */ @@ -40,6 +42,7 @@ export class TerminalCommandController { readonly #isStopped: () => boolean readonly #stop: () => void readonly #composerMode: () => ComposerMode + readonly #nativeInteractive: NativeInteractiveUiActions | undefined constructor(options: TerminalCommandControllerOptions) { this.#controller = options.controller @@ -51,6 +54,7 @@ export class TerminalCommandController { this.#isStopped = options.isStopped this.#stop = options.stop this.#composerMode = options.composerMode + this.#nativeInteractive = options.nativeInteractive } submit(rawText: string): void { @@ -117,6 +121,10 @@ export class TerminalCommandController { } async #dispatchCommandValue(command: CommandName, args: readonly string[]): Promise { + if (command === 'interactive' || command === 'attach') { + await this.#runNativeInteractive(command, args) + return + } if (command === 'profile' && args.length === 0) { this.#overlays.openProfile() return @@ -209,6 +217,49 @@ export class TerminalCommandController { } }) } + + async #runNativeInteractive( + command: 'interactive' | 'attach', + args: readonly string[], + ): Promise { + const action = command === 'interactive' ? 'start' : 'attach' + const actions = this.#nativeInteractive + if (actions === undefined) { + this.#overlays.openUnavailable( + `/${command}`, + 'Native terminal mode is unavailable in this interface', + ) + return + } + const availability = actions.availability(action) + if (!availability.available) { + this.#overlays.openUnavailable(`/${command}`, availability.reason ?? 'Unavailable') + return + } + await this.#drafts.flush() + let result: Awaited> + try { + result = await actions.run( + command === 'interactive' + ? { + action: 'start', + ...(args.length === 0 ? {} : { initialPrompt: args.join(' ') }), + } + : { action: 'attach', ...(args[0] === undefined ? {} : { runId: args[0] }) }, + ) + } catch (error) { + this.#overlays.openUnavailable( + `/${command}`, + error instanceof Error ? error.message : 'Native terminal operation failed', + ) + return + } + if (result.kind === 'unavailable') { + this.#overlays.openUnavailable(`/${command}`, result.reason) + } else if (result.kind === 'error') { + this.#overlays.openUnavailable(`/${command}`, result.message) + } + } } function steerIntent(operationId: string, text: string): Extract { diff --git a/src/views/tui/terminal-identity.ts b/src/views/tui/terminal-identity.ts index fb99705..e434b51 100644 --- a/src/views/tui/terminal-identity.ts +++ b/src/views/tui/terminal-identity.ts @@ -1,6 +1,5 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { modeForColumns } from './layout.js' import type { BraidTheme } from './theme.js' export interface TerminalIdentityView { @@ -10,10 +9,21 @@ export interface TerminalIdentityView { readonly profileName: string readonly runner: string readonly model: string + readonly backend?: string readonly connection: string readonly effort?: string - readonly maxOutputTokens?: number + readonly maxVisibleOutputTokens?: number + readonly maxReasoningTokens?: number + readonly maxTotalOutputTokens?: number readonly execution?: string + readonly executionFacts?: readonly string[] +} + +export function terminalContextModeForColumns(columns: number): 'narrow' | 'standard' | 'wide' { + const safeColumns = Math.max(1, Math.floor(columns)) + if (safeColumns < 60) return 'narrow' + if (safeColumns >= 100) return 'wide' + return 'standard' } export function renderTerminalIdentity( @@ -33,48 +43,126 @@ export function renderTerminalContext( priority: 'left' | 'right' = 'left', ): string { const safeWidth = Math.max(1, Math.floor(width)) - const mode = modeForColumns(safeWidth) - const model = - mode === 'wide' ? cleanTerminalField(identity.model) : compactModelName(identity.model) + const mode = terminalContextModeForColumns(safeWidth) + const synthetic = isSyntheticFixture(identity.model) + const model = synthetic + ? '' + : mode === 'wide' + ? cleanTerminalField(identity.model) + : compactModelName(identity.model) const profile = terminalValuePart( theme, mode === 'narrow' ? identity.profileName : `profile ${identity.profileName}`, ) - const route = terminalValuePart(theme, `${identity.runner} / ${model}`) - const connection = terminalValuePart( + if (mode === 'narrow') return fitTerminalColumns([profile], right, safeWidth, priority) + + const compactRoute = terminalValuePart( theme, - mode === 'wide' || mode === 'narrow' - ? identity.connection - : compactConnectionName(identity.connection), + model ? `${identity.runner} / ${model}` : identity.runner, ) - const effortValue = cleanTerminalField(identity.effort) - const effort = - mode === 'wide' && effortValue && effortValue !== 'none' && effortValue !== 'off' - ? terminalValuePart(theme, effortValue) + if (mode === 'standard') { + return fitTerminalColumns( + [ + profile, + compactRoute, + terminalValuePart( + theme, + `via ${executionConnectionLabel(identity.backend, identity.connection)}`, + ), + ], + right, + safeWidth, + priority, + ) + } + + const route = terminalValuePart(theme, `harness ${identity.runner}`) + const modelPart = model ? terminalValuePart(theme, `model ${model}`) : '' + const backendLabel = executionBackendLabel(identity.backend, identity.connection) + const backend = terminalValuePart(theme, `backend ${backendLabel}`) + const connectionValue = cleanTerminalField(identity.connection) + const connection = + !synthetic && + connectionValue && + connectionValue.toLocaleLowerCase() !== backendLabel.toLocaleLowerCase() + ? terminalValuePart(theme, `connection ${connectionValue}`) : '' - const execution = terminalValuePart(theme, identity.execution ?? '') + const effortValue = synthetic ? '' : cleanTerminalField(identity.effort) + const effort = effortValue ? terminalValuePart(theme, `think ${effortValue}`) : '' + const configuredLimits = synthetic ? '' : configuredLimitGroup(theme, identity) const branchValue = cleanTerminalField(identity.branch) const branch = - mode === 'wide' && branchValue && branchValue !== 'main' && branchValue !== 'branch-1' + branchValue && branchValue !== 'main' && branchValue !== 'branch-1' ? terminalValuePart(theme, branchValue) : '' return fitTerminalColumns( - [profile, route, connection, effort, execution, branch], + [profile, route, modelPart, backend, connection, effort, configuredLimits, branch], right, safeWidth, priority, ) } +export function isSyntheticFixture(value: string | null | undefined): boolean { + return cleanTerminalField(value).toLocaleLowerCase() === 'fixture/deterministic' +} + +function configuredLimitGroup(theme: BraidTheme, identity: TerminalIdentityView): string { + const limits = [ + identity.maxVisibleOutputTokens === undefined + ? undefined + : `vis ${compactTerminalNumber(identity.maxVisibleOutputTokens)}`, + identity.maxReasoningTokens === undefined + ? undefined + : `reas ${compactTerminalNumber(identity.maxReasoningTokens)}`, + identity.maxTotalOutputTokens === undefined + ? undefined + : `total ${compactTerminalNumber(identity.maxTotalOutputTokens)}`, + ].filter((value): value is string => value !== undefined) + return limits.length === 0 ? '' : terminalValuePart(theme, `caps ${limits.join(' · ')}`) +} + function compactModelName(value: string): string { const safe = cleanTerminalField(value) const segments = safe.split('/').filter((segment) => segment.length > 0) return segments.at(-1) ?? safe } -function compactConnectionName(value: string): string { - const safe = cleanTerminalField(value) - return safe === 'deterministic fixture' ? 'fixture' : safe +function executionBackendLabel(backend: string | undefined, connection: string): string { + const provider = cleanTerminalField(backend).toLocaleLowerCase() + const human = cleanTerminalField(connection) + const normalizedHuman = human.toLocaleLowerCase() + if ( + provider === 'fixture' || + provider === 'deterministic' || + provider === 'deterministic fixture' || + normalizedHuman === 'deterministic fixture' + ) + return 'local' + if (provider === 'cli-bridge') return 'CLI Bridge' + if (/cli bridge/iu.test(human)) return human || 'CLI Bridge' + if (provider === 'tangle-sandbox' || /tangle sandbox/iu.test(human)) return 'Sandbox' + if (provider === 'tangle-inference' || /tangle inference/iu.test(human)) return 'Tangle inference' + return human || cleanTerminalField(backend) || 'local' +} + +function executionConnectionLabel(backend: string | undefined, connection: string): string { + const provider = cleanTerminalField(backend).toLocaleLowerCase() + const human = cleanTerminalField(connection) + const normalizedHuman = human.toLocaleLowerCase() + if ( + provider === 'fixture' || + provider === 'deterministic' || + provider === 'deterministic fixture' || + normalizedHuman === 'deterministic fixture' + ) + return 'local' + if (provider === 'cli-bridge' || /cli bridge/iu.test(human)) return human || 'CLI Bridge' + if (provider === 'tangle-sandbox' || /tangle sandbox/iu.test(human)) return 'Sandbox' + if (provider === 'tangle-inference' || /tangle inference/iu.test(human)) { + return 'Tangle inference' + } + return human || executionBackendLabel(backend, connection) } export function cleanTerminalField(value: string | null | undefined): string { diff --git a/src/views/tui/terminal-input-controller.ts b/src/views/tui/terminal-input-controller.ts index f36d753..56b098d 100644 --- a/src/views/tui/terminal-input-controller.ts +++ b/src/views/tui/terminal-input-controller.ts @@ -1,8 +1,6 @@ import { matchesKey, type TUI } from '@earendil-works/pi-tui' import { commandAvailability } from '../shared/command-registry.js' import type { BraidIntent, BraidUiController, UiDispatchResult } from '../shared/intents.js' -import type { BraidViewModel } from '../shared/models.js' -import { hasLiveWork } from './activity.js' import type { ComposerMode } from './composer-view.js' import { type BraidKeymap, isTextInputSequence, matchesKeyAction } from './keyboard.js' import type { ModalCoordinator } from './modal-coordinator.js' @@ -25,13 +23,6 @@ export interface TerminalInputControllerOptions { readonly stateChanged: () => void } -export type ActivityVisibility = 'auto' | 'hidden' | 'visible' - -export function activityVisibleFor(view: BraidViewModel, visibility: ActivityVisibility): boolean { - if (visibility !== 'visible') return false - return hasLiveWork(view) -} - /** Routes non-text terminal keys and owns the short-lived quit/activity state. */ export class TerminalInputController { readonly #tui: TUI @@ -48,7 +39,6 @@ export class TerminalInputController { readonly #stateChanged: () => void #quitTimer: ReturnType | undefined #quitArmed = false - #activityVisibility: ActivityVisibility = 'auto' #composerMode: ComposerMode = 'queue' constructor(options: TerminalInputControllerOptions) { @@ -70,10 +60,6 @@ export class TerminalInputController { return this.#quitArmed } - get activityVisible(): boolean { - return activityVisibleFor(this.#controller.view(), this.#activityVisibility) - } - get composerMode(): ComposerMode { return this.#composerMode } @@ -91,6 +77,13 @@ export class TerminalInputController { } if (isTextInputSequence(data)) return undefined if (matchesKeyAction(data, this.#keymap, 'closeOverlay') && this.#tui.hasOverlay()) { + if ( + matchesKey(data, 'left') && + !this.#interactionOpen() && + this.#modals.backOrCloseIfFullScreen() + ) { + return { consume: true } + } if (this.#interactionOpen()) return undefined if (matchesKey(data, 'left')) { if (!this.#modals.backOrCloseIfPassive()) return undefined @@ -141,8 +134,7 @@ export class TerminalInputController { return { consume: true } } if (matchesKeyAction(data, this.#keymap, 'activity')) { - this.#activityVisibility = this.activityVisible ? 'hidden' : 'visible' - this.#stateChanged() + this.#overlays.openSurface('activity') return { consume: true } } if ( diff --git a/src/views/tui/terminal-interaction-controller.ts b/src/views/tui/terminal-interaction-controller.ts index bf9eae0..a75c1bb 100644 --- a/src/views/tui/terminal-interaction-controller.ts +++ b/src/views/tui/terminal-interaction-controller.ts @@ -123,6 +123,7 @@ export class TerminalInteractionController { width: '100%', maxHeight: '100%', margin: 0, + fullScreenBelow: Number.MAX_SAFE_INTEGER, }) this.#syncRefresh(interaction) } diff --git a/src/views/tui/terminal-overlays.ts b/src/views/tui/terminal-overlays.ts index 40a1475..f75081e 100644 --- a/src/views/tui/terminal-overlays.ts +++ b/src/views/tui/terminal-overlays.ts @@ -35,6 +35,7 @@ export interface TerminalOverlayOptions { readonly connectionLifecycle?: UiConnectionLifecycle readonly dispatchCommand: (command: CommandName, args: readonly string[]) => void readonly requestRender: () => void + readonly columns: () => number readonly rows: () => number } @@ -44,9 +45,11 @@ export class TerminalOverlayController { readonly #modals: ModalCoordinator readonly #editor: Editor readonly #nextOperationId: () => string + readonly #rows: () => number readonly #dispatchCommand: TerminalOverlayOptions['dispatchCommand'] readonly #configuration: TerminalOverlayOptions['configuration'] readonly #requestRender: TerminalOverlayOptions['requestRender'] + readonly #columns: () => number readonly #conversations: ConversationOverlayController readonly #connectionWorkflow: ConnectionOverlayWorkflow | undefined readonly #surfaces: TerminalSurfaceOverlays @@ -58,6 +61,8 @@ export class TerminalOverlayController { this.#modals = options.modals this.#editor = options.editor this.#nextOperationId = options.nextOperationId + this.#rows = options.rows + this.#columns = options.columns this.#conversations = new ConversationOverlayController({ theme: this.#theme, controller: this.#controller, @@ -116,8 +121,15 @@ export class TerminalOverlayController { nextOperationId: this.#nextOperationId, query, onCancel: () => this.#modals.closeTop(), + rows: this.#rows, + }) + this.#modals.open(panel, { + anchor: 'top-left', + width: '100%', + maxHeight: '100%', + margin: 0, + fullScreenBelow: Number.MAX_SAFE_INTEGER, }) - this.#modals.open(panel, { anchor: 'center', width: '88%', minWidth: 44, maxHeight: '90%' }) } openConnectionEditor(): void { @@ -186,13 +198,24 @@ export class TerminalOverlayController { title: 'Commands', items: commandItems(view.capabilities), theme: this.#theme, + maxVisible: Math.max(1, Math.min(8, this.#rows() - 6)), + footer: + this.#columns() < 60 + ? 'enter choose · ←/esc close' + : 'type to filter · enter to choose · ←/esc close', onSelect: (item) => { this.#modals.closeTop() this.#dispatchCommand(item.value as CommandName, []) }, onCancel: () => this.#modals.closeTop(), }) - this.#modals.open(palette, { anchor: 'center', width: '70%', minWidth: 32, maxHeight: 14 }) + this.#modals.open(palette, { + anchor: 'top-left', + width: '100%', + maxHeight: '100%', + margin: 0, + fullScreenBelow: Number.MAX_SAFE_INTEGER, + }) } openSwitcher(): void { @@ -229,7 +252,7 @@ export class TerminalOverlayController { items, maxVisible: 5, theme: this.#theme, - footer: 'enter to change · esc to close', + footer: 'enter to change · ←/esc close', onSelect: (item) => { this.#modals.closeTop() if (item.value === 'profile') this.openProfile() diff --git a/src/views/tui/terminal-shell.ts b/src/views/tui/terminal-shell.ts index 8347e07..348a347 100644 --- a/src/views/tui/terminal-shell.ts +++ b/src/views/tui/terminal-shell.ts @@ -8,7 +8,6 @@ import { } from '@earendil-works/pi-tui' import type { BraidViewModel } from '../shared/models.js' import { sanitizeTerminalText } from '../shared/sanitize.js' -import { ActivityView, hasLiveWork } from './activity.js' import { type ComposerMode, ComposerView } from './composer-view.js' import { layoutFor } from './layout.js' import { TerminalChrome } from './terminal-chrome.js' @@ -17,13 +16,11 @@ import { TranscriptView } from './transcript.js' export class BraidShell extends Container { readonly #transcript: TranscriptView - readonly #activity: ActivityView readonly #chrome: TerminalChrome readonly #composer: ComposerView readonly #theme: BraidTheme readonly #rows: () => number #view: BraidViewModel | undefined - #showActivity = false #modalVisible = false #quitArmed = false #composerMode: ComposerMode = 'queue' @@ -39,14 +36,12 @@ export class BraidShell extends Container { this.#theme = theme this.#rows = rows this.#transcript = new TranscriptView(theme) - this.#activity = new ActivityView(theme) this.#chrome = new TerminalChrome(theme) const editor = new Editor(tui, theme.editor, { paddingX: 1 }) editor.onSubmit = onSubmit editor.onChange = onChange this.#composer = new ComposerView({ editor, rows, theme }) this.addChild(this.#transcript) - this.addChild(this.#activity) this.addChild(this.#composer) } @@ -61,11 +56,6 @@ export class BraidShell extends Container { this.#refreshChrome() } - setActivityVisible(visible: boolean): void { - this.#showActivity = visible - this.invalidate() - } - setModalVisible(visible: boolean): void { this.#modalVisible = visible this.invalidate() @@ -75,12 +65,11 @@ export class BraidShell extends Container { this.#view = view this.#quitArmed = quitArmed this.#transcript.setView(view) - this.#activity.setView(view) this.#composer.setView(view) this.#chrome.setState({ view, quitArmed, - activityVisible: this.#showActivity, + activityVisible: false, navigationHint: this.#transcript.navigationHint(), composerMode: this.#composerMode, }) @@ -121,32 +110,20 @@ export class BraidShell extends Container { override render(width: number): string[] { const view = this.#view if (!view) return super.render(width) - const showActivity = this.#showActivity && hasLiveWork(view) - const layout = layoutFor(width, this.#rows(), showActivity) + const layout = layoutFor(width, this.#rows(), false) const editorLines = this.#modalVisible ? [] : this.#composer.render(width) const topChrome = this.#chrome.renderTop(width) - const bottomChrome = this.#chrome.renderBottom(width) + const bottomChrome = this.#modalVisible ? [] : this.#chrome.renderBottom(width) const dockRows = topChrome.length + bottomChrome.length + editorLines.length const contentRows = Math.max(1, layout.rows - dockRows) this.#transcript.setViewportRows(contentRows) - const transcriptLines = this.#transcript.render(layout.transcriptWidth) + const transcriptLines = this.#modalVisible + ? [] + : this.#transcript.render(layout.transcriptWidth) this.#refreshChrome() - const activityLines = - layout.mode === 'wide' && showActivity - ? tail(this.#activity.render(layout.activityWidth), contentRows) - : [] - const content: string[] = [] - for (let index = 0; index < contentRows; index += 1) { - const transcript = transcriptLines[index] ?? '' - if (activityLines.length === 0) { - content.push(truncateToWidth(transcript, width, '…', true)) - } else { - const activity = activityLines[index] ?? '' - content.push( - `${truncateToWidth(transcript, layout.transcriptWidth, '…', true)}${this.#theme.muted('│')}${truncateToWidth(activity, layout.activityWidth, '…', true)}`, - ) - } - } + const content = Array.from({ length: contentRows }, (_, index) => + truncateToWidth(transcriptLines[index] ?? '', width, '…', true), + ) return [...topChrome, ...content, ...editorLines, ...this.#chrome.renderBottom(width)].slice( 0, layout.rows, @@ -159,7 +136,7 @@ export class BraidShell extends Container { this.#chrome.setState({ view, quitArmed: this.#quitArmed, - activityVisible: this.#showActivity && hasLiveWork(view), + activityVisible: false, navigationHint: this.#transcript.navigationHint(), composerMode: this.#composerMode, }) @@ -167,13 +144,6 @@ export class BraidShell extends Container { } } -function tail(lines: readonly string[], count: number): string[] { - if (lines.length <= count) return [...lines] - const header = lines[0] - if (header === undefined || count <= 1) return lines.slice(-count) - return [header, ...lines.slice(-(count - 1))] -} - export class UnavailablePanel extends Container { constructor(theme: BraidTheme, title: string, reason: string) { super() @@ -182,6 +152,6 @@ export class UnavailablePanel extends Container { this.addChild( new Text(theme.muted('This action is unavailable with the selected connection.'), 1, 0), ) - this.addChild(new Text(theme.muted('esc close'), 1, 0)) + this.addChild(new Text(theme.muted('←/esc close'), 1, 0)) } } diff --git a/src/views/tui/terminal-surface-overlays.ts b/src/views/tui/terminal-surface-overlays.ts index 5435190..81597e6 100644 --- a/src/views/tui/terminal-surface-overlays.ts +++ b/src/views/tui/terminal-surface-overlays.ts @@ -294,7 +294,7 @@ export class TerminalSurfaceOverlays { }, ], theme: this.#options.theme, - footer: 'enter open · esc close', + footer: 'enter open · ←/esc close', onSelect: (item) => { this.#options.modals.closeTop() if (item.value === 'profile') this.#options.openProfile() diff --git a/src/views/tui/terminal-usage.ts b/src/views/tui/terminal-usage.ts index 83d36c7..4491662 100644 --- a/src/views/tui/terminal-usage.ts +++ b/src/views/tui/terminal-usage.ts @@ -10,6 +10,32 @@ export function metricsFor(view: BraidViewModel): string[] { ].filter((value): value is string => value !== undefined) } +/** Returns only compact direct-run measurements suitable for the persistent footer. */ +export function footerMetricsFor(view: BraidViewModel): string[] { + const usage = view.sessionUsage.turns + return [ + ...tokenMetrics(usage), + measuredCostMetric(usage), + measurementMetric( + 'latency', + usage.llmLatencyMs, + usage.latencyStatus, + (value) => `${Math.round(value)}ms`, + ), + ].filter((value): value is string => value !== undefined) +} + +function measuredCostMetric(usage: UsageTotalsView): string | undefined { + const reported = finiteNonNegative(usage.costUsd) + if (usage.costStatus === 'reported' && reported !== undefined) { + return `$${reported.toFixed(4)}` + } + if (usage.costStatus === 'observed-floor' && reported !== undefined && reported > 0) { + return `≥$${reported.toFixed(4)}` + } + return undefined +} + function usageGroup(label: string, usage: UsageTotalsView): string | undefined { if (usage.sourceCount === 0) return undefined const metrics = usageMetrics(usage) @@ -26,7 +52,7 @@ function usageMetrics(usage: UsageTotalsView): string[] { String(Math.round(value)), ), measurementMetric( - 'model', + 'latency', usage.llmLatencyMs, usage.latencyStatus, (value) => `${Math.round(value)}ms`, diff --git a/src/views/tui/transcript.ts b/src/views/tui/transcript.ts index c19e9e4..3e46db2 100644 --- a/src/views/tui/transcript.ts +++ b/src/views/tui/transcript.ts @@ -86,9 +86,9 @@ export class TranscriptView extends Container { : detailCount return `Ctrl+E next detail (${current}/${detailCount})` } - if (detailCount === 1) return 'Ctrl+E detail' + if (detailCount === 1) return 'Ctrl+E details' if (this.canScroll) return 'PgUp/PgDn history' - return 'Ctrl+P commands' + return '/ commands · Ctrl+P' } hasCollapsibleDetails(): boolean { @@ -146,6 +146,13 @@ export class TranscriptView extends Container { override render(width: number): string[] { const allLines = super.render(Math.max(1, Math.floor(width))) this.#lastLines = allLines + if (this.#view?.messages.length === 0 && (this.#view.queue?.length ?? 0) === 0) { + const prompt = allLines.at(-1) ?? '' + const promptRow = Math.max(0, this.#viewportRows - 1) + return [...Array.from({ length: Math.max(0, promptRow - 1) }, () => ''), prompt].slice( + -this.#viewportRows, + ) + } const max = Math.max(0, allLines.length - this.#viewportRows) if (this.#pendingHistoryPosition === 'start') { this.#scrollTop = 0 diff --git a/test/activity-document.test.ts b/test/activity-document.test.ts index b7c33da..3cecd3d 100644 --- a/test/activity-document.test.ts +++ b/test/activity-document.test.ts @@ -8,7 +8,6 @@ import { ActivityView } from '../src/views/tui/activity.js' import { activityDocument } from '../src/views/tui/activity-browser.js' import { projectActivityDocument } from '../src/views/tui/activity-document.js' import { omitUnreportedCostAndLatency } from '../src/views/tui/measurement-display.js' -import { activityVisibleFor } from '../src/views/tui/terminal-input-controller.js' import { BraidShell } from '../src/views/tui/terminal-shell.js' import { createBraidTheme } from '../src/views/tui/theme.js' import { VirtualTerminal } from './support/virtual-terminal.js' @@ -91,10 +90,7 @@ test('one activity document preserves event status, tree identity, duration, usa ], }) const missingDetail = missingUsage.rows.find((row) => row.id === 'run-1')?.detailLines.join('\n') - assert.match(missingDetail ?? '', /model calls: not reported/u) - assert.match(missingDetail ?? '', /model latency: not reported/u) - assert.match(missingDetail ?? '', /token measurement: not reported/u) - assert.match(missingDetail ?? '', /cost measurement: not reported/u) + assert.doesNotMatch(missingDetail ?? '', /model calls|model latency|not reported/u) const rail = new ActivityView(theme) rail.setView(view) @@ -102,14 +98,7 @@ test('one activity document preserves event status, tree identity, duration, usa assert.doesNotMatch(rail.render(80).join('\n'), /failed shell|permission denied/u) }) -test('the wide live-work rail uses a divider and remains explicitly opt-in', async () => { - const live = activityView() - const idle = { ...live, activity: [], messages: [] } - assert.equal(activityVisibleFor({ ...idle, activeRunId: 'run-1' }, 'auto'), false) - assert.equal(activityVisibleFor(idle, 'visible'), false) - assert.equal(activityVisibleFor(live, 'visible'), true) - assert.equal(activityVisibleFor({ ...idle, activeRunId: 'run-1' }, 'hidden'), false) - +test('live work stays in the focused activity surface instead of docking beside the transcript', async () => { const view = activityView() const terminal = new VirtualTerminal(120, 30) const tui = new TuiMainScreen(terminal) @@ -120,16 +109,12 @@ test('the wide live-work rail uses a divider and remains explicitly opt-in', asy () => {}, () => {}, ) - shell.setView(idle, false) - shell.setActivityVisible(true) - assert.doesNotMatch(shell.render(120).join('\n'), /│/u) shell.setView(view, false) - shell.setActivityVisible(true) tui.addChild(shell) tui.start() await terminal.waitForRender() - assert.match(terminal.getViewport().join('\n'), /│/u) + assert.doesNotMatch(terminal.getViewport().join('\n'), /│/u) tui.stop() }) diff --git a/test/analysis-model-call-roundtrip.test.ts b/test/analysis-model-call-roundtrip.test.ts index 567e39a..794a06f 100644 --- a/test/analysis-model-call-roundtrip.test.ts +++ b/test/analysis-model-call-roundtrip.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { buildBraidViewModel } from '../src/adapters/tui/ui-view-model.js' import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/composition.js' import { importAnalysisModelCalls } from '../src/app/conversation-import-analyses.js' import { MemoryJournal } from '../src/app/journal.js' -import { buildBraidViewModel } from '../src/adapters/tui/ui-view-model.js' import { canonicalDigest } from '../src/domain/canonical.js' import type { AnalysisRecord } from '../src/domain/entities.js' import type { BraidEventEnvelope } from '../src/domain/events.js' @@ -11,7 +11,11 @@ import { createAnalysisId, createEventId } from '../src/domain/ids.js' import { replayEvents } from '../src/domain/reducer.js' import { initialState } from '../src/domain/state.js' import { FixedClock } from '../src/ports/clock.js' -import { analysisModelCallSummary } from '../src/views/shared/analysis-model-call-presentation.js' +import { + analysisMeasuredModelCallLine, + analysisMeasuredModelCallSummary, + analysisModelCallSummary, +} from '../src/views/shared/analysis-model-call-presentation.js' import { queryDetails } from '../src/views/shared/semantic-details.js' const startedAt = '2026-08-10T00:00:00.000Z' @@ -235,7 +239,7 @@ test('analysis model-call summary keeps partial telemetry explicit', () => { outcome: 'failed', }, ]), - '2 calls · tokens ≥120 in / ≥45 out (+1 unknown) · cost ≥$0.0123 (+1 unknown) · model ≥88ms (+1 unknown) · 1 failed', + '2 calls · tokens ≥120 in / ≥45 out (+1 unknown) · cost ≥$0.0123 (+1 unknown) · latency ≥88ms (+1 unknown) · 1 failed', ) }) @@ -254,7 +258,36 @@ test('analysis model-call summary distinguishes estimates from exact charges', ( outcome: 'succeeded', }, ]), - '1 call · tokens 20 in / 8 out · cost ~$0.0042 · model 40ms', + '1 call · tokens 20 in / 8 out · cost ~$0.0042 · latency 40ms', + ) +}) + +test('compact analysis surfaces omit unmeasured usage and latency', () => { + const partial = { + sequence: 2, + model: 'glm-5.2', + tokensKnown: false, + costStatus: 'unknown' as const, + outcome: 'failed' as const, + } + assert.equal(analysisMeasuredModelCallLine(partial), '#2 glm-5.2') + assert.equal( + analysisMeasuredModelCallSummary([ + { + sequence: 1, + provider: 'openai', + model: 'gpt-5.6-luna', + inputTokens: 120, + outputTokens: 45, + tokensKnown: true, + costUsd: 0.0123, + costStatus: 'observed', + latencyMs: 88, + outcome: 'succeeded', + }, + partial, + ]), + '2 calls · tokens ≥120 in / ≥45 out · cost $0.0123 · latency ≥88ms · 1 failed', ) }) diff --git a/test/application.test.ts b/test/application.test.ts index 260553e..770b81f 100644 --- a/test/application.test.ts +++ b/test/application.test.ts @@ -366,7 +366,10 @@ test('restart keeps exact loaded metadata for the matching durable profile', asy harness: 'pi', model: { default: 'openai/gpt-5.6-luna', - metadata: { maxTokens: 8192, route: 'private-runtime-choice' }, + maxVisibleOutputTokens: 8192, + maxReasoningTokens: 16_384, + maxTotalOutputTokens: 24_576, + metadata: { route: 'private-runtime-choice' }, }, }) const source = createProfileRecord( @@ -412,10 +415,10 @@ test('restart keeps exact loaded metadata for the matching durable profile', asy params: { ref: source.id }, }) assert.equal(JSON.stringify(first.state()).includes('private-runtime-choice'), false) - assert.deepEqual(first.state().profile.model?.metadata, { - maxTokens: 8192, - redacted: '[redacted]', - }) + assert.deepEqual(first.state().profile.model?.metadata, { redacted: '[redacted]' }) + assert.equal(first.state().profile.model?.maxVisibleOutputTokens, 8192) + assert.equal(first.state().profile.model?.maxReasoningTokens, 16_384) + assert.equal(first.state().profile.model?.maxTotalOutputTokens, 24_576) const durableProfileSelection = JSON.stringify(journal.all()) assert.equal(durableProfileSelection.includes('private-runtime-choice'), false) assert.equal(durableProfileSelection.includes(canonicalAgentProfileDigestHex(exactProfile)), true) @@ -430,17 +433,20 @@ test('restart keeps exact loaded metadata for the matching durable profile', asy }) await restarted.send({ operationId: 'op-after-profile-restart', text: 'continue' }).completion - assert.deepEqual(executionProfiles[0]?.model?.metadata, { - maxTokens: 8192, - route: 'private-runtime-choice', - }) + assert.deepEqual(executionProfiles[0]?.model?.metadata, { route: 'private-runtime-choice' }) + assert.equal(executionProfiles[0]?.model?.maxVisibleOutputTokens, 8192) + assert.equal(executionProfiles[0]?.model?.maxReasoningTokens, 16_384) + assert.equal(executionProfiles[0]?.model?.maxTotalOutputTokens, 24_576) const mismatchedProfile = defineAgentProfile({ name: exactProfile.name, harness: exactProfile.harness, model: { default: exactProfile.model?.default ?? 'openai/gpt-5.6-luna', - metadata: { maxTokens: 4096, route: 'different-private-runtime-choice' }, + maxVisibleOutputTokens: 4096, + maxReasoningTokens: 8_192, + maxTotalOutputTokens: 12_288, + metadata: { route: 'different-private-runtime-choice' }, }, }) const mismatchedRestart = new BraidApplication({ @@ -460,10 +466,10 @@ test('restart keeps exact loaded metadata for the matching durable profile', asy JSON.stringify(executionProfiles[1]).includes('different-private-runtime-choice'), false, ) - assert.deepEqual(executionProfiles[1]?.model?.metadata, { - maxTokens: 8192, - redacted: '[redacted]', - }) + assert.deepEqual(executionProfiles[1]?.model?.metadata, { redacted: '[redacted]' }) + assert.equal(executionProfiles[1]?.model?.maxVisibleOutputTokens, 8192) + assert.equal(executionProfiles[1]?.model?.maxReasoningTokens, 16_384) + assert.equal(executionProfiles[1]?.model?.maxTotalOutputTokens, 24_576) assert.equal( mismatchedRestart.state().profiles[0]?.executionDigest, canonicalAgentProfileDigestHex(exactProfile), @@ -498,10 +504,10 @@ test('restart keeps exact loaded metadata for the matching durable profile', asy }).completion assert.equal(JSON.stringify(executionProfiles[2]).includes('private-runtime-choice'), false) - assert.deepEqual(executionProfiles[2]?.model?.metadata, { - maxTokens: 8192, - redacted: '[redacted]', - }) + assert.deepEqual(executionProfiles[2]?.model?.metadata, { redacted: '[redacted]' }) + assert.equal(executionProfiles[2]?.model?.maxVisibleOutputTokens, 8192) + assert.equal(executionProfiles[2]?.model?.maxReasoningTokens, 16_384) + assert.equal(executionProfiles[2]?.model?.maxTotalOutputTokens, 24_576) }) test('an identical operation is replayed and conflicting input is rejected', async () => { diff --git a/test/automation-interaction-commands.test.ts b/test/automation-interaction-commands.test.ts index 2b19f4a..2be600b 100644 --- a/test/automation-interaction-commands.test.ts +++ b/test/automation-interaction-commands.test.ts @@ -11,13 +11,14 @@ import { rebindInteractionRequest, } from '../src/app/interaction-request.js' import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' -import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' +import type { ExecutionPort } from '../src/ports/execution.js' import type { BraidResponse } from '../src/views/headless/protocol.js' import { runRpc } from '../src/views/headless/rpc.js' import { parseRequest } from '../src/views/headless/rpc-parser.js' import type { InteractionView } from '../src/views/shared/models.js' import { InteractionShell } from '../src/views/tui/interaction.js' import { createBraidTheme } from '../src/views/tui/theme.js' +import { interactionResponseRunCapabilities } from './support/run-capabilities.js' function request(id: string, runId = 'run-request'): InteractionRequest { const material: InteractionRequestMaterial = { @@ -47,7 +48,7 @@ function interactionExecution(source: InteractionRequest): { let release: (() => void) | undefined let responses = 0 const execution: ExecutionPort = { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable { yield { type: 'interaction', diff --git a/test/cli-bridge-interactions.test.ts b/test/cli-bridge-interactions.test.ts new file mode 100644 index 0000000..d203e21 --- /dev/null +++ b/test/cli-bridge-interactions.test.ts @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { type AgentExactRunControlRef, defineAgentProfile } from '@tangle-network/agent-interface' +import { + createCliBridgeRetainedPlan, + startCliBridgeRetainedRun, +} from '../src/adapters/runtime/cli-bridge-retained-run.js' +import { prepareCliBridgeConnection } from '../src/adapters/runtime/production-cli-bridge-backend.js' +import { ConnectionRegistry } from '../src/app/connections.js' +import type { ConnectionRecord } from '../src/domain/entities.js' +import { createConnectionId } from '../src/domain/ids.js' +import type { RunAdmissionReceipt } from '../src/domain/run-contracts.js' +import { + DEFAULT_RUN_CAPABILITIES, + type ExecuteTurnInput, + type RetainedRunAdmissionRecord, +} from '../src/ports/execution.js' +import { startRuntimeBridgeServer } from './support/runtime-bridge-server.js' + +const createdAt = '2026-08-15T00:00:00.000Z' + +test('fresh CLI Bridge plans keep provider identity unknown until Runtime admission', async () => { + const bridge = await startRuntimeBridgeServer() + const connection: ConnectionRecord = { + id: createConnectionId('connection-cli-identity'), + kind: 'cli-bridge', + name: 'CLI Bridge identity test', + endpoint: bridge.endpoint, + providerOptions: { transport: 'local' }, + createdAt, + updatedAt: createdAt, + lastHealth: { status: 'unknown' }, + } + const profile = defineAgentProfile({ + name: 'CLI Bridge identity test', + harness: 'pi', + model: { provider: 'openai', default: 'openai/gpt-5' }, + }) + const input: ExecuteTurnInput = { + operationId: 'operation-cli-identity', + runId: 'run-cli-identity', + text: 'Retain this exact provider identity.', + profile, + connectionId: connection.id, + workspaceRoot: '/workspace', + signal: new AbortController().signal, + } + const options = { + connections: new ConnectionRegistry([connection]), + workspaceCwd: '/workspace', + select: () => ({ connection: { connectionId: connection.id } }), + } + + try { + const prepared = await prepareCliBridgeConnection( + options, + input, + { connection: { connectionId: connection.id } }, + connection.id, + bridge.endpoint, + ) + const fresh = await createCliBridgeRetainedPlan(prepared, input.runId) + assert.equal(fresh.environmentId, undefined) + assert.match(fresh.environmentIdempotencyKey, /^environment-braid-/u) + assert.equal(fresh.materializationReceipt.environmentId, undefined) + + const exact: AgentExactRunControlRef = { + runId: 'provider-run-cli-identity', + provider: 'cli-bridge', + environmentId: 'cb1.opaque-cli-identity', + sessionId: 'session-cli-identity', + executionId: 'execution-cli-identity', + requestDigest: `sha256:${'d'.repeat(64)}`, + } + const recovered = await createCliBridgeRetainedPlan(prepared, input.runId, exact) + assert.equal(recovered.environmentId, exact.environmentId) + assert.equal(recovered.environmentIdempotencyKey, 'environment-braid-run-cli-identity') + assert.notEqual(recovered.environmentId, recovered.environmentIdempotencyKey) + } finally { + await bridge.close() + } +}) + +test('retained CLI Bridge replays a persisted intent through Runtime', async () => { + const bridge = await startRuntimeBridgeServer({ responseText: 'CLI_BRIDGE_REPLAY_OK' }) + const connection: ConnectionRecord = { + id: createConnectionId('connection-cli-replay'), + kind: 'cli-bridge', + name: 'CLI Bridge replay test', + endpoint: bridge.endpoint, + providerOptions: { transport: 'local' }, + createdAt, + updatedAt: createdAt, + lastHealth: { status: 'unknown' }, + } + const profile = defineAgentProfile({ + name: 'CLI Bridge replay test', + harness: 'pi', + model: { provider: 'openai', default: 'openai/gpt-5' }, + }) + const input: ExecuteTurnInput = { + operationId: 'operation-cli-replay', + runId: 'run-cli-replay', + text: 'Replay this retained intent exactly.', + profile, + connectionId: connection.id, + workspaceRoot: '/workspace', + signal: new AbortController().signal, + onRetainedAdmission: async () => {}, + } + const options = { + connections: new ConnectionRegistry([connection]), + workspaceCwd: '/workspace', + select: () => ({ connection: { connectionId: connection.id } }), + } + + try { + const prepared = await prepareCliBridgeConnection( + options, + input, + { connection: { connectionId: connection.id } }, + connection.id, + bridge.endpoint, + ) + const plan = await createCliBridgeRetainedPlan(prepared, input.runId) + const admissions: RetainedRunAdmissionRecord[] = [] + await assert.rejects( + startCliBridgeRetainedRun(plan, { + ...input, + onRetainedAdmission: async (admission) => { + admissions.push(structuredClone(admission)) + if (admission.phase === 'intent') throw new Error('simulated process death') + }, + }), + (error: unknown) => + error instanceof Error && + error.cause instanceof Error && + error.cause.message === 'simulated process death', + ) + const intent = admissions[0] + if (intent?.phase !== 'intent') throw new Error('the retained intent was not persisted') + + const receipt: RunAdmissionReceipt = { + version: 1, + runId: input.runId, + turnId: input.operationId, + operationId: input.operationId, + conversationId: 'conversation-cli-replay', + branchId: 'branch-cli-replay', + admittedAt: createdAt, + profileDigest: `sha256:${'a'.repeat(64)}`, + requested: { + text: input.text, + profile, + connectionId: connection.id, + interactions: {}, + }, + capabilities: DEFAULT_RUN_CAPABILITIES, + requestDigest: `sha256:${'b'.repeat(64)}`, + capabilitiesDigest: `sha256:${'c'.repeat(64)}`, + digest: `sha256:${'d'.repeat(64)}`, + } + const recoveredAdmissions: RetainedRunAdmissionRecord[] = [] + const recovered = await plan.recover?.({ + admission: intent, + receipt, + onRetainedAdmission: async (admission) => { + recoveredAdmissions.push(structuredClone(admission)) + }, + }) + assert.ok(recovered) + assert.deepEqual( + recoveredAdmissions.map((admission) => admission.phase), + ['environment', 'dispatched'], + ) + assert.equal(bridge.requests.length, 1) + assert.equal(recovered.controlRef.sessionId, prepared.providerSessionId) + } finally { + await bridge.close() + } +}) + +test('retained CLI Bridge receives the exact admitted interaction map', async () => { + const bridge = await startRuntimeBridgeServer({ responseText: 'CLI_BRIDGE_INTERACTION_OK' }) + const connection: ConnectionRecord = { + id: createConnectionId('connection-cli-interactions'), + kind: 'cli-bridge', + name: 'CLI Bridge interaction test', + endpoint: bridge.endpoint, + providerOptions: { transport: 'local' }, + createdAt, + updatedAt: createdAt, + lastHealth: { status: 'unknown' }, + } + const profile = defineAgentProfile({ + name: 'CLI Bridge interaction test', + harness: 'pi', + model: { provider: 'openai', default: 'openai/gpt-5' }, + }) + const input: ExecuteTurnInput = { + operationId: 'operation-cli-interactions', + runId: 'run-cli-interactions', + text: 'Continue with the approved interaction posture.', + profile, + connectionId: connection.id, + workspaceRoot: '/workspace', + signal: new AbortController().signal, + interactions: Object.freeze({ permission: true, question: true, plan: true }), + onRetainedAdmission: async (_admission: RetainedRunAdmissionRecord) => {}, + } + const options = { + connections: new ConnectionRegistry([connection]), + workspaceCwd: '/workspace', + select: () => ({ connection: { connectionId: connection.id } }), + } + + try { + const prepared = await prepareCliBridgeConnection( + options, + input, + { connection: { connectionId: connection.id } }, + connection.id, + bridge.endpoint, + ) + const plan = await createCliBridgeRetainedPlan(prepared, input.runId) + await startCliBridgeRetainedRun(plan, input) + + assert.equal(bridge.requests.length, 1) + assert.deepEqual(bridge.requests[0]?.body.interactions, input.interactions) + } finally { + await bridge.close() + } +}) diff --git a/test/cli-bridge-retained-restart.test.ts b/test/cli-bridge-retained-restart.test.ts index 7eaec88..4d112c6 100644 --- a/test/cli-bridge-retained-restart.test.ts +++ b/test/cli-bridge-retained-restart.test.ts @@ -12,7 +12,6 @@ import { } from '../src/adapters/runtime/retained-execution.js' import { finalRetainedEnvelope } from '../src/adapters/runtime/retained-execution-projection.js' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' -import { providerEventFor } from '../src/app/run-event-mapper.js' import { createDurableBraidApplication, type DurableBraidApplication, @@ -21,6 +20,7 @@ import { createProductionComposition, type ProductionCompositionConfig, } from '../src/app/production-composition.js' +import { providerEventFor } from '../src/app/run-event-mapper.js' import type { ConnectionRecord } from '../src/domain/entities.js' import { createConnectionId } from '../src/domain/ids.js' import { isFinalRuntimeEvent, isRuntimeEventEnvelope } from '../src/domain/runtime-events.js' @@ -29,6 +29,7 @@ import { type RetainedRunAdmissionRecord, } from '../src/ports/execution.js' import { RandomIds } from '../src/ports/ids.js' +import { RETAINED_RUN_HANDLE_CAPABILITIES } from './support/retained-run-capabilities.js' import { startRuntimeBridgeServer } from './support/runtime-bridge-server.js' const now = '2026-08-11T12:00:00.000Z' @@ -131,6 +132,7 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { + const runId = 'braid/opaque-environment-recovery' + const retainedAdmission: RetainedRunAdmissionRecord = { + phase: 'environment', + provider: 'cloud-provider', + environmentId: 'cb1.persisted-opaque-environment', + idempotencyKey: 'environment-braid-opaque-environment-recovery', + turnId: 'turn-opaque-environment-recovery', + sessionId: 'session-opaque-environment-recovery', + executionId: 'execution-opaque-environment-recovery', + } + const exact: AgentExactRunControlRef = { + runId: 'cloud-provider-run-opaque-environment-recovery', + provider: retainedAdmission.provider, + environmentId: retainedAdmission.environmentId, + sessionId: retainedAdmission.sessionId, + executionId: retainedAdmission.executionId, + requestDigest: `sha256:${'e'.repeat(64)}`, + } + let receivedAdmission: RetainedRunAdmissionRecord | undefined + let receivedControlRef: AgentExactRunControlRef | undefined + const execution = new RetainedExecutionPort({ + resolve: async () => { + throw new Error('fresh admission is not part of this recovery test') + }, + recover: async ({ retainedAdmission: admission }) => { + receivedAdmission = admission + if (admission?.phase !== 'environment') { + throw new Error('recovery lost the retained environment admission') + } + return { + ...recoveryPlan(exact), + reconnect: async (controlRef) => { + receivedControlRef = controlRef + return recoveryPlan(exact).reconnect(controlRef) + }, + } + }, + }) + + const snapshot = await execution.status({ + runId, + providerSessionId: retainedAdmission.sessionId, + retainedAdmission, + }) + assert.deepEqual(receivedAdmission, retainedAdmission) + assert.deepEqual(receivedControlRef, exact) + assert.equal(snapshot?.runId, runId) + assert.equal(snapshot?.sessionId, retainedAdmission.sessionId) +}) + test('durable Braid detaches, restarts, and resumes one retained CLI Bridge job exactly once', async () => { const root = await mkdtemp(join(tmpdir(), 'braid-retained-restart-')) const bridge = await startRuntimeBridgeServer({ @@ -361,7 +414,7 @@ test('restart discovery preserves a continued provider session before the contro assert.equal(bridge.requests[0]?.sessionId, providerSessionId) assert.deepEqual( admissions.map((entry) => entry.phase), - ['environment', 'dispatched'], + ['intent', 'environment', 'dispatched'], ) await stream.return?.() @@ -369,7 +422,15 @@ test('restart discovery preserves a continued provider session before the contro if (restarted.execution.status === undefined || restarted.execution.cancelRun === undefined) { throw new Error('Retained recovery controls are unavailable') } - const snapshot = await restarted.execution.status({ runId, providerSessionId }) + const environmentAdmission = admissions.find((entry) => entry.phase === 'environment') + if (environmentAdmission === undefined) { + throw new Error('The environment admission was not retained') + } + const snapshot = await restarted.execution.status({ + runId, + providerSessionId, + retainedAdmission: environmentAdmission, + }) assert.equal(snapshot?.runId, runId) assert.equal(snapshot?.sessionId, providerSessionId) assert.equal(snapshot?.status, 'streaming') @@ -378,6 +439,7 @@ test('restart discovery preserves a continued provider session before the contro operationId: 'operation-retained-crash-window-cancel', runId, providerSessionId, + retainedAdmission: environmentAdmission, }) assert.equal('outcome' in cancellation ? cancellation.outcome : cancellation.status, 'accepted') assert.equal(bridge.cancellations.length, 1) @@ -421,7 +483,14 @@ test('restart discovery rejects a status response for another CLI Bridge run', a if (restarted.execution.status === undefined) { throw new Error('Retained recovery controls are unavailable') } - await assert.rejects(restarted.execution.status({ runId }), /another run identity/u) + const environmentAdmission = admissions.find((entry) => entry.phase === 'environment') + if (environmentAdmission === undefined) { + throw new Error('The environment admission was not retained') + } + await assert.rejects( + restarted.execution.status({ runId, retainedAdmission: environmentAdmission }), + /another (?:retained )?run identity/u, + ) assert.equal(bridge.requests.length, 1) } finally { bridge.complete() diff --git a/test/cli-startup.test.ts b/test/cli-startup.test.ts index 61ed28e..fecdd46 100644 --- a/test/cli-startup.test.ts +++ b/test/cli-startup.test.ts @@ -158,6 +158,47 @@ test('verification signals capture more than one atomic semantic frame', async ( } }) +test('native terminal signal ownership returns to Braid before shutdown', async () => { + let routedSignal: ((exitCode: number) => void) | undefined + let nativeExitCode: number | undefined + let shutdowns = 0 + let stopped = 0 + const lifecycle = createInterfaceSignalLifecycle({ + controller: { + dispatch: async () => { + shutdowns += 1 + return { kind: 'accepted', revision: 1, completion: Promise.resolve() } + }, + } as unknown as BraidUiController, + view: { stop: () => (stopped += 1) }, + application: { markCleanupUncertain: () => {} }, + nextOperationId: () => 'operation-native-signal', + startupPreview: { + takeSignalOwnership: (handler: (exitCode: number) => void) => { + routedSignal = handler + return () => {} + }, + } as unknown as import('../src/startup/preview-runtime.js').StartupPreview, + }) + try { + const release = lifecycle.takeOver((exitCode) => { + nativeExitCode = exitCode + }) + assert.ok(routedSignal) + routedSignal(130) + assert.equal(nativeExitCode, 130) + assert.equal(shutdowns, 0) + + release() + await lifecycle.settle() + assert.equal(shutdowns, 1) + assert.equal(stopped, 1) + assert.equal(lifecycle.exitCode(), 130) + } finally { + lifecycle.dispose() + } +}) + test('startup preview renders real route context and replays early input once', async () => { const writes: string[] = [] class RecordingTerminal extends VirtualTerminal { diff --git a/test/configuration-product-flow.test.ts b/test/configuration-product-flow.test.ts index b2758b0..8366ebb 100644 --- a/test/configuration-product-flow.test.ts +++ b/test/configuration-product-flow.test.ts @@ -12,6 +12,7 @@ import type { UiDispatchResult, } from '../src/views/shared/intents.js' import type { BraidViewModel, HeadlessState } from '../src/views/shared/models.js' +import { profileDetailLines } from '../src/views/tui/configuration-presenters.js' import { ConfigurationWizard } from '../src/views/tui/configuration-wizard.js' import { ConnectionSetupViewPanel } from '../src/views/tui/connection-setup.js' import { ProfileEditorViewPanel } from '../src/views/tui/profile-editor.js' @@ -28,7 +29,9 @@ function profile(name: string, _writable: boolean): AgentProfile { model: { default: `provider/${name.toLowerCase()}`, reasoningEffort: 'high', - metadata: { maxTokens: 8192 }, + maxVisibleOutputTokens: 8192, + maxReasoningTokens: 16_384, + maxTotalOutputTokens: 24_576, }, harness: 'pi', tools: { read: true }, @@ -53,7 +56,9 @@ function profileSummary(name: string, writable: boolean) { runner: 'pi', model: `provider/${name.toLowerCase()}`, reasoningEffort: 'high', - maxOutputTokens: 8192, + maxVisibleOutputTokens: 8192, + maxReasoningTokens: 16_384, + maxTotalOutputTokens: 24_576, tools: ['read'], skills: [], connections: [], @@ -84,14 +89,16 @@ function accepted(data: unknown, revision = 8): UiDispatchResult { function controllerFor( calls: BraidIntent[], - options: { duplicateProfileName?: boolean; rejectLists?: boolean } = {}, + options: { duplicateProfileName?: boolean; rejectLists?: boolean; singleProfile?: boolean } = {}, ): BraidUiController { let currentView = { revision: 7, profileName: 'Reviewer', connection: 'Local Bridge', } as BraidViewModel - const profiles = [profileSummary('Reviewer', true), profileSummary('ReadOnly', false)] + const profiles = options.singleProfile + ? [profileSummary('Reviewer', true)] + : [profileSummary('Reviewer', true), profileSummary('ReadOnly', false)] if (options.duplicateProfileName) { profiles.push({ ...profileSummary('Reviewer', false), id: 'profile-reviewer-duplicate' }) } @@ -285,13 +292,17 @@ test('profile editor uses canonical summaries and validates without advertising panel.focused = true await settle() const initial = panel.render(80).join('\n') - assert.match(initial, /Active profile · Reviewer/u) + assert.match(initial, /profile/u) + assert.match(initial, /─{20,}/u) + assert.match(initial, /Reviewer/u) + assert.doesNotMatch(initial, /Active profile/u) assert.match(initial, /pi/u) assert.match(initial, /provider\/reviewer/u) assert.doesNotMatch(initial, /secret-canary|secret-profile\.json/u) const narrow = panel.render(40) assert.ok(narrow.length <= 12) - assert.match(narrow.join('\n'), /enter select · \^V validate · esc close/u) + assert.match(narrow.join('\n'), /enter · \^V validate · ←\/esc close/u) + assert.match(narrow.join('\n'), /close/u) assert.doesNotMatch(narrow.join('\n'), /save/iu) panel.handleInput('\u0016') @@ -309,6 +320,92 @@ test('profile editor uses canonical summaries and validates without advertising ) }) +test('one profile renders a focused summary without a redundant switch list', async () => { + const panel = new ProfileEditorViewPanel(theme, { + controller: controllerFor([], { singleProfile: true }), + }) + panel.focused = true + await settle() + const rendered = panel.render(80).join('\n') + assert.match(rendered, /^ profile/mu) + assert.match(rendered, /─{20,}/u) + assert.match(rendered, /Reviewer/u) + assert.match(rendered, /runner pi · model provider\/reviewer/u) + assert.doesNotMatch(rendered, /switch profile|profiles/u) + assert.match(rendered, /←\/esc close/u) +}) + +test('built-in starter details omit the implicit source while real sources remain visible', () => { + const builtIn = { + ...profileSummary('Braid starter', false), + source: { + kind: 'inline' as const, + reference: 'braid:active', + label: 'Braid starter', + writable: false, + trusted: true, + }, + } + const builtInText = profileDetailLines(builtIn).join('\n') + assert.doesNotMatch(builtInText, /source Braid starter/u) + assert.match(builtInText, /trusted · read-only/u) + + const sourceText = profileDetailLines(profileSummary('Reviewer', false)).join('\n') + assert.match(sourceText, /source workspace profile/u) +}) + +test('profile and configuration selectors close with either back key', async () => { + const connection = { + id: 'connection-left-key', + kind: 'cli-bridge' as const, + name: 'Local Bridge', + endpoint: 'https://bridge.example.test/v1', + providerOptions: { transport: 'https' as const, capabilityHints: ['stream'] }, + createdAt: at, + updatedAt: at, + lastHealth: { status: 'healthy' as const, checkedAt: at }, + } + const record = createProfileRecord( + { + kind: 'inline', + reference: 'profile:left-key', + label: 'Left key profile', + writable: false, + trusted: true, + }, + profile('Left key profile', false), + ) + + for (const key of ['\u001b[D', '\u001b']) { + let profileCancelled = 0 + const profiles = new ProfileEditorViewPanel(theme, { + controller: controllerFor([]), + onCancel: () => { + profileCancelled += 1 + }, + }) + profiles.focused = true + await settle() + profiles.handleInput(key) + assert.equal(profileCancelled, 1, `profile ${JSON.stringify(key)}`) + + let configurationCancelled = 0 + const wizard = new ConfigurationWizard({ + theme, + profiles: [record], + connections: [connection], + onCommit: () => {}, + onComplete: () => {}, + onCancel: () => { + configurationCancelled += 1 + }, + }) + wizard.focused = true + wizard.handleInput(key) + assert.equal(configurationCancelled, 1, `configuration ${JSON.stringify(key)}`) + } +}) + test('connection setup shows health and capabilities without endpoints or credentials, then applies exact keyboard actions', async () => { const calls: BraidIntent[] = [] let cancelled = false @@ -328,7 +425,7 @@ test('connection setup shows health and capabilities without endpoints or creden assert.doesNotMatch(initial, /access_token|secret-canary/u) const narrow = panel.render(40) assert.ok(narrow.length <= 12) - assert.match(narrow.join('\n'), /enter select · \^T test · esc close/u) + assert.match(narrow.join('\n'), /enter select · \^T test · ←\/esc close/u) panel.handleInput('\u0014') await settle() diff --git a/test/configuration-wizard.test.ts b/test/configuration-wizard.test.ts index bf12523..314ddec 100644 --- a/test/configuration-wizard.test.ts +++ b/test/configuration-wizard.test.ts @@ -310,7 +310,7 @@ test('mounted first-run setup preserves choices and fits keyboard guidance at na } assert.match(frame('profile'), /choose an AgentProfile/u) - assert.match(frame('profile'), /filter · enter choose · esc cancel/u) + assert.match(frame('profile'), /filter · enter choose · ←\/esc cancel/u) terminal.sendInput('\u001b[B') terminal.sendInput('\r') await terminal.waitForRender() diff --git a/test/connections.test.ts b/test/connections.test.ts index 6fceebe..a56db35 100644 --- a/test/connections.test.ts +++ b/test/connections.test.ts @@ -312,7 +312,8 @@ test('production resolver routes chat connections through agent-runtime', async ) assert.equal(backend.kind, 'prepared-execution') const events = [] - for await (const event of streamAgentTurn(backend.backend, 'hello')) events.push(event) + for await (const event of streamAgentTurn(backend.backend, { prompt: 'hello' })) + events.push(event) assert.equal(events.at(-1)?.type, 'final') assert.equal(calls[0]?.model, 'openai/gpt-5') }) @@ -373,7 +374,7 @@ test('CLI Bridge and sandbox resolvers expose only supported runtime backend sha ) assert.deepEqual(sandboxBackend.backend.profile, profile()) const sandboxEvents = [] - for await (const event of streamAgentTurn(sandboxBackend.backend, 'say hello')) { + for await (const event of streamAgentTurn(sandboxBackend.backend, { prompt: 'say hello' })) { sandboxEvents.push(event) } const sandboxTerminal = sandboxEvents.at(-1) diff --git a/test/domain-reducer.test.ts b/test/domain-reducer.test.ts index 08a7830..eba77af 100644 --- a/test/domain-reducer.test.ts +++ b/test/domain-reducer.test.ts @@ -392,6 +392,17 @@ test('incremental reduction and full replay produce the same complete projection }) test('retained admission survives the pre-dispatch crash window and binds one exact run', () => { + const intentAdmission = { + phase: 'intent' as const, + provider: 'cli-bridge', + idempotencyKey: 'environment-retained-admission', + turnId: 'turn-retained-admission', + sessionId: 'session-retained-admission', + executionId: 'execution-retained-admission', + runId: 'retained-intent-run', + requestedProfileDigest: `sha256:${'a'.repeat(64)}` as const, + requestDigest: `sha256:${'b'.repeat(64)}` as const, + } const environmentAdmission = { phase: 'environment' as const, provider: 'cli-bridge', @@ -432,10 +443,18 @@ test('retained admission survives the pre-dispatch crash window and binds one ex { kind: 'run.retained.admitted', runId: 'run-retained-admission', - admission: environmentAdmission, + admission: intentAdmission, }, 3, ), + envelope( + { + kind: 'run.retained.admitted', + runId: 'run-retained-admission', + admission: environmentAdmission, + }, + 4, + ), ] as const const interrupted = replayEvents(initialState(STARTER_PROFILE), initialEvents) @@ -450,7 +469,7 @@ test('retained admission survives the pre-dispatch crash window and binds one ex runId: 'run-retained-admission', admission: dispatchedAdmission, }, - 4, + 5, ), ) assert.deepEqual(dispatched.runs[0]?.retainedAdmission, dispatchedAdmission) @@ -478,7 +497,7 @@ test('retained admission survives the pre-dispatch crash window and binds one ex receivedAt: '2026-08-02T00:00:05.000Z', }, }, - 5, + 6, ), ) assert.equal(harnessSession.runs[0]?.providerSessionId, environmentAdmission.sessionId) @@ -494,7 +513,7 @@ test('retained admission survives the pre-dispatch crash window and binds one ex runId: 'run-retained-admission', admission: environmentAdmission, }, - 6, + 7, ), ) assert.deepEqual(retriedEnvironment.runs[0]?.retainedAdmission, dispatchedAdmission) @@ -512,7 +531,7 @@ test('retained admission survives the pre-dispatch crash window and binds one ex environmentId: 'environment-retained-conflict', }, }, - 7, + 8, ), ), /conflicts with its environment admission/u, diff --git a/test/interaction-response-durability.test.ts b/test/interaction-response-durability.test.ts index 0f0c350..2f6c733 100644 --- a/test/interaction-response-durability.test.ts +++ b/test/interaction-response-durability.test.ts @@ -8,12 +8,20 @@ import type { InteractionRequestMaterial, } from '@tangle-network/agent-interface' import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' +import type { SqliteStorage } from '../src/adapters/storage/sqlite.js' import { openSqliteStorage } from '../src/adapters/storage/sqlite.js' +import type { StoredAutomationRule } from '../src/app/automation-matching.js' +import { ruleUseReservationId } from '../src/app/automation-rule-persistence.js' +import { automationOperationRecord } from '../src/app/automation-rule-store.js' import { createBraidApplication, DETERMINISTIC_PROFILE } from '../src/app/composition.js' import { InteractionAutomationCoordinator } from '../src/app/interaction-automation-coordinator.js' -import { automationOperationRecord } from '../src/app/automation-rule-store.js' -import { ruleUseReservationId } from '../src/app/automation-rule-persistence.js' +import { + createInteractionRequest, + interactionResponseBinding, + rebindInteractionRequest, +} from '../src/app/interaction-request.js' import { StorageJournal } from '../src/app/storage-journal.js' +import { toJson } from '../src/app/storage-journal-support.js' import { canonicalDigest } from '../src/domain/canonical.js' import type { BraidEvent, BraidEventEnvelope } from '../src/domain/events.js' import { @@ -31,16 +39,8 @@ import { createMaterializedStateSnapshot } from '../src/domain/materialized-stat import { replayEvents } from '../src/domain/reducer.js' import { type BraidState, initialState } from '../src/domain/state.js' import type { ExecutionPort } from '../src/ports/execution.js' -import { DEFAULT_RUN_CAPABILITIES } from '../src/ports/execution.js' import type { JournalEvent } from '../src/ports/storage.js' -import { toJson } from '../src/app/storage-journal-support.js' -import { - createInteractionRequest, - interactionResponseBinding, - rebindInteractionRequest, -} from '../src/app/interaction-request.js' -import type { StoredAutomationRule } from '../src/app/automation-matching.js' -import type { SqliteStorage } from '../src/adapters/storage/sqlite.js' +import { interactionResponseRunCapabilities } from './support/run-capabilities.js' const NOW = '2026-08-09T00:00:00.000Z' const RESPONSE_TIMEOUT_MS = 25 @@ -117,7 +117,7 @@ test('a hung provider response cannot strand manual response, rule mutation, or let responseCalls = 0 let receivedSignal: AbortSignal | undefined const execution: ExecutionPort = { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable<{ readonly type: 'interaction' readonly request: InteractionRequest diff --git a/test/interaction-response-safety.test.ts b/test/interaction-response-safety.test.ts index addc5d6..d859202 100644 --- a/test/interaction-response-safety.test.ts +++ b/test/interaction-response-safety.test.ts @@ -5,16 +5,22 @@ import type { InteractionRequestMaterial, InteractionResponse, } from '@tangle-network/agent-interface' +import { defaultTangleSandboxCapabilities } from '@tangle-network/agent-provider-tangle' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { automationAudits } from '../src/app/automation-rules.js' import { createBraidApplication } from '../src/app/composition.js' import { AppError } from '../src/app/errors.js' -import { automationAudits } from '../src/app/automation-rules.js' import { createInteractionRequest, rebindInteractionRequest, } from '../src/app/interaction-request.js' import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' -import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' +import { + DEFAULT_RUN_CAPABILITIES, + type ExecutionPort, + type RunCapabilities, +} from '../src/ports/execution.js' +import { interactionResponseRunCapabilities } from './support/run-capabilities.js' function interactionRequest(id: string): InteractionRequest { const material: InteractionRequestMaterial = { @@ -45,6 +51,142 @@ async function waitFor(predicate: () => boolean): Promise { } } +function retainedRunCapabilities(responseSupported: boolean): RunCapabilities { + const environment = structuredClone(defaultTangleSandboxCapabilities('opencode')) + if (!responseSupported) delete environment.interactions + return { + ...DEFAULT_RUN_CAPABILITIES, + streaming: { ...DEFAULT_RUN_CAPABILITIES.streaming, replay: true, detach: true }, + sessions: { ...DEFAULT_RUN_CAPABILITIES.sessions, continue: true }, + controls: { ...DEFAULT_RUN_CAPABILITIES.controls, recreate: true }, + events: { ...DEFAULT_RUN_CAPABILITIES.events, stableIdentity: true, cursor: true }, + environment, + } +} + +function retainedInteractionExecution(responseCapability: true | false | 'unknown'): { + readonly execution: ExecutionPort + readonly release: () => void + readonly responseCount: () => number +} { + const source = interactionRequest( + responseCapability === true + ? 'interaction-retained-capable' + : `interaction-retained-${responseCapability === false ? 'incapable' : 'unknown'}`, + ) + let releaseStream: (() => void) | undefined + let responseCount = 0 + return { + execution: { + capabilities: () => + responseCapability === 'unknown' + ? DEFAULT_RUN_CAPABILITIES + : retainedRunCapabilities(responseCapability), + async *streamTurn(input): AsyncIterable { + yield { + type: 'interaction', + request: rebindInteractionRequest(source, { + ...source.binding, + runId: input.runId, + executionId: input.runId, + }), + } + await new Promise((resolve) => { + releaseStream = resolve + }) + }, + // Retained ports expose this method even when the environment cannot record responses. + respondInteraction: async (input) => { + responseCount += 1 + releaseStream?.() + return { operationId: input.command.operationId, outcome: 'accepted' } + }, + }, + release: () => releaseStream?.(), + responseCount: () => responseCount, + } +} + +test('retained response actions follow the admitted environment capability', async () => { + for (const responseCapability of [true, false, 'unknown'] as const) { + const responseSupported = responseCapability === true + const retained = retainedInteractionExecution(responseCapability) + const app = createBraidApplication({ fixture: 'deterministic', execution: retained.execution }) + app.initialize('/workspace') + const send = app.send({ + operationId: `operation-send-retained-${String(responseCapability)}`, + text: 'ask', + }) + await waitFor(() => app.state().runs[0]?.interactions[0]?.status === 'pending') + const run = app.state().runs[0] + const interaction = run?.interactions[0] + assert(run && interaction) + + assert.equal( + run.receipt.capabilities.environment?.interactions?.responseIdempotency === true, + responseSupported, + ) + assert.equal(app.canRespondToInteractions(run.id), responseSupported) + assert.equal(app.canRespondToInteractions(), false) + const controller = createApplicationUiController(app) + assert.equal( + controller.view().capabilities['interaction.respond']?.available, + responseSupported, + ) + assert.equal( + controller.view().capabilities['interaction.automation']?.available, + responseSupported, + ) + + if (responseSupported) { + const receipt = await app.respondInteraction({ + operationId: `operation-respond-retained-${responseSupported}`, + runId: run.id, + interactionId: interaction.request.id, + response: { + id: interaction.request.id, + outcome: 'accepted', + data: { continue: true }, + }, + }) + assert.equal(receipt.acknowledgement.outcome, 'accepted') + assert.equal(retained.responseCount(), 1) + } else { + await assert.rejects( + app.respondInteraction({ + operationId: 'operation-respond-retained-incapable', + runId: run.id, + interactionId: interaction.request.id, + response: { + id: interaction.request.id, + outcome: 'accepted', + data: { continue: true }, + }, + }), + (error: unknown) => error instanceof AppError && error.code === 'CAPABILITY_UNAVAILABLE', + ) + await assert.rejects( + app.automation.apply({ + operationId: 'operation-automate-retained-incapable', + runId: run.id, + interactionId: interaction.request.id, + }), + (error: unknown) => error instanceof AppError && error.code === 'CAPABILITY_UNAVAILABLE', + ) + assert.equal(retained.responseCount(), 0) + assert.equal( + app.events().filter((event) => event.event.kind === 'run.interaction.response.requested') + .length, + 0, + ) + } + + retained.release() + await send.completion + await app.close() + } +}) + test('an adapter without response support leaves interactions and rules untouched', async () => { const source = interactionRequest('interaction-unsupported') let releaseStream: (() => void) | undefined @@ -125,7 +267,7 @@ test('automatic and manual responses dispatch exactly once when they race', asyn let responseCount = 0 let automaticResponse: InteractionResponse | undefined const execution: ExecutionPort = { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable { yield { type: 'interaction', diff --git a/test/interactions.test.ts b/test/interactions.test.ts index 83b01ca..34feaf3 100644 --- a/test/interactions.test.ts +++ b/test/interactions.test.ts @@ -41,6 +41,7 @@ import { type BraidState, initialState } from '../src/domain/state.js' import { FixedClock } from '../src/ports/clock.js' import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' import { SequenceIds } from '../src/ports/ids.js' +import { interactionResponseRunCapabilities } from './support/run-capabilities.js' const NOW = '2026-08-01T00:00:00.000Z' @@ -149,7 +150,7 @@ function interactionExecution(request: InteractionRequest): { let lastCommand: InteractionResponseCommand | undefined let releaseStream: (() => void) | undefined const execution: ExecutionPort = { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable { yield { type: 'interaction', @@ -480,7 +481,7 @@ test('the terminal API never reports an unconfirmed interaction response as acce const request = questionRequest('interaction-unconfirmed') let releaseStream: (() => void) | undefined const execution: ExecutionPort = { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable { yield { type: 'interaction', diff --git a/test/interactive-execution-adapters.test.ts b/test/interactive-execution-adapters.test.ts new file mode 100644 index 0000000..d8a2c3d --- /dev/null +++ b/test/interactive-execution-adapters.test.ts @@ -0,0 +1,930 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { AgentInteractiveSessionRef, AgentProfile } from '@tangle-network/agent-interface' +import { + AgentInteractiveSessionRefSchema, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' +import type { + AgentEnvironment, + AgentEnvironmentCapabilities, + AgentEnvironmentProvider, +} from '@tangle-network/agent-interface/environment-provider' +import type { RetainedInteractiveAdmission } from '@tangle-network/agent-runtime/kernel' +import { ModeRoutingExecutionPort } from '../src/adapters/runtime/mode-routing-execution.js' +import { NativeInteractiveRunBroker } from '../src/adapters/runtime/native-interactive-run-broker.js' +import { + safeExecutionId, + stableProviderId, +} from '../src/adapters/runtime/production-backend-common.js' +import type { PreparedTangleRetainedConnection } from '../src/adapters/runtime/production-tangle-sandbox-backend.js' +import { assertInteractiveProvider } from '../src/adapters/runtime/tangle-retained-interactive-contract.js' +import { TangleRetainedInteractiveExecutionPort } from '../src/adapters/runtime/tangle-retained-interactive-execution.js' +import type { RunAdmissionReceipt } from '../src/domain/receipts.js' +import type { RuntimeEventEnvelope } from '../src/domain/runtime-events.js' +import type { ExecuteTurnInput, ExecutionPort } from '../src/ports/execution.js' + +const PROFILE: AgentProfile = { + name: 'Braid interactive test', + harness: 'pi', + model: { default: 'test-model', provider: 'test-provider' }, +} + +test('the broker reports typed settlement outcomes', () => { + const broker = new NativeInteractiveRunBroker() + assert.equal(broker.settle('missing', { kind: 'detached' }), 'absent') + + const lease = broker.open('run/broker') + assert.equal(broker.settle('run/broker', { kind: 'detached' }), 'settled') + assert.equal(broker.settle('run/broker', { kind: 'detached' }), 'already-settled') + lease.close() +}) + +test('mode routing is async when either branch is async and honors canonical interactive admissions', async () => { + const calls: string[] = [] + const headless = routingPort('headless', 'sync', calls) + const interactive = routingPort('interactive', 'async', calls) + const router = new ModeRoutingExecutionPort({ headless, interactive }) + + assert.equal(router.admissionMode, 'async') + await collect(router.streamTurn(input('run/interactive', { mode: 'interactive' }))) + await router.status({ + runId: 'run/recovered', + retainedAdmission: { phase: 'interactive_intent' }, + } as never) + await router.cancelRun({ + runId: 'run/recovered-control', + operationId: 'operation-recovered-control', + retainedAdmission: { phase: 'interactive_started' }, + } as never) + await collect(router.streamTurn(input('run/headless', { mode: 'headless' }))) + + assert.deepEqual(calls, [ + 'interactive:stream', + 'interactive:status', + 'interactive:cancel', + 'headless:stream', + ]) + await assert.rejects( + () => + collect( + router.streamTurn( + input('run/mismatch', { + mode: 'headless', + retainedAdmission: { phase: 'interactive_started' }, + }), + ), + ), + /conflicts with an interactive retained admission/u, + ) +}) + +test('starts one native process, records Runtime identity, and emits replayable envelopes', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const port = interactivePort(fixture, broker) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/start', async (admission) => { + admissions.push(admission) + }) + + const admission = await port.admit(input) + assert.equal(admission.providerSessionId, undefined) + const iterator = port.streamTurn(input)[Symbol.asyncIterator]() + const observed = await next(iterator) + const handle = await broker.waitForHandle(input.runId) + + assert.equal(fixture.stats.dispatchCalls, 0) + assert.equal(fixture.stats.processStarts, 1) + assert.equal(fixture.resolveInputs.length, 1) + assert.deepEqual( + admissions.map((entry) => entry.phase), + ['interactive_intent', 'interactive_environment', 'interactive_started'], + ) + assert.equal(admission.capabilities?.streaming.replay, true) + assert.equal(admission.capabilities?.events.cursor, true) + assert.equal(observed.cursor, `${input.runId}:interactive:${handle.ref.incarnationId}:1`) + assert.equal(observed.eventId, `${input.runId}:interactive:${handle.ref.incarnationId}:observed`) + assert.equal(fixture.lastCreateMetadata?.providerSessionId, undefined) + assert.equal(fixture.lastCreateMetadata?.surface, 'interactive-agent') + + await finish(iterator, broker, input.runId) + const cancelled = await port.cancelRun({ operationId: 'cancel-after-finish', runId: input.runId }) + assert.equal(cancelled.outcome, 'unknown') +}) + +test('detach ends the foreground stream while preserving the native handle for reattachment', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const port = interactivePort(fixture, broker) + const input = executionInput('run/detach', async () => {}) + const iterator = port.streamTurn(input)[Symbol.asyncIterator]() + await next(iterator) + const handle = await broker.waitForHandle(input.runId) + + const detached = await port.detachRun({ + runId: input.runId, + operationId: 'detach-1', + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + }) + assert.equal(detached.outcome, 'accepted') + assert.equal((await iterator.next()).done, true) + + const status = await port.status({ runId: input.runId }) + assert.equal(status?.status, 'detached') + const stopped = await port.cancelRun({ operationId: 'cancel-detached', runId: input.runId }) + assert.equal(stopped.outcome, 'accepted') + const stoppedAgain = await port.cancelRun({ + operationId: 'cancel-detached-again', + runId: input.runId, + }) + assert.equal(stoppedAgain.outcome, 'unknown') +}) + +test('reattach reuses the Runtime ref, honors a replay cursor, and does not start a second process', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const first = interactivePort(fixture, broker) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/reattach', async (admission) => { + admissions.push(admission) + }) + const firstIterator = first.streamTurn(input)[Symbol.asyncIterator]() + const observed = await next(firstIterator) + const handle = await broker.waitForHandle(input.runId) + await first.detachRun({ + runId: input.runId, + operationId: 'detach-before-reconnect', + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + }) + assert.equal((await firstIterator.next()).done, true) + + const started = admissions.find((entry) => entry.phase === 'interactive_started') + assert.ok(started?.phase === 'interactive_started') + const second = interactivePort(fixture, broker) + const reconnectInput = { + runId: input.runId, + retainedAdmission: started, + receipt: receiptFor(input), + after: observed.cursor, + signal: input.signal, + } as Parameters[0] + const reconnectIterator = second.reconnect(reconnectInput)[Symbol.asyncIterator]() + const finalPromise = reconnectIterator.next() + const reattached = await broker.waitForHandle(input.runId) + assert.equal(reattached.ref.run.sessionId, handle.ref.run.sessionId) + assert.equal(fixture.stats.processStarts, 1) + assert.equal(fixture.stats.startCalls, 1) + + const settlement = broker.settle(input.runId, { kind: 'exited', exitCode: 0 }) + assert.equal(settlement, 'settled') + const final = await finalPromise + assert.equal(final.done, false) + assert.equal(final.value.sequence, 2) + assert.equal(final.value.cursor, `${input.runId}:interactive:${handle.ref.incarnationId}:2`) + assert.equal((await reconnectIterator.next()).done, true) +}) + +test('partial intent and environment admissions report replayable status without recovery', async () => { + for (const phase of ['interactive_intent', 'interactive_environment'] as const) { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const port = interactivePort(fixture, broker) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput(`run/partial-${phase}`, async (admission) => { + admissions.push(admission) + if (admission.phase === phase) throw new Error(`simulated ${phase} loss`) + }) + const iterator = port.streamTurn(input)[Symbol.asyncIterator]() + await assert.rejects( + () => iterator.next(), + (error: unknown) => causedBy(error, `simulated ${phase} loss`), + ) + const partial = admissions.find((admission) => admission.phase === phase) + assert.ok(partial) + await assert.rejects( + () => + port.status({ + runId: input.runId, + providerSessionId: 'wrong-native-session', + retainedAdmission: partial, + receipt: receiptFor(input), + } as Parameters[0]), + /conflicts with the saved admission/u, + ) + const before = { ...fixture.stats } + const status = await port.status({ + runId: input.runId, + retainedAdmission: partial, + receipt: receiptFor(input), + } as Parameters[0]) + + assert.equal(status?.status, 'unknown') + assert.equal(status?.detail, `replayable:${phase}`) + assert.deepEqual(fixture.stats, before) + } +}) + +test('reconnect recovers a persisted interactive intent exactly once', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const initial = interactivePort(fixture, broker) + const admissions: RetainedInteractiveAdmission[] = [] + let failIntent = true + const input = executionInput('run/recover-intent', async (admission) => { + admissions.push(admission) + if (failIntent && admission.phase === 'interactive_intent') { + throw new Error('simulated intent loss') + } + }) + const initialIterator = initial.streamTurn(input)[Symbol.asyncIterator]() + await assert.rejects( + () => initialIterator.next(), + (error: unknown) => causedBy(error, 'simulated intent loss'), + ) + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + assert.ok(intent?.phase === 'interactive_intent') + assert.equal(fixture.stats.createCalls, 0) + + failIntent = false + const recovered = interactivePort(fixture, broker) + const reconnectIterator = recovered + .reconnect({ + runId: input.runId, + retainedAdmission: intent, + receipt: receiptFor(input), + onRetainedAdmission: input.onRetainedAdmission, + signal: input.signal, + } as Parameters[0]) + [Symbol.asyncIterator]() + await next(reconnectIterator) + const handle = await broker.waitForHandle(input.runId) + assert.equal(handle.ref.run.sessionId, intent.sessionId) + assert.equal(fixture.stats.createCalls, 1) + assert.equal(fixture.stats.processStarts, 1) + assert.equal(fixture.stats.dispatchCalls, 0) + assert.equal(fixture.resolveInputs.at(-1)?.sessionId, undefined) + await finish(reconnectIterator, broker, input.runId) +}) + +test('reconnect rejects a provider session mismatch before attaching', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const first = interactivePort(fixture, broker) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/mismatch', async (admission) => { + admissions.push(admission) + }) + const iterator = first.streamTurn(input)[Symbol.asyncIterator]() + await next(iterator) + const handle = await broker.waitForHandle(input.runId) + await first.detachRun({ + runId: input.runId, + operationId: 'detach-mismatch', + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + }) + assert.equal((await iterator.next()).done, true) + const started = admissions.find((entry) => entry.phase === 'interactive_started') + assert.ok(started?.phase === 'interactive_started') + + const second = interactivePort(fixture, broker) + const reconnect = second + .reconnect({ + runId: input.runId, + retainedAdmission: started, + receipt: receiptFor(input), + providerSessionId: 'wrong-native-session', + signal: input.signal, + } as Parameters[0]) + [Symbol.asyncIterator]() + await assert.rejects(() => reconnect.next(), /conflicts with the saved process reference/u) + assert.equal(fixture.stats.processStarts, 1) +}) + +test('a second foreground execution cannot duplicate an active native process', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const port = interactivePort(fixture, broker) + const input = executionInput('run/no-duplicate', async () => {}) + const first = port.streamTurn(input)[Symbol.asyncIterator]() + await next(first) + const duplicate = port.streamTurn(input)[Symbol.asyncIterator]() + await assert.rejects(() => duplicate.next(), /already has an active execution/u) + assert.equal(fixture.stats.processStarts, 1) + const handle = await broker.waitForHandle(input.runId) + await port.detachRun({ + runId: input.runId, + operationId: 'detach-no-duplicate', + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + }) + assert.equal((await first.next()).done, true) +}) + +test('cancellation between admission and start cannot create a native process', async () => { + const fixture = interactiveFixture() + const port = interactivePort(fixture, new NativeInteractiveRunBroker()) + const input = executionInput('run/cancel-before-start', async () => {}) + await port.admit(input) + const iterator = port.streamTurn(input)[Symbol.asyncIterator]() + const nextResult = iterator.next() + const cancelled = await port.cancelRun({ + operationId: 'cancel-before-native-start', + runId: input.runId, + }) + + assert.equal(cancelled.outcome, 'accepted') + assert.deepEqual(await nextResult, { done: true, value: undefined }) + assert.equal(fixture.stats.processStarts, 0) +}) + +test('cancelling a persisted interactive intent after restart does not materialize it', async () => { + const fixture = interactiveFixture() + const first = interactivePort(fixture, new NativeInteractiveRunBroker()) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/cancel-intent', async (admission) => { + admissions.push(admission) + if (admission.phase === 'interactive_intent') throw new Error('simulated intent loss') + }) + const initial = first.streamTurn(input)[Symbol.asyncIterator]() + await assert.rejects( + () => initial.next(), + (error: unknown) => causedBy(error, 'simulated intent loss'), + ) + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + assert.ok(intent?.phase === 'interactive_intent') + + const restarted = interactivePort(fixture, new NativeInteractiveRunBroker()) + const cancelled = await restarted.cancelRun({ + operationId: 'cancel-persisted-interactive-intent', + runId: input.runId, + providerSessionId: intent.sessionId, + retainedAdmission: intent, + receipt: receiptFor(input), + }) + + assert.deepEqual(cancelled, { + operationId: 'cancel-persisted-interactive-intent', + outcome: 'accepted', + detail: 'cancelled-before-start', + }) + assert.equal(fixture.stats.createCalls, 0) + assert.equal(fixture.stats.processStarts, 0) + assert.equal(fixture.stats.stopCalls, 0) +}) + +test('detaching a persisted interactive intent does not materialize it', async () => { + const fixture = interactiveFixture() + const first = interactivePort(fixture, new NativeInteractiveRunBroker()) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/detach-intent', async (admission) => { + admissions.push(admission) + if (admission.phase === 'interactive_intent') throw new Error('simulated detach intent loss') + }) + const initial = first.streamTurn(input)[Symbol.asyncIterator]() + await assert.rejects( + () => initial.next(), + (error: unknown) => causedBy(error, 'simulated detach intent loss'), + ) + const intent = admissions.find((admission) => admission.phase === 'interactive_intent') + assert.ok(intent?.phase === 'interactive_intent') + + const restarted = interactivePort(fixture, new NativeInteractiveRunBroker()) + const detached = await restarted.detachRun({ + operationId: 'detach-persisted-interactive-intent', + runId: input.runId, + providerSessionId: intent.sessionId, + retainedAdmission: intent, + receipt: receiptFor(input), + }) + + assert.deepEqual(detached, { + operationId: 'detach-persisted-interactive-intent', + outcome: 'accepted', + detail: 'detached', + }) + assert.equal(fixture.stats.createCalls, 0) + assert.equal(fixture.stats.processStarts, 0) + assert.equal(fixture.stats.stopCalls, 0) +}) + +test('cancellation recovers the exact native process after a process restart', async () => { + const fixture = interactiveFixture() + const firstBroker = new NativeInteractiveRunBroker() + const first = interactivePort(fixture, firstBroker) + const admissions: RetainedInteractiveAdmission[] = [] + const input = executionInput('run/restart-cancel', async (admission) => { + admissions.push(admission) + }) + const iterator = first.streamTurn(input)[Symbol.asyncIterator]() + await next(iterator) + const handle = await firstBroker.waitForHandle(input.runId) + await first.detachRun({ + runId: input.runId, + operationId: 'detach-before-restart-cancel', + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + }) + assert.equal((await iterator.next()).done, true) + const started = admissions.find((entry) => entry.phase === 'interactive_started') + assert.ok(started?.phase === 'interactive_started') + + const restarted = interactivePort(fixture, new NativeInteractiveRunBroker()) + const cancelled = await restarted.cancelRun({ + operationId: 'cancel-after-restart', + runId: input.runId, + providerSessionId: started.ref.run.sessionId, + controlRef: started.ref.run, + retainedAdmission: started, + receipt: receiptFor(input), + workspaceRoot: '/workspace', + }) + + assert.equal(cancelled.outcome, 'accepted') + assert.equal(fixture.stats.processStarts, 1) + assert.equal(fixture.stats.stopCalls, 1) +}) + +test('native cancellation omits an expired foreground signal from provider stop', async () => { + const fixture = interactiveFixture() + const broker = new NativeInteractiveRunBroker() + const port = interactivePort(fixture, broker) + const input = executionInput('run/expired-cancel', async () => {}) + const iterator = port.streamTurn(input)[Symbol.asyncIterator]() + await next(iterator) + const handle = await broker.waitForHandle(input.runId) + const controller = new AbortController() + controller.abort(new Error('foreground deadline elapsed')) + + const cancelled = await port.cancelRun({ + operationId: 'cancel-expired-foreground', + runId: input.runId, + providerSessionId: handle.ref.run.sessionId, + controlRef: handle.ref.run, + signal: controller.signal, + }) + + assert.equal(cancelled.outcome, 'accepted') + assert.equal(fixture.stats.stopSignals.at(-1), undefined) + const final = await next(iterator) + assert.equal(final.sequence, 2) + assert.equal((await iterator.next()).done, true) +}) + +test('interactive admission rejects a changed request for the same run identity', async () => { + const fixture = interactiveFixture() + const port = interactivePort(fixture, new NativeInteractiveRunBroker()) + const input = executionInput('run/request-integrity', async () => {}) + await port.admit(input) + + await assert.rejects( + () => collect(port.streamTurn({ ...input, text: 'Use a different prompt.' })), + /different request/u, + ) + assert.equal(fixture.stats.processStarts, 0) +}) + +test('provider identity keys preserve uniqueness after normalization and truncation', () => { + assert.notEqual(safeExecutionId('run/a'), safeExecutionId('run-a')) + const first = 'x'.repeat(140) + const second = `${'x'.repeat(139)}y` + assert.notEqual(stableProviderId('env-braid-', first), stableProviderId('env-braid-', second)) + assert.ok(stableProviderId('env-braid-', first).length <= 128) +}) + +test('interactive provider admission requires terminal and prompt capabilities', () => { + const fixture = interactiveFixture() + const incomplete = { + ...fixture.prepared, + capabilities: { + ...fixture.prepared.capabilities, + interactiveAgent: { + ...fixture.prepared.capabilities.interactiveAgent, + input: false, + }, + }, + } as PreparedTangleRetainedConnection + assert.throws(() => assertInteractiveProvider(incomplete), /exact interactive agents/u) +}) + +test('native interactive admission does not advertise structured responses it cannot route', async () => { + const fixture = interactiveFixture() + const port = new TangleRetainedInteractiveExecutionPort({ + broker: new NativeInteractiveRunBroker(), + resolve: async (input) => ({ + ...fixture.prepared, + profile: input.profile, + capabilities: { + ...fixture.prepared.capabilities, + interactions: { + kinds: ['question'], + answerFieldTypes: ['text'], + responseScopes: ['interaction'], + secretAnswers: false, + concurrentRequests: false, + replay: true, + responseIdempotency: true, + }, + }, + }), + }) + + const admission = await port.admit( + executionInput('run/native-interaction-capability', async () => {}), + ) + + assert.equal(admission.capabilities?.environment?.interactions, undefined) +}) + +interface InteractiveFixture { + readonly provider: AgentEnvironmentProvider + readonly prepared: PreparedTangleRetainedConnection + readonly profile: AgentProfile + readonly resolveInputs: ExecuteTurnInput[] + readonly stats: { + createCalls: number + getCalls: number + startCalls: number + processStarts: number + dispatchCalls: number + stopCalls: number + stopSignals: (AbortSignal | undefined)[] + } + readonly lastCreateMetadata: Readonly> | undefined +} + +function interactiveFixture(): InteractiveFixture { + const profile = PROFILE + const resolveInputs: ExecuteTurnInput[] = [] + const stats = { + createCalls: 0, + getCalls: 0, + startCalls: 0, + processStarts: 0, + dispatchCalls: 0, + stopCalls: 0, + stopSignals: [], + } + const state: { + ref?: AgentInteractiveSessionRef + lastCreateMetadata: Readonly> | undefined + } = { lastCreateMetadata: undefined } + const capabilities = interactiveCapabilities() + const environment: AgentEnvironment = { + id: 'environment-test', + provider: 'test-provider', + status: async () => ({ state: 'ready' }) as never, + async *stream() {}, + dispatch: async () => { + stats.dispatchCalls += 1 + throw new Error('headless dispatch must not run') + }, + startInteractive: async (request) => { + stats.startCalls += 1 + if (state.ref === undefined) { + stats.processStarts += 1 + state.ref = interactiveRef(request) + } + return state.ref + }, + interactive: (ref) => interactiveSession(ref, stats) as never, + } + const provider: AgentEnvironmentProvider = { + name: 'test-provider', + capabilities: async () => capabilities, + create: async (input) => { + stats.createCalls += 1 + state.lastCreateMetadata = input.metadata + return environment + }, + get: async () => { + stats.getCalls += 1 + return environment + }, + } + const prepared = { + profile, + model: 'test-model', + runner: 'pi', + provider, + capabilities, + observation: { + snapshot: async () => ({ + kind: 'sandbox', + provider: 'test-provider', + lifecycle: 'ready', + lifecycleMode: 'retained', + cleanup: 'explicit', + continuity: 'session', + location: 'remote', + createdAt: '2026-08-16T00:00:00.000Z', + observedAt: '2026-08-16T00:00:00.000Z', + unavailable: [], + }), + }, + providerSessionId: 'headless-session-must-not-leak', + environmentIdempotencyKey: 'headless-environment-key-must-not-leak', + environmentName: 'headless-name-must-not-leak', + environmentMetadata: { + headless: true, + providerSessionId: 'headless-session-must-not-leak', + }, + idleTtlSeconds: 3600, + discoverControlRef: async () => null, + materializationReceipt: { headless: true, providerSessionId: 'headless-session-must-not-leak' }, + } as PreparedTangleRetainedConnection + return { + provider, + prepared, + profile, + resolveInputs, + stats, + get lastCreateMetadata() { + return state.lastCreateMetadata + }, + } +} + +function interactivePort(fixture: InteractiveFixture, broker: NativeInteractiveRunBroker) { + const port = new TangleRetainedInteractiveExecutionPort({ + broker, + resolve: async (input) => { + fixture.resolveInputs.push(input) + return { ...fixture.prepared, profile: input.profile } + }, + }) + return port +} + +function executionInput( + runId: string, + onRetainedAdmission: (admission: RetainedInteractiveAdmission) => Promise, +): ExecuteTurnInput { + return { + operationId: `operation-${runId}`, + runId, + text: 'Inspect this workspace.', + profile: PROFILE, + mode: 'interactive', + signal: new AbortController().signal, + onRetainedAdmission: async (admission) => { + await onRetainedAdmission(admission as unknown as RetainedInteractiveAdmission) + }, + } +} + +function receiptFor(input: ExecuteTurnInput): RunAdmissionReceipt { + return { + version: 1, + runId: input.runId, + turnId: 'turn-test', + operationId: input.operationId, + conversationId: 'conversation-test', + branchId: 'branch-test', + admittedAt: '2026-08-16T00:00:00.000Z', + profileDigest: `sha256:${'1'.repeat(64)}`, + requested: { text: input.text, profile: input.profile }, + capabilities: {} as RunAdmissionReceipt['capabilities'], + requestDigest: `sha256:${'2'.repeat(64)}`, + capabilitiesDigest: `sha256:${'3'.repeat(64)}`, + digest: `sha256:${'4'.repeat(64)}`, + } +} + +function causedBy(error: unknown, message: string): boolean { + return error instanceof Error && error.cause instanceof Error && error.cause.message === message +} + +async function next(iterator: AsyncIterator): Promise { + const result = await iterator.next() + assert.equal(result.done, false) + return (result as IteratorYieldResult).value +} + +async function finish( + iterator: AsyncIterator, + broker: NativeInteractiveRunBroker, + runId: string, +): Promise { + assert.equal(broker.settle(runId, { kind: 'exited', exitCode: 0 }), 'settled') + const final = await next(iterator) + assert.equal(final.sequence, 2) + assert.equal((await iterator.next()).done, true) +} + +async function collect(iterable: AsyncIterable): Promise { + for await (const _event of iterable) { + return + } +} + +function routingPort( + label: string, + admissionMode: 'sync' | 'async', + calls: string[], +): ExecutionPort { + return { + admissionMode, + capabilities: () => ({ + streaming: { live: true, replay: false, detach: false, turnIdempotency: true }, + sessions: { continue: false, messages: false }, + controls: { cancel: false, steer: false, queue: false, status: false, recreate: false }, + events: { stableIdentity: false, sequence: true, cursor: false }, + usage: false, + }), + streamTurn: async function* () { + calls.push(`${label}:stream`) + yield* [] + }, + status: async () => { + calls.push(`${label}:status`) + return null + }, + cancelRun: async (control) => { + calls.push(`${label}:cancel`) + return { operationId: control.operationId, outcome: 'accepted' } + }, + } +} + +function input(runId: string, overrides: Record = {}): ExecuteTurnInput { + return { + operationId: `operation-${runId}`, + runId, + text: 'test', + profile: PROFILE, + signal: new AbortController().signal, + ...overrides, + } as ExecuteTurnInput +} + +function interactiveCapabilities(): AgentEnvironmentCapabilities { + return { + profile: { + namedProfiles: true, + systemPrompt: { replace: true, append: true }, + instructions: true, + tools: true, + permissions: true, + mcp: true, + subagents: true, + resources: { files: true, instructions: true }, + runtimeUpdate: true, + validation: true, + }, + streaming: { live: true, replay: true, detach: true, turnIdempotency: true }, + sessions: { continue: true, list: true, messages: true }, + retainedControl: { + exactRunIdentity: true, + resultIdentity: true, + eventIdentity: true, + cancellationIdempotency: true, + }, + workspace: { read: true, write: true, exec: true, git: true, upload: true, download: true }, + branching: { checkpoint: false, fork: false }, + placement: true, + usage: false, + confidential: false, + interactiveAgent: { + start: true, + control: true, + status: true, + attach: true, + reattach: true, + sendPrompt: true, + input: true, + resize: true, + stop: true, + }, + } +} + +function interactiveRef( + request: Parameters>[0], +): AgentInteractiveSessionRef { + const preparationReceipt = { + kind: 'agent-execution-preparation' as const, + schemaVersion: 1 as const, + preparationId: 'preparation-test', + requestDigest: request.run.requestDigest, + authoredProfileDigest: request.requestedProfileDigest, + effectiveProfileDigest: request.requestedProfileDigest, + backend: 'test-backend', + harness: request.profile.harness ?? 'pi', + harnessVersion: 'test-harness-1', + resolvedModel: { + requested: request.profile.model?.default ?? 'test-model', + resolved: request.profile.model?.default ?? 'test-model', + }, + workspace: { + leaseId: 'workspace-lease-test', + provider: 'test-provider', + identityDigest: digest('2'), + isolation: 'per-run' as const, + sourceSnapshotDigest: digest('3'), + sourceSnapshotPolicy: { + kind: 'provider-declared' as const, + name: 'test', + version: 1, + digest: digest('4'), + }, + preparedWorkspaceDigest: digest('5'), + profileActivationDigest: digest('6'), + }, + axisResults: [], + executionPlanDigest: digest('7'), + materializer: { name: 'test-materializer', version: '1' }, + expiresAtMs: 4102444800000, + } + return AgentInteractiveSessionRefSchema.parse({ + run: request.run, + preparationReceipt: { + ...preparationReceipt, + digest: canonicalCandidateDigest(preparationReceipt), + }, + incarnationId: 'incarnation-test', + startedAt: '2026-08-16T00:00:00.000Z', + }) +} + +function interactiveSession(ref: AgentInteractiveSessionRef, stats: InteractiveFixture['stats']) { + const control = (holderId: string) => ({ + refDigest: canonicalCandidateDigest(ref), + generation: 1, + leaseId: 'interactive-lease-test', + holderId, + expiresAt: '2099-01-01T00:00:00.000Z', + }) + return { + ref, + claimControl: async (request: { + operationId: string + requestDigest: string + holderId: string + }) => ({ + operationId: request.operationId, + requestDigest: request.requestDigest, + ref, + status: 'accepted' as const, + control: control(request.holderId), + }), + status: async () => ({ state: 'running' as const, ref }), + attach: async (request: { control: unknown }) => ({ + ref: { + terminalSessionId: 'terminal-test', + parentExecutionId: ref.run.executionId, + name: 'pi', + shell: '/bin/sh', + command: 'pi', + cwd: '/workspace', + cols: 120, + rows: 40, + createdAt: '2026-08-16T00:00:00.000Z', + lastActivityAt: '2026-08-16T00:00:00.000Z', + expiresAt: '2026-08-17T00:00:00.000Z', + isRunning: true, + attachCount: 1, + }, + control: request.control, + cursors: { earliest: 0, latest: 0 }, + input: async () => {}, + resize: async () => {}, + detach: async () => ({ status: 'detached' as const, terminalSessionId: 'terminal-test' }), + close: async () => ({ status: 'closed' as const, terminalSessionId: 'terminal-test' }), + async *events() {}, + }), + sendPrompt: async (command: { + operationId: string + requestDigest: string + control: unknown + }) => ({ + operationId: command.operationId, + requestDigest: command.requestDigest, + ref, + control: command.control, + status: 'accepted' as const, + }), + stop: async ( + command: { operationId: string; requestDigest: string; control: unknown }, + options?: { signal?: AbortSignal }, + ) => { + stats.stopCalls += 1 + stats.stopSignals.push(options?.signal) + return { + operationId: command.operationId, + requestDigest: command.requestDigest, + ref, + control: command.control, + status: 'accepted' as const, + effect: 'stopped' as const, + } + }, + } +} + +function digest(seed: string): `sha256:${string}` { + return `sha256:${seed.repeat(64).slice(0, 64)}` +} diff --git a/test/native-interactive-actions.test.ts b/test/native-interactive-actions.test.ts new file mode 100644 index 0000000..da8614c --- /dev/null +++ b/test/native-interactive-actions.test.ts @@ -0,0 +1,278 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { + AgentInteractiveSessionControlClaimRequest, + AgentInteractiveSessionRef, + AgentInteractiveTerminalSession, + TerminalDetachAck, +} from '@tangle-network/agent-interface' +import { + agentExecutionPreparationReceiptSchema, + canonicalCandidateDigest, +} from '@tangle-network/agent-interface' +import type { RetainedInteractiveRunHandle } from '@tangle-network/agent-runtime/kernel' +import type { BraidApplication } from '../src/app/application.js' +import { createNativeInteractiveUiActions } from '../src/bin/native-interactive-actions.js' +import type { NativeInteractiveExecutionControl } from '../src/ports/native-interactive-execution.js' +import type { NativeTerminalHost, NativeTerminalSignalPort } from '../src/index.js' + +class ExitTerminalSession implements AgentInteractiveTerminalSession { + readonly ref = { + terminalSessionId: 'terminal-native-actions', + parentExecutionId: 'execution-native-actions', + name: 'native actions', + shell: '/bin/sh', + cwd: '/workspace', + cols: 100, + rows: 30, + createdAt: '2026-08-16T00:00:00.000Z', + lastActivityAt: '2026-08-16T00:00:00.000Z', + expiresAt: '2099-01-01T00:00:00.000Z', + isRunning: true, + attachCount: 1, + } + readonly cursors = { earliest: 0, latest: 1 } + readonly control = { + refDigest: `sha256:${'1'.repeat(64)}` as const, + generation: 1, + leaseId: 'lease-native-actions', + holderId: 'braid-native-actions', + expiresAt: '2099-01-01T00:00:00.000Z', + } + + input(): Promise { + return Promise.resolve() + } + + resize(): Promise { + return Promise.resolve() + } + + detach(): Promise { + return Promise.resolve({ + status: 'detached', + terminalSessionId: this.ref.terminalSessionId, + }) + } + + close(): Promise { + return Promise.resolve({ status: 'closed', terminalSessionId: this.ref.terminalSessionId }) + } + + async *events(): AsyncIterable<{ readonly type: 'exit'; readonly exitCode: number }> { + yield { type: 'exit', exitCode: 0 } + } +} + +class TestTerminal implements NativeTerminalHost { + readonly columns = 100 + readonly rows = 30 + starts = 0 + stops = 0 + + start(): void { + this.starts += 1 + } + + stop(): void { + this.stops += 1 + } + + write(): void {} +} + +const signals: NativeTerminalSignalPort = { + takeOver: () => () => {}, +} + +const preparationMaterial = { + kind: 'agent-execution-preparation' as const, + schemaVersion: 1 as const, + preparationId: 'preparation-native-actions', + requestDigest: `sha256:${'1'.repeat(64)}` as const, + authoredProfileDigest: `sha256:${'2'.repeat(64)}` as const, + effectiveProfileDigest: `sha256:${'2'.repeat(64)}` as const, + backend: 'test-backend', + harness: 'pi' as const, + harnessVersion: 'test-harness-1', + resolvedModel: { requested: 'test/model', resolved: 'test/model' }, + workspace: { + leaseId: 'workspace-lease-native-actions', + provider: 'test-provider', + identityDigest: `sha256:${'3'.repeat(64)}` as const, + isolation: 'per-run' as const, + sourceSnapshotDigest: `sha256:${'4'.repeat(64)}` as const, + sourceSnapshotPolicy: { + kind: 'provider-declared' as const, + name: 'test-snapshot', + version: 1, + digest: `sha256:${'5'.repeat(64)}` as const, + }, + preparedWorkspaceDigest: `sha256:${'6'.repeat(64)}` as const, + profileActivationDigest: `sha256:${'7'.repeat(64)}` as const, + }, + axisResults: [], + executionPlanDigest: `sha256:${'8'.repeat(64)}` as const, + materializer: { name: 'test-materializer', version: '1' }, + expiresAtMs: 4_102_444_800_000, +} + +const interactiveRef: AgentInteractiveSessionRef = { + run: { + provider: 'test-provider', + environmentId: 'environment-native-actions', + sessionId: 'session-native-actions', + executionId: 'execution-native-actions', + runId: 'provider-run-native-actions', + requestDigest: preparationMaterial.requestDigest, + }, + preparationReceipt: agentExecutionPreparationReceiptSchema.parse({ + ...preparationMaterial, + digest: canonicalCandidateDigest(preparationMaterial), + }), + incarnationId: 'incarnation-native-actions', + startedAt: '2026-08-16T00:00:00.000Z', +} + +function retainedHandle( + terminalSession: AgentInteractiveTerminalSession, + attached: AgentInteractiveSessionControlClaimRequest[], +): RetainedInteractiveRunHandle { + const ref = interactiveRef + return { + ref, + capabilities: {} as RetainedInteractiveRunHandle['capabilities'], + claimControl: async (request) => ({ + operationId: request.operationId, + requestDigest: request.requestDigest, + ref, + status: 'accepted', + control: { + refDigest: canonicalCandidateDigest(ref), + generation: 1, + leaseId: 'lease-native-actions', + holderId: request.holderId, + expiresAt: '2099-01-01T00:00:00.000Z', + }, + }), + status: async () => ({ state: 'running', ref }), + attach: async (request) => { + attached.push({ + operationId: 'attach-observation', + requestDigest: `sha256:${'2'.repeat(64)}`, + ref, + holderId: request.control.holderId, + expectedGeneration: request.control.generation, + }) + return terminalSession + }, + sendPrompt: async () => { + throw new Error('not used') + }, + stop: async () => { + throw new Error('not used') + }, + } +} + +test('starts one Runtime-owned native run and returns to Braid after remote exit', async () => { + const terminal = new TestTerminal() + const attached: AgentInteractiveSessionControlClaimRequest[] = [] + const handle = retainedHandle(new ExitTerminalSession(), attached) + const sends: Array<{ readonly text: string; readonly mode?: string }> = [] + const settlements: unknown[] = [] + let settleCompletion: () => void = () => {} + const completion = new Promise((resolve) => { + settleCompletion = resolve + }) + const app = { + state: () => ({ activeRunId: null, runs: [] }), + send: (input: { readonly text: string; readonly mode?: string }) => { + sends.push(input) + return { runId: 'run-native-actions', completion } + }, + reconnectRun: async () => ({}), + detachRun: async () => { + throw new Error('remote exit must not detach') + }, + } as unknown as Pick + const execution: NativeInteractiveExecutionControl = { + waitForHandle: async () => handle, + settle: (_runId, outcome) => { + settlements.push(outcome) + settleCompletion() + }, + } + let suspends = 0 + let resumes = 0 + let operation = 0 + const actions = createNativeInteractiveUiActions({ + current: () => ({ app, nativeInteractive: execution }), + terminal, + signals: () => signals, + suspend: () => { + suspends += 1 + }, + resume: () => { + resumes += 1 + }, + nextOperationId: () => `operation-${++operation}`, + holderId: 'braid-native-actions', + }) + + const result = await actions.run({ action: 'start', initialPrompt: 'inspect the failing tests' }) + + assert.deepEqual(result, { kind: 'returned', runId: 'run-native-actions', outcome: 'exited' }) + assert.deepEqual(sends, [ + { operationId: 'operation-1', text: 'inspect the failing tests', mode: 'interactive' }, + ]) + assert.deepEqual(settlements, [{ kind: 'exited', exitCode: 0 }]) + assert.equal(attached[0]?.holderId, 'braid-native-actions') + assert.equal(attached[0]?.expectedGeneration, 1) + assert.equal(terminal.starts, 1) + assert.equal(terminal.stops, 1) + assert.equal(suspends, 1) + assert.equal(resumes, 1) +}) + +test('requires a prompt and explains unavailable native providers', async () => { + const app = { + state: () => ({ activeRunId: null, runs: [] }), + } as unknown as Pick + const actions = createNativeInteractiveUiActions({ + current: () => ({ app }), + terminal: new TestTerminal(), + signals: () => signals, + suspend: () => {}, + resume: () => {}, + nextOperationId: () => 'operation-unused', + holderId: 'braid-native-actions', + }) + + assert.deepEqual(actions.availability('start'), { + available: false, + reason: 'Select a retained Tangle Sandbox connection with native terminal support', + }) + + const supported = createNativeInteractiveUiActions({ + current: () => ({ + app, + nativeInteractive: { + waitForHandle: async () => { + throw new Error('must not wait without a prompt') + }, + settle: () => {}, + }, + }), + terminal: new TestTerminal(), + signals: () => signals, + suspend: () => {}, + resume: () => {}, + nextOperationId: () => 'operation-unused', + holderId: 'braid-native-actions', + }) + assert.deepEqual(await supported.run({ action: 'start' }), { + kind: 'error', + message: 'Usage: /interactive ', + }) +}) diff --git a/test/native-interactive-command.test.ts b/test/native-interactive-command.test.ts new file mode 100644 index 0000000..f617492 --- /dev/null +++ b/test/native-interactive-command.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { TuiMainScreen } from '@earendil-works/pi-tui' +import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' +import { createBraidApplication } from '../src/app/composition.js' +import type { + NativeInteractiveCommand, + NativeInteractiveUiActions, +} from '../src/views/shared/native-interactive-actions.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' +import { createBraidTheme } from '../src/views/tui/theme.js' +import { VirtualTerminal } from './support/virtual-terminal.js' + +async function waitUntil(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('Timed out waiting for native terminal command') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} + +function terminalWith(actions?: NativeInteractiveUiActions): { + readonly terminal: VirtualTerminal + readonly view: BraidTerminalApp + readonly done: Promise +} { + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const terminal = new VirtualTerminal(80, 24) + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui: new TuiMainScreen(terminal), + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'operation-native-command', + ...(actions === undefined ? {} : { nativeInteractive: actions }), + }) + return { terminal, view, done: view.start() } +} + +test('native terminal commands cross one typed terminal-only port', async () => { + const commands: NativeInteractiveCommand[] = [] + const actions: NativeInteractiveUiActions = { + availability: () => ({ available: true }), + run: async (command) => { + commands.push(command) + return { kind: 'returned', runId: 'run-native', outcome: 'detached' } + }, + } + for (const input of ['/interactive inspect the failing tests', '/attach run-native']) { + const { terminal, view, done } = terminalWith(actions) + const expectedCount = commands.length + 1 + try { + terminal.sendInput(input) + terminal.sendInput('\r') + await waitUntil(() => commands.length === expectedCount) + } finally { + view.stop() + await done + } + } + assert.deepEqual(commands, [ + { action: 'start', initialPrompt: 'inspect the failing tests' }, + { action: 'attach', runId: 'run-native' }, + ]) +}) + +test('native commands explain unavailable capability without calling the port', async () => { + let calls = 0 + const actions: NativeInteractiveUiActions = { + availability: (action) => ({ + available: false, + reason: action === 'start' ? 'This connection has no native terminal' : 'No retained session', + }), + run: async () => { + calls += 1 + return { kind: 'error', message: 'must not run' } + }, + } + const { terminal, view, done } = terminalWith(actions) + try { + terminal.sendInput('/interactive\r') + await waitUntil(() => + /This connection has no native terminal/u.test(terminal.getViewport().join('\n')), + ) + assert.equal(calls, 0) + } finally { + view.stop() + await done + } +}) diff --git a/test/native-interactive-run-broker.test.ts b/test/native-interactive-run-broker.test.ts new file mode 100644 index 0000000..0a786a4 --- /dev/null +++ b/test/native-interactive-run-broker.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { RetainedInteractiveRunHandle } from '@tangle-network/agent-runtime/kernel' +import { NativeInteractiveRunBroker } from '../src/adapters/runtime/native-interactive-run-broker.js' + +const HANDLE = Object.freeze({ ref: Object.freeze({}) }) as unknown as RetainedInteractiveRunHandle + +test('native interactive broker joins UI and execution regardless of arrival order', async () => { + const broker = new NativeInteractiveRunBroker() + const waiting = broker.waitForHandle('run-native') + const lease = broker.open('run-native') + lease.publish(HANDLE) + assert.equal(await waiting, HANDLE) + + const outcome = lease.outcome() + broker.settle('run-native', { kind: 'exited', exitCode: 0 }) + assert.deepEqual(await outcome, { kind: 'exited', exitCode: 0 }) + lease.close() +}) + +test('native interactive broker rejects duplicate execution and caller-local aborts', async () => { + const broker = new NativeInteractiveRunBroker() + const lease = broker.open('run-native') + assert.throws(() => broker.open('run-native'), /already has an active execution/u) + + const abort = new AbortController() + const waiting = broker.waitForHandle('run-native', { signal: abort.signal }) + abort.abort(new Error('viewer stopped')) + await assert.rejects(waiting, /viewer stopped/u) + + lease.publish(HANDLE) + assert.equal(await broker.waitForHandle('run-native'), HANDLE) + lease.fail(new Error('execution failed')) + await assert.rejects(lease.outcome(), /execution failed/u) + lease.close() +}) diff --git a/test/native-terminal-transport.test.ts b/test/native-terminal-transport.test.ts new file mode 100644 index 0000000..884efe2 --- /dev/null +++ b/test/native-terminal-transport.test.ts @@ -0,0 +1,413 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { + AgentTerminalSession, + TerminalDetachAck, + TerminalInput, + TerminalOutputEvent, + TerminalResize, + TerminalSessionRef, +} from '@tangle-network/agent-interface' +import { createNativeTerminalTransport } from '../src/index.js' +import type { + NativeTerminalHost, + NativeTerminalSignalPort, +} from '../src/ports/native-terminal-transport.js' + +const sessionId = 'terminal-transport-test' + +class EventStream { + private readonly values: T[] = [] + private closed = false + private resolveNext: ((value: T | undefined) => void) | undefined + + push(value: T): void { + if (this.closed) throw new Error('event stream is closed') + const resolve = this.resolveNext + this.resolveNext = undefined + if (resolve !== undefined) resolve(value) + else this.values.push(value) + } + + close(): void { + this.closed = true + const resolve = this.resolveNext + this.resolveNext = undefined + resolve?.(undefined) + } + + async *read(signal?: AbortSignal): AsyncIterable { + while (true) { + const value = await this.next(signal) + if (value === undefined) return + yield value + } + } + + private next(signal?: AbortSignal): Promise { + if (signal?.aborted || this.closed) return Promise.resolve(undefined) + const value = this.values.shift() + if (value !== undefined) return Promise.resolve(value) + return new Promise((resolve) => { + const onAbort = (): void => { + if (this.resolveNext === resolveNext) this.resolveNext = undefined + resolve(undefined) + } + const resolveNext = (next: T | undefined): void => { + signal?.removeEventListener('abort', onAbort) + resolve(next) + } + this.resolveNext = resolveNext + signal?.addEventListener('abort', onAbort, { once: true }) + }) + } +} + +class FakeTerminal implements NativeTerminalHost { + columns = 80 + rows = 24 + readonly writes: string[] = [] + startCount = 0 + stopCount = 0 + private onInputHandler: ((data: string) => void) | undefined + private onResizeHandler: (() => void) | undefined + + start(onInput: (data: string) => void, onResize: () => void): void { + this.startCount += 1 + this.onInputHandler = onInput + this.onResizeHandler = onResize + } + + stop(): void { + this.stopCount += 1 + this.onInputHandler = undefined + this.onResizeHandler = undefined + } + + write(data: string): void { + this.writes.push(data) + } + + input(data: string): void { + this.onInputHandler?.(data) + } + + resize(columns: number, rows: number): void { + this.columns = columns + this.rows = rows + this.onResizeHandler?.() + } +} + +class FakeSignals implements NativeTerminalSignalPort { + takeOverCount = 0 + releaseCount = 0 + private handler: ((exitCode: number) => void) | undefined + + takeOver(handler: (exitCode: number) => void): () => void { + this.takeOverCount += 1 + this.handler = handler + return () => { + this.releaseCount += 1 + this.handler = undefined + } + } + + emit(exitCode: number): void { + this.handler?.(exitCode) + } +} + +class FakeSession implements AgentTerminalSession { + readonly ref: TerminalSessionRef = { + terminalSessionId: sessionId, + parentExecutionId: 'execution-transport-test', + name: 'native transport test', + shell: '/bin/sh', + cwd: '/workspace', + cols: 80, + rows: 24, + createdAt: '2026-08-16T00:00:00.000Z', + lastActivityAt: '2026-08-16T00:00:00.000Z', + expiresAt: '2026-08-17T00:00:00.000Z', + isRunning: true, + attachCount: 1, + } + readonly cursors = { earliest: 0, latest: 0 } + readonly inputs: TerminalInput[] = [] + readonly resizes: TerminalResize[] = [] + readonly stream = new EventStream() + detachCount = 0 + closeCount = 0 + detachAck: TerminalDetachAck = { status: 'detached', terminalSessionId: sessionId } + + input(input: TerminalInput, options?: { signal?: AbortSignal }): Promise { + if (options?.signal?.aborted) return Promise.reject(new Error('input aborted')) + this.inputs.push(input) + return Promise.resolve() + } + + resize(resize: TerminalResize, options?: { signal?: AbortSignal }): Promise { + if (options?.signal?.aborted) return Promise.reject(new Error('resize aborted')) + this.resizes.push(resize) + return Promise.resolve() + } + + detach(options?: { signal?: AbortSignal }): Promise { + this.detachCount += 1 + if (options?.signal?.aborted) return Promise.reject(new Error('detach aborted')) + return Promise.resolve(this.detachAck) + } + + close(): Promise { + this.closeCount += 1 + return Promise.resolve({ status: 'closed', terminalSessionId: sessionId }) + } + + events(options?: { signal?: AbortSignal }): AsyncIterable { + return this.stream.read(options?.signal) + } +} + +class HungInputSession extends FakeSession { + inputStarted: (() => void) | undefined + inputSignal: AbortSignal | undefined + + override input(input: TerminalInput, options?: { signal?: AbortSignal }): Promise { + this.inputs.push(input) + this.inputSignal = options?.signal + this.inputStarted?.() + return new Promise(() => {}) + } +} + +class HungResizeSession extends FakeSession { + resizeStarted: (() => void) | undefined + resizeSignal: AbortSignal | undefined + + override resize(resize: TerminalResize, options?: { signal?: AbortSignal }): Promise { + this.resizes.push(resize) + this.resizeSignal = options?.signal + this.resizeStarted?.() + return new Promise(() => {}) + } +} + +function fixture(session: FakeSession = new FakeSession()) { + const terminal = new FakeTerminal() + const signals = new FakeSignals() + const transport = createNativeTerminalTransport({ session, terminal, signals }) + return { session, terminal, signals, transport } +} + +async function started(terminal: FakeTerminal): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (terminal.startCount === 1) return + await new Promise((resolve) => setImmediate(resolve)) + } + assert.fail('transport did not start the local terminal') +} + +async function settlesWithin(promise: Promise, timeoutMs = 250): Promise { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`promise did not settle within ${timeoutMs}ms`)), + timeoutMs, + ) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +test('forwards output and restores terminal and signal state', async () => { + const { session, terminal, signals, transport } = fixture() + const running = transport.run() + await started(terminal) + session.stream.push({ type: 'output', seq: 0, data: '\u001b[2Jremote-ui' }) + session.stream.push({ type: 'exit', exitCode: 0 }) + + const result = await running + assert.deepEqual(terminal.writes, ['\u001b[2Jremote-ui']) + assert.deepEqual(result.outcome, { + kind: 'remote-exit', + sessionId, + reason: 'exited', + exitCode: 0, + }) + assert.equal(result.cleanup.terminal, 'restored') + assert.equal(result.cleanup.signal, 'restored') + assert.equal(result.cleanup.remote, 'not-required') + assert.equal(terminal.stopCount, 1) + assert.equal(signals.releaseCount, 1) + assert.equal(session.detachCount, 0) + assert.equal(session.closeCount, 0) +}) + +test('forwards local input without interpreting the remote TUI', async () => { + const { session, terminal, transport } = fixture() + const running = transport.run() + await started(terminal) + terminal.input('hello remote') + await new Promise((resolve) => setImmediate(resolve)) + session.stream.push({ type: 'exit', exitCode: 0 }) + + await running + assert.deepEqual(session.inputs, [{ data: 'hello remote' }]) +}) + +test('forwards the current local resize once', async () => { + const { session, terminal, transport } = fixture() + const running = transport.run() + await started(terminal) + terminal.resize(132, 43) + await new Promise((resolve) => setImmediate(resolve)) + session.stream.push({ type: 'exit', exitCode: 0 }) + + await running + assert.deepEqual(session.resizes, [{ cols: 132, rows: 43 }]) +}) + +test('detaches on Ctrl+] and leaves the remote process running', async () => { + const { session, terminal, transport } = fixture() + const running = transport.run() + await started(terminal) + terminal.input('before\u001dignored') + + const result = await running + assert.deepEqual(session.inputs, [{ data: 'before' }]) + assert.deepEqual(result.outcome, { kind: 'detached', sessionId, trigger: 'user' }) + assert.equal(result.cleanup.remote, 'detached') + assert.equal(session.detachCount, 1) + assert.equal(session.closeCount, 0) + assert.equal(terminal.stopCount, 1) +}) + +test('detaches promptly when an in-flight input never settles', async () => { + const session = new HungInputSession() + const inputStarted = new Promise((resolve) => { + session.inputStarted = resolve + }) + const { terminal, transport } = fixture(session) + const running = transport.run() + await started(terminal) + terminal.input('before detach') + await inputStarted + terminal.input('\u001d') + + const result = await settlesWithin(running) + assert.deepEqual(result.outcome, { kind: 'detached', sessionId, trigger: 'user' }) + assert.equal(result.cleanup.remote, 'detached') + assert.equal(terminal.stopCount, 1) + assert.equal(session.detachCount, 1) + assert.equal(session.inputSignal?.aborted, true) +}) + +test('stops promptly and cancels queued resizes when one resize never settles', async () => { + const session = new HungResizeSession() + const resizeStarted = new Promise((resolve) => { + session.resizeStarted = resolve + }) + const { terminal, transport } = fixture(session) + const controller = new AbortController() + const running = transport.run({ signal: controller.signal }) + await started(terminal) + terminal.resize(132, 43) + await resizeStarted + terminal.resize(160, 50) + controller.abort() + + const result = await settlesWithin(running) + assert.deepEqual(result.outcome, { kind: 'aborted', sessionId, source: 'caller' }) + assert.equal(result.cleanup.remote, 'detached') + assert.equal(terminal.stopCount, 1) + assert.equal(session.detachCount, 1) + assert.deepEqual(session.resizes, [{ cols: 132, rows: 43 }]) + assert.equal(session.resizeSignal?.aborted, true) +}) + +test('reports a remote exit without detaching or closing it', async () => { + const { session, terminal, transport } = fixture() + const running = transport.run() + await started(terminal) + session.stream.push({ type: 'exit', exitSignal: 'SIGTERM' }) + + const result = await running + assert.deepEqual(result.outcome, { + kind: 'remote-exit', + sessionId, + reason: 'exited', + exitSignal: 'SIGTERM', + }) + assert.equal(session.detachCount, 0) + assert.equal(session.closeCount, 0) +}) + +test('reports transport errors and preserves the remote process', async () => { + const { session, terminal, transport } = fixture() + const running = transport.run() + await started(terminal) + session.stream.push({ type: 'error', message: 'remote stream failed' }) + + const result = await running + assert.deepEqual(result.outcome, { + kind: 'transport-error', + sessionId, + phase: 'events', + message: 'remote stream failed', + }) + assert.equal(result.cleanup.remote, 'detached') + assert.equal(session.detachCount, 1) + assert.equal(session.closeCount, 0) +}) + +test('aborts from the caller and restores every local boundary', async () => { + const { session, terminal, signals, transport } = fixture() + const controller = new AbortController() + const running = transport.run({ signal: controller.signal }) + await started(terminal) + controller.abort() + + const result = await running + assert.deepEqual(result.outcome, { kind: 'aborted', sessionId, source: 'caller' }) + assert.equal(result.cleanup.terminal, 'restored') + assert.equal(result.cleanup.signal, 'restored') + assert.equal(result.cleanup.remote, 'detached') + assert.equal(signals.releaseCount, 1) + assert.equal(session.closeCount, 0) +}) + +test('aborts from the process signal latch with a typed exit code', async () => { + const { session, terminal, signals, transport } = fixture() + const running = transport.run() + await started(terminal) + signals.emit(130) + + const result = await running + assert.deepEqual(result.outcome, { kind: 'aborted', sessionId, source: 'signal', exitCode: 130 }) + assert.equal(result.cleanup.signal, 'restored') + assert.equal(signals.takeOverCount, 1) + assert.equal(signals.releaseCount, 1) + assert.equal(session.closeCount, 0) +}) + +test('memoizes the run and makes cleanup idempotent', async () => { + const { session, terminal, signals, transport } = fixture() + const first = transport.run() + const second = transport.run() + assert.equal(first, second) + await started(terminal) + session.stream.push({ type: 'exit', exitCode: 0 }) + + const result = await first + assert.equal(await transport.run(), result) + assert.equal(terminal.stopCount, 1) + assert.equal(signals.takeOverCount, 1) + assert.equal(signals.releaseCount, 1) + assert.equal(session.detachCount, 0) +}) diff --git a/test/production-composition.test.ts b/test/production-composition.test.ts index b250e5f..08615d4 100644 --- a/test/production-composition.test.ts +++ b/test/production-composition.test.ts @@ -19,13 +19,13 @@ import { canonicalCandidateJson, defineAgentProfile, } from '@tangle-network/agent-interface' -import { HeadlessCredentialStore } from '../src/adapters/credentials/headless-store.js' -import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' import { materializeBridgeModelRoute, portableBridgeModel, qualifyBridgeProfileModel, } from '../src/adapters/connections/cli-bridge-model-route.js' +import { HeadlessCredentialStore } from '../src/adapters/credentials/headless-store.js' +import { MemoryCredentialStore } from '../src/adapters/credentials/memory.js' import { resolveProductionBackend } from '../src/adapters/runtime/production-backend-resolver.js' import { ApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { @@ -72,6 +72,7 @@ import { FixedClock } from '../src/ports/clock.js' import { credentialRef } from '../src/ports/credentials.js' import { SequenceIds } from '../src/ports/ids.js' import { startRuntimeBridgeServer } from './support/runtime-bridge-server.js' +import { FakeTangleRetainedSandbox } from './support/tangle-retained-sandbox.js' const at = '2026-08-03T12:00:00.000Z' @@ -209,6 +210,43 @@ test('normal composition streams a configured CLI Bridge turn through agent-runt } }) +test('retained CLI Bridge turns forward request-scoped model credentials only on the model call', async () => { + const bridge = await startRuntimeBridgeServer() + const seen: { token?: string | null; baseUrl?: string | null } = {} + try { + const record = connection('cli-bridge', 'scoped-model', bridge.endpoint) + const result = await runProductionTurn( + composition(record, { + bridgeModelCredential: { + key: 'BRAID_TEST_MODEL_TOKEN', + baseUrlKey: 'BRAID_TEST_MODEL_BASE_URL', + provider: { + get: async (key: string) => + key === 'BRAID_TEST_MODEL_TOKEN' + ? 'test-model-token' + : key === 'BRAID_TEST_MODEL_BASE_URL' + ? 'https://chatgpt.com/backend-api' + : undefined, + }, + }, + fetch: async (input, init) => { + if (new URL(String(input)).pathname === '/v1/chat/completions') { + seen.token = requestHeader(init, 'x-cli-bridge-model-credential') + seen.baseUrl = requestHeader(init, 'x-cli-bridge-model-base-url') + } + return fetch(input, init) + }, + }), + ) + + assert.equal(result.state.runs[0]?.status, 'completed') + assert.equal(seen.token, 'test-model-token') + assert.equal(seen.baseUrl, 'https://chatgpt.com/backend-api') + } finally { + await bridge.close() + } +}) + test('production composition persists acknowledged CLI Bridge cancellation', async () => { const bridge = await startRuntimeBridgeServer({ holdStreams: true }) const clock = new FixedClock() @@ -391,6 +429,80 @@ test('production rejects remote cleartext endpoints unless an explicit policy ac assert.equal(accepted.connection.id, remote.id) }) +test('retained Tangle composition exposes one native control through durable startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'braid-production-native-control-')) + const record: ConnectionRecord = { + ...connection('tangle-sandbox', 'native-control', 'https://sandbox.tangle.tools'), + providerOptions: { + transport: 'https', + lifecycle: 'retained', + idleTtlSeconds: 1_800, + }, + } + const selected = composition( + record, + {}, + defineAgentProfile({ + name: 'native production profile', + harness: 'opencode', + model: { default: 'openai/gpt-5', provider: 'openai' }, + }), + ) + const composed = createProductionComposition(selected) + assert.notEqual(composed.nativeInteractive, undefined) + + const durable = await createDurableBraidApplication({ + path: join(root, 'braid.db'), + workspaceRoot: root, + credentialStore: new MemoryCredentialStore(), + production: selected, + }) + try { + assert.notEqual(durable.nativeInteractive, undefined) + } finally { + await durable.app.close() + await durable.storage.close() + } +}) + +test('retained Tangle composition routes interactive admission without creating a sandbox', async () => { + const sandbox = new FakeTangleRetainedSandbox() + const record: ConnectionRecord = { + ...connection('tangle-sandbox', 'interactive-routing', 'https://sandbox.tangle.tools', true), + providerOptions: { + transport: 'https', + lifecycle: 'retained', + idleTtlSeconds: 1_800, + }, + } + const credentials = new MemoryCredentialStore() + await credentials.store({ + ref: credentialRef('cred:v1:credential-interactive-routing'), + value: Buffer.from('test-only-sandbox-key'), + }) + const selectedProfile = defineAgentProfile({ + name: 'interactive routing profile', + harness: 'opencode', + model: { default: 'openai/gpt-5', provider: 'openai' }, + }) + const composed = createProductionComposition( + composition(record, { credentials, sandboxClient: sandbox.client() }, selectedProfile), + ) + const admission = await composed.execution.admit?.({ + operationId: 'operation-interactive-routing', + runId: 'run-interactive-routing', + text: 'Inspect this workspace.', + profile: selectedProfile, + mode: 'interactive', + connectionId: record.id, + signal: new AbortController().signal, + }) + + assert.equal(admission?.materializationReceipt?.surface, 'interactive-agent') + assert.equal(admission?.capabilities?.streaming.replay, true) + assert.equal(sandbox.createCalls.length, 0) +}) + test('bin startup loads a canonical profile and exact connection from a bounded config file', async () => { const root = await mkdtemp(join(tmpdir(), 'braid-production-startup-')) const profilePath = join(root, 'profile.json') @@ -1061,8 +1173,13 @@ test('protected Bridge auth survives setup, restart, and a real turn without per assert.notEqual(secondBody.session_id, firstBody.session_id) for (const body of [firstBody, secondBody]) { const runId = String(body.run_id) - assert.deepEqual(body.metadata, { + const metadata = body.metadata as Record + const retainedIntentDigest = String(metadata.retainedIntentDigest) + assert.match(retainedIntentDigest, /^sha256:[0-9a-f]{64}$/u) + assert.deepEqual(metadata, { retainedIdempotencyKey: `environment-braid-${runId}`, + retainedIntentDigest, + retainedRunId: `retained-intent-run:${retainedIntentDigest.slice('sha256:'.length)}`, sessionId: `session-braid-${runId}`, executionId: runId, }) @@ -1946,6 +2063,8 @@ test('first-run model validation authorizes its token limit in a separate AgentP readonly default?: unknown readonly provider?: unknown readonly reasoningEffort?: unknown + readonly maxVisibleOutputTokens?: unknown + readonly maxTotalOutputTokens?: unknown readonly metadata?: Record } } @@ -1953,7 +2072,9 @@ test('first-run model validation authorizes its token limit in a separate AgentP assert.equal(validationProfile.model?.default, 'tangle-router/glm-5.2') assert.equal(validationProfile.model?.provider, 'tangle-router') assert.equal(validationProfile.model?.reasoningEffort, 'high') - assert.equal(validationProfile.model?.metadata?.maxTokens, 1) + assert.equal(validationProfile.model?.maxVisibleOutputTokens, 1) + assert.equal(validationProfile.model?.maxTotalOutputTokens, 1) + assert.equal(validationProfile.model?.metadata, undefined) assert.equal('effort' in (body ?? {}), false) }) diff --git a/test/profiles.test.ts b/test/profiles.test.ts index a5c6b01..7390634 100644 --- a/test/profiles.test.ts +++ b/test/profiles.test.ts @@ -217,7 +217,7 @@ test('the installed canonical profile schema round-trips every field and extensi assert.equal(canonicalCandidateJson(imported.profile), json) assert.deepEqual(imported.profile.extensions, fullProfile.extensions) assert.deepEqual(imported.profile.resources, fullProfile.resources) - assert.equal(AGENT_INTERFACE_PACKAGE_VERSION, '0.52.0') + assert.equal(AGENT_INTERFACE_PACKAGE_VERSION, '1.0.0') }) test('unknown canonical fields fail closed while namespaced extensions remain opaque', () => { diff --git a/test/retained-execution-lifecycle.test.ts b/test/retained-execution-lifecycle.test.ts index 58efd1b..64264fe 100644 --- a/test/retained-execution-lifecycle.test.ts +++ b/test/retained-execution-lifecycle.test.ts @@ -1,14 +1,24 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { defineAgentProfile, type AgentExactRunControlRef } from '@tangle-network/agent-interface' +import { + type AgentExactRunControlRef, + defineAgentProfile, + type InteractionResponseCommand, +} from '@tangle-network/agent-interface' import type { RetainedRunHandle } from '@tangle-network/agent-runtime/kernel' import { type RetainedExecutionPlan, RetainedExecutionPort, } from '../src/adapters/runtime/retained-execution.js' -import { DEFAULT_RUN_CAPABILITIES, type ExecuteTurnInput } from '../src/ports/execution.js' +import { + DEFAULT_RUN_CAPABILITIES, + type ExecuteTurnInput, + type RetainedRunAdmissionRecord, +} from '../src/ports/execution.js' +import { RETAINED_RUN_HANDLE_CAPABILITIES } from './support/retained-run-capabilities.js' const now = '2026-08-12T12:00:00.000Z' + const profile = defineAgentProfile({ name: 'Retained lifecycle test', harness: 'pi', @@ -43,11 +53,13 @@ function handle( exact: AgentExactRunControlRef, options: { readonly cancel?: (input: CancelOptions) => Promise + readonly respondToInteraction?: RetainedRunHandle['respondToInteraction'] readonly status?: RetainedRunHandle['status'] } = {}, ): RetainedRunHandle { return { controlRef: exact, + capabilities: RETAINED_RUN_HANDLE_CAPABILITIES, status: options.status ?? (async () => ({ @@ -61,9 +73,11 @@ function handle( async result() { return { text: '', success: true } }, - async respondToInteraction() { - throw new Error('interaction is not part of this test') - }, + respondToInteraction: + options.respondToInteraction ?? + (async () => { + throw new Error('interaction is not part of this test') + }), async contextBoundary() { return null }, @@ -156,6 +170,119 @@ test('pre-start cancellation prevents the retained plan from starting', async () assert.equal(replayed.outcome, 'already-applied') }) +test('restart cancellation of a persisted retained intent does not recover provider work', async () => { + const exact = controlRef('restart-intent') + const execution = new RetainedExecutionPort({ + resolve: async () => { + throw new Error('a cancelled intent must not resolve a new plan') + }, + recover: async () => { + throw new Error('a cancelled intent must not recover provider work') + }, + }) + const admission: RetainedRunAdmissionRecord = { + phase: 'intent', + provider: exact.provider, + idempotencyKey: 'environment-restart-intent', + turnId: 'turn-restart-intent', + sessionId: exact.sessionId, + executionId: exact.executionId, + runId: 'runtime-restart-intent', + requestedProfileDigest: `sha256:${'1'.repeat(64)}`, + requestDigest: `sha256:${'2'.repeat(64)}`, + } + + const acknowledgement = await execution.cancelRun({ + operationId: 'operation-restart-intent-cancel', + runId: 'run-restart-intent', + providerSessionId: exact.sessionId, + retainedAdmission: admission, + }) + + assert.deepEqual(acknowledgement, { + operationId: 'operation-restart-intent-cancel', + outcome: 'accepted', + detail: 'cancelled-before-start', + }) +}) + +test('restart detach validates the persisted retained control reference', async () => { + const exact = controlRef('restart-detach') + let recoveries = 0 + const recoveredPlan = plan(exact, async () => handle(exact)) + const admission: RetainedRunAdmissionRecord = { + phase: 'dispatched', + idempotencyKey: 'environment-restart-detach', + turnId: 'turn-restart-detach', + controlRef: exact, + } + const execution = new RetainedExecutionPort({ + resolve: async () => { + throw new Error('restart detach must not resolve a fresh run') + }, + recover: async ({ controlRef }) => { + recoveries += 1 + assert.deepEqual(controlRef, exact) + return recoveredPlan + }, + }) + + await assert.rejects( + execution.detachRun({ + operationId: 'operation-restart-detach-conflict', + runId: 'run-restart-detach', + providerSessionId: exact.sessionId, + controlRef: { ...exact, requestDigest: `sha256:${'3'.repeat(64)}` }, + retainedAdmission: admission, + }), + /conflicts with the persisted run/u, + ) + const detached = await execution.detachRun({ + operationId: 'operation-restart-detach', + runId: 'run-restart-detach', + providerSessionId: exact.sessionId, + retainedAdmission: admission, + }) + assert.equal(detached.outcome, 'accepted') + assert.equal(recoveries, 1) +}) + +test('restart controls reject a process reference against a pre-dispatch admission', async () => { + const exact = controlRef('pre-dispatch-control') + const conflicting = { ...exact, environmentId: 'environment-other' } + const admission: RetainedRunAdmissionRecord = { + phase: 'environment', + provider: exact.provider, + environmentId: exact.environmentId, + idempotencyKey: 'environment-pre-dispatch-control', + turnId: 'turn-pre-dispatch-control', + sessionId: exact.sessionId, + executionId: exact.executionId, + } + let recoveries = 0 + const execution = new RetainedExecutionPort({ + resolve: async () => { + throw new Error('pre-dispatch control must not resolve a fresh run') + }, + recover: async () => { + recoveries += 1 + return plan(exact, async () => handle(exact)) + }, + }) + + await assert.rejects( + execution.detachRun({ + operationId: 'operation-pre-dispatch-control', + runId: 'run-pre-dispatch-control', + providerSessionId: exact.sessionId, + controlRef: conflicting, + retainedAdmission: admission, + }), + /pre-dispatch admission/u, + ) + assert.equal(recoveries, 0) +}) + test('in-flight start cancellation omits an expired foreground signal', async () => { const exact = controlRef('in-flight') let resolveStart: (value: RetainedRunHandle) => void = () => undefined @@ -345,6 +472,69 @@ test('a reconciled cancelled snapshot confirms an ambiguous cancellation', async assert.equal(statusCalls, 1) }) +test('interaction response recovers the retained handle and preserves retry acknowledgement', async () => { + const exact = controlRef('interaction') + let responses = 0 + let recoverySignal: AbortSignal | undefined + const retainedHandle = handle(exact, { + respondToInteraction: async (command) => { + responses += 1 + return { + operationId: command.operationId, + binding: command.binding, + commandDigest: command.commandDigest, + status: responses === 1 ? 'accepted' : 'already_resolved_same', + } + }, + }) + const recoveredPlan: RetainedExecutionPlan = { + ...plan(exact, async () => retainedHandle, retainedHandle), + discover: async (runId) => { + assert.equal(runId, 'run-interaction') + return exact + }, + } + const execution = new RetainedExecutionPort({ + resolve: async () => { + throw new Error('a recovered interaction must not resolve a new run') + }, + recover: async ({ runId, providerSessionId, signal }) => { + assert.equal(runId, 'run-interaction') + assert.equal(providerSessionId, exact.sessionId) + recoverySignal = signal + return recoveredPlan + }, + }) + const command: InteractionResponseCommand = { + operationId: 'operation-interaction-response', + binding: { + requestDigest: `sha256:${'b'.repeat(64)}`, + runId: 'run-interaction', + provider: exact.provider, + environmentId: exact.environmentId, + sessionId: exact.sessionId, + executionId: exact.executionId, + interactionId: 'interaction-retained', + }, + commandDigest: `sha256:${'c'.repeat(64)}`, + response: { id: 'interaction-retained', outcome: 'accepted' }, + } + + const signal = new AbortController().signal + assert.deepEqual(await execution.respondInteraction({ command, signal }), { + operationId: command.operationId, + outcome: 'accepted', + detail: 'INTERACTION_RESPONSE_ACCEPTED', + }) + assert.equal(recoverySignal, signal) + assert.deepEqual(await execution.respondInteraction({ command }), { + operationId: command.operationId, + outcome: 'already-applied', + detail: 'INTERACTION_RESPONSE_REPLAYED', + }) + assert.equal(responses, 2) +}) + test('admission rejects at capacity instead of evicting a prepared plan', async () => { let starts = 0 const execution = executionFor(async (runInput) => { diff --git a/test/run-interactions.test.ts b/test/run-interactions.test.ts new file mode 100644 index 0000000..2c34c31 --- /dev/null +++ b/test/run-interactions.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { defineAgentProfile } from '@tangle-network/agent-interface' +import { defaultTangleSandboxCapabilities } from '@tangle-network/agent-provider-tangle' +import { retainedExecutionKey } from '../src/adapters/runtime/retained-execution-state.js' +import { createBraidApplication } from '../src/app/composition.js' +import { MemoryJournal } from '../src/app/journal.js' +import { runEffectRequest } from '../src/app/run-admission-request.js' +import { snapshotRunExecution } from '../src/app/run-execution-snapshot.js' +import { canonicalDigest } from '../src/domain/canonical.js' +import { createAdmissionReceipt } from '../src/domain/receipts.js' +import { requestedInteractionsForRun } from '../src/domain/run-interactions.js' +import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' +import { initialState } from '../src/domain/state.js' +import { FixedClock } from '../src/ports/clock.js' +import { + DEFAULT_RUN_CAPABILITIES, + type ExecuteTurnInput, + type ExecutionPort, + type RunCapabilities, +} from '../src/ports/execution.js' + +const PROFILE = defineAgentProfile({ + name: 'Per-turn interaction test', + harness: 'opencode', + model: { default: 'fixture/interaction-test' }, +}) + +function capabilitiesFor(kinds: readonly string[], responseIdempotency = true): RunCapabilities { + const environment = defaultTangleSandboxCapabilities('opencode') + assert.ok(environment.interactions) + return { + ...DEFAULT_RUN_CAPABILITIES, + environment: { + ...environment, + interactions: { + ...environment.interactions, + kinds: [...kinds], + responseIdempotency, + }, + }, + } +} + +async function dispatchedInput( + mode: string | undefined, + capabilities: RunCapabilities, +): Promise { + let received: ExecuteTurnInput | undefined + const execution: ExecutionPort = { + admit: () => ({ capabilities }), + async *streamTurn(input): AsyncIterable { + received = input + yield { + type: 'final', + task: { id: input.runId, intent: 'interaction wiring test' }, + status: 'completed', + reason: 'completed', + text: 'done', + metadata: { tokenUsage: { input: 1, output: 1 } }, + timestamp: '2026-08-15T00:00:00.000Z', + } + }, + } + const clock = new FixedClock() + const journal = new MemoryJournal(clock) + const app = createBraidApplication({ + profile: PROFILE, + execution, + clock, + journal, + effectStorage: journal, + }) + app.initialize('/workspace') + if (mode !== undefined) { + await app.conversations.branches.setRunOverrides({ + operationId: `operation-mode-${mode}`, + mode, + }) + } + const receipt = app.send({ operationId: `operation-${mode ?? 'normal'}`, text: 'continue' }) + await receipt.completion + assert.ok(received) + return received +} + +test('selected mode survives the snapshot, effect request, receipt, and retry identity', () => { + const state = { ...initialState(PROFILE), workspace: '/workspace' } + const snapshot = snapshotRunExecution( + { operationId: 'operation-mode', text: 'review the plan' }, + state, + PROFILE, + undefined, + 'plan', + ) + assert.equal(snapshot.mode, 'plan') + assert.equal(runEffectRequest(snapshot).mode, 'plan') + + const receipt = createAdmissionReceipt({ + runId: 'run-mode', + turnId: 'turn-mode', + operationId: snapshot.operationId, + conversationId: snapshot.conversationId, + branchId: snapshot.branchId, + admittedAt: '2026-08-15T00:00:00.000Z', + profile: snapshot.profile, + text: snapshot.text, + mode: snapshot.mode, + interactions: { plan: true }, + capabilities: DEFAULT_RUN_CAPABILITIES, + }) + assert.equal(receipt.requested.mode, 'plan') + assert.deepEqual(receipt.requested.interactions, { plan: true }) + + const base: ExecuteTurnInput = { + operationId: 'operation-retry', + runId: 'run-retry', + text: 'retry this', + profile: PROFILE, + signal: new AbortController().signal, + } + assert.notEqual( + retainedExecutionKey({ ...base, mode: 'normal' }), + retainedExecutionKey({ ...base, mode: 'plan' }), + ) + assert.equal( + receipt.requestDigest, + canonicalDigest({ + runId: 'run-mode', + turnId: 'turn-mode', + operationId: snapshot.operationId, + conversationId: snapshot.conversationId, + branchId: snapshot.branchId, + text: snapshot.text, + profileDigest: receipt.profileDigest, + connectionId: null, + mode: 'plan', + interactions: { plan: true }, + contextPlanDigest: null, + }), + ) +}) + +test('normal mode requests advertised permission and question kinds, never plan', async () => { + const advertised = capabilitiesFor(['permission', 'question', 'plan']).environment + assert.ok(advertised) + assert.deepEqual( + requestedInteractionsForRun(undefined, { + environment: advertised, + }), + { permission: true, question: true }, + ) + assert.deepEqual( + (await dispatchedInput(undefined, capabilitiesFor(['permission', 'question', 'plan']))) + .interactions, + { permission: true, question: true }, + ) +}) + +test('plan mode enables plan only when the admitted capability advertises it', async () => { + const advertised = capabilitiesFor(['permission', 'plan']).environment + assert.ok(advertised) + assert.deepEqual( + requestedInteractionsForRun('plan', { + environment: advertised, + }), + { permission: true, plan: true }, + ) + assert.deepEqual( + (await dispatchedInput('plan', capabilitiesFor(['permission', 'question']))).interactions, + { permission: true, question: true }, + ) +}) + +test('unsupported or non-idempotent response capability passes an explicit empty map', async () => { + const unsupported = requestedInteractionsForRun( + 'plan', + capabilitiesFor(['permission', 'question', 'plan'], false), + ) + assert.deepEqual(unsupported, {}) + assert.equal(Object.isFrozen(unsupported), true) + const received = await dispatchedInput('plan', { ...DEFAULT_RUN_CAPABILITIES }) + assert.deepEqual(received.interactions, {}) + assert.equal(Object.isFrozen(received.interactions), true) +}) + +test('the retained execution boundary receives the exact admitted map', async () => { + const expected = { permission: true, question: true, plan: true } + const received = await dispatchedInput( + 'plan', + capabilitiesFor(['permission', 'question', 'plan']), + ) + assert.deepEqual(received.interactions, expected) + assert.equal(Object.isFrozen(received.interactions), true) +}) diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts index 9da198b..bb28353 100644 --- a/test/sanitize.test.ts +++ b/test/sanitize.test.ts @@ -57,7 +57,7 @@ test('numeric token telemetry survives redaction while string tokens never do', totalTokens: 34, cachedPromptTokens: 5, cacheWriteTokens: 3, - model: { maxTokens: 4096 }, + model: { maxVisibleOutputTokens: 4096, maxTotalOutputTokens: 8192 }, tokenUsage: { input: 21, output: 13 }, accessToken: 'secret-canary', poisoned: { inputTokens: 'secret-canary' }, @@ -70,7 +70,7 @@ test('numeric token telemetry survives redaction while string tokens never do', assert.equal(redacted.totalTokens, 34) assert.equal(redacted.cachedPromptTokens, 5) assert.equal(redacted.cacheWriteTokens, 3) - assert.deepEqual(redacted.model, { maxTokens: 4096 }) + assert.deepEqual(redacted.model, { maxVisibleOutputTokens: 4096, maxTotalOutputTokens: 8192 }) assert.deepEqual(redacted.tokenUsage, { input: 21, output: 13 }) assert.equal(redacted.accessToken, '[redacted]') assert.deepEqual(redacted.poisoned, { inputTokens: '[redacted]' }) @@ -159,29 +159,35 @@ test('plain structured redaction preserves only the exact aggregate token counte ) }) -test('profile redaction exposes only the public output-token limit from model metadata', () => { +test('profile redaction preserves maxVisibleOutputTokens and maxTotalOutputTokens', () => { const redacted = redactProfile({ name: 'Public model settings', model: { default: 'tangle-router/glm-5.2', + maxVisibleOutputTokens: 8192, + maxReasoningTokens: 16_384, + maxTotalOutputTokens: 24_576, metadata: { - maxTokens: 8192, route: 'private-route-canary', apiKey: 'private-key-canary', }, }, - metadata: { maxTokens: 65_536, owner: 'private-owner-canary' }, + metadata: { owner: 'private-owner-canary' }, modes: { - fast: { metadata: { maxTokens: 4096, note: 'private-mode-canary' } }, + fast: { metadata: { note: 'private-mode-canary' } }, }, }) - assert.deepEqual(redacted.model?.metadata, { maxTokens: 8192, redacted: '[redacted]' }) + assert.equal(redacted.model?.maxVisibleOutputTokens, 8192) + assert.equal(redacted.model?.maxReasoningTokens, 16_384) + assert.equal(redacted.model?.maxTotalOutputTokens, 24_576) + assert.deepEqual(redacted.model?.metadata, { redacted: '[redacted]' }) assert.deepEqual(redacted.metadata, { redacted: '[redacted]' }) assert.deepEqual(redacted.modes?.fast?.metadata, { redacted: '[redacted]' }) assert.doesNotMatch(JSON.stringify(redacted), /private-(?:route|key|owner|mode)-canary/u) assert.deepEqual( - redactProfile({ model: { metadata: { maxTokens: 'private-token-canary' } } }).model?.metadata, + redactProfile({ model: { metadata: { legacyOutputLimit: 'private-token-canary' } } }).model + ?.metadata, { redacted: '[redacted]' }, ) }) diff --git a/test/scripts.test.ts b/test/scripts.test.ts index 4d4c7cf..a3a66de 100644 --- a/test/scripts.test.ts +++ b/test/scripts.test.ts @@ -69,6 +69,31 @@ test('W5 exposes stable checks for every requested release surface', () => { for (const script of required) assert.equal(typeof packageJson.scripts[script], 'string', script) }) +test('deterministic visual capture stays separate from the explicit live demo', async () => { + const [visualSource, liveDemoSource] = await Promise.all([ + readFile(new URL('../../scripts/capture-visual.mjs', import.meta.url), 'utf8'), + readFile(new URL('../../scripts/live-demo.mjs', import.meta.url), 'utf8'), + ]) + + assert.equal( + packageJson.scripts['capture:visual'], + 'pnpm run build && node scripts/capture-visual.mjs', + ) + assert.equal( + packageJson.scripts['capture:demo:live'], + 'pnpm run build && node scripts/live-demo.mjs', + ) + assert.match(visualSource, /--fixture', 'deterministic'/u) + assert.match(visualSource, /fullInterfaceMarker = uiFixture === 'interaction'\s*\? '↑↓ move'/u) + assert.match(visualSource, /output\.includes\(fullInterfaceMarker\)/u) + assert.doesNotMatch(visualSource, /output\.includes\('›'\)/u) + assert.match(visualSource, /stateFixture: 'deterministic'/u) + assert.match(visualSource, /liveDemoCommand: 'pnpm capture:demo:live'/u) + assert.doesNotMatch(visualSource, /capture-product-demo|productDemo/u) + assert.match(liveDemoSource, /BRAID_LIVE_DEMO_ENDPOINT/u) + assert.match(liveDemoSource, /assertPublicCapture/u) +}) + test('active streaming capture waits for a terminal run state before exiting', async () => { let screen = 'working' let cancelled = false @@ -189,6 +214,8 @@ test('every scoped package alias forwards its declared file set', () => { 'domain-reducer.test.js', 'domain-text.test.js', 'eval.test.js', + 'native-interactive-actions.test.js', + 'native-interactive-run-broker.test.js', 'observability.test.js', 'plain-accessibility.test.js', 'property.test.js', @@ -204,6 +231,7 @@ test('every scoped package alias forwards its declared file set', () => { 'analysis-model-call-observability.test.js', 'analysis-model-call-roundtrip.test.js', 'application.test.js', + 'cli-bridge-interactions.test.js', 'cli-bridge-profile-contract.test.js', 'cli-bridge-retained-restart.test.js', 'conversations.test.js', @@ -213,15 +241,19 @@ test('every scoped package alias forwards its declared file set', () => { 'observability.test.js', 'reducer.test.js', 'scripts.test.js', + 'tangle-retained-lifecycle.test.js', 'usage-projection.test.js', 'w6-contract.test.js', ], 'test:coordination': [ 'analysis-durable.test.js', + 'cli-bridge-interactions.test.js', 'cli-bridge-retained-restart.test.js', 'coordination.test.js', 'effect-admission.test.js', 'run-admission-architecture.test.js', + 'run-interactions.test.js', + 'tangle-retained-lifecycle.test.js', ], 'test:rpc': [ 'automation-interaction-commands.test.js', @@ -234,6 +266,7 @@ test('every scoped package alias forwards its declared file set', () => { 'configuration-product-flow.test.js', 'intelligence-dispatch.test.js', 'keyboard.test.js', + 'native-interactive-command.test.js', 'terminal-responsive.test.js', 'terminal-usage-status.test.js', 'tui-autocomplete.test.js', diff --git a/test/support/retained-run-capabilities.ts b/test/support/retained-run-capabilities.ts new file mode 100644 index 0000000..7f4508b --- /dev/null +++ b/test/support/retained-run-capabilities.ts @@ -0,0 +1,32 @@ +import type { AgentEnvironmentCapabilities } from '@tangle-network/agent-interface' + +export const RETAINED_RUN_HANDLE_CAPABILITIES: AgentEnvironmentCapabilities = { + profile: { + namedProfiles: false, + systemPrompt: { replace: false, append: false }, + instructions: false, + tools: false, + permissions: false, + mcp: false, + subagents: false, + resources: { + files: false, + instructions: false, + tools: false, + skills: false, + agents: false, + commands: false, + }, + hooks: false, + modes: false, + runtimeUpdate: false, + validation: false, + }, + streaming: { live: true, replay: false, detach: false, turnIdempotency: true }, + sessions: { continue: false, list: false, messages: false }, + workspace: { read: false, write: false, exec: false, git: false, upload: false, download: false }, + branching: { checkpoint: false, fork: false }, + placement: false, + usage: false, + confidential: true, +} diff --git a/test/support/run-capabilities.ts b/test/support/run-capabilities.ts new file mode 100644 index 0000000..2ce93f1 --- /dev/null +++ b/test/support/run-capabilities.ts @@ -0,0 +1,10 @@ +import { defaultTangleSandboxCapabilities } from '@tangle-network/agent-provider-tangle' +import { DEFAULT_RUN_CAPABILITIES, type RunCapabilities } from '../../src/ports/execution.js' + +/** A real shared capability document for fake interaction-response adapters. */ +export function interactionResponseRunCapabilities(): RunCapabilities { + return { + ...DEFAULT_RUN_CAPABILITIES, + environment: defaultTangleSandboxCapabilities('opencode'), + } +} diff --git a/test/support/tangle-retained-sandbox.ts b/test/support/tangle-retained-sandbox.ts index ce99ce9..cee2187 100644 --- a/test/support/tangle-retained-sandbox.ts +++ b/test/support/tangle-retained-sandbox.ts @@ -1,8 +1,10 @@ import type { - AgentRunCancellationAcknowledgement, - AgentRunCancellationRequest, AgentExactRunControlRef, AgentProfile, + AgentRunCancellationAcknowledgement, + AgentRunCancellationRequest, + InteractionResponseCommand, + RequestedInteractions, } from '@tangle-network/agent-interface' import { createTangleProvider, @@ -11,8 +13,8 @@ import { type SandboxInstanceLike, } from '@tangle-network/agent-provider-tangle' import type { CreateSandboxOptions, SandboxEvent } from '@tangle-network/sandbox' -import type { PreparedTangleRetainedConnection } from '../../src/adapters/runtime/production-tangle-sandbox-backend.js' import { safeExecutionId } from '../../src/adapters/runtime/production-backend-common.js' +import type { PreparedTangleRetainedConnection } from '../../src/adapters/runtime/production-tangle-sandbox-backend.js' import { observeSandboxClient } from '../../src/adapters/runtime/sandbox-observation.js' import { retainedSandboxIdentity, @@ -39,7 +41,7 @@ export interface FakeRetainedBox { deleted: boolean } -/** Stateful double for the exact Tangle SDK surface used by provider 0.10.0. */ +/** Stateful double for the exact Tangle SDK surface used by provider 0.11.0. */ export class FakeTangleRetainedSandbox { readonly createCalls: CreateSandboxOptions[] = [] readonly dispatches: Array<{ @@ -47,6 +49,7 @@ export class FakeTangleRetainedSandbox { readonly sessionId: string readonly executionId: string readonly prompt: string + readonly interactions?: RequestedInteractions }> = [] readonly cancellations: AgentRunCancellationRequest[] = [] failDispatch = false @@ -126,6 +129,7 @@ export class FakeTangleRetainedSandbox { dispatch: { runControlRef: true, executionIdOnAdmission: true }, cancel: { canonicalRunCancellation: true, digestBound: true, idempotent: true }, runs: { executionScopedStatus: true, eventReplay: true }, + interactions: { responseDedupe: true }, } }, async refresh() {}, @@ -177,6 +181,9 @@ export class FakeTangleRetainedSandbox { sessionId, executionId, prompt: typeof message === 'string' ? message : JSON.stringify(message), + ...(options?.backend?.interactions === undefined + ? {} + : { interactions: structuredClone(options.backend.interactions) }), }) return { sessionId, @@ -252,6 +259,16 @@ export class FakeTangleRetainedSandbox { async prompt() { throw new Error('Fake Tangle prompt is not used by retained dispatch') }, + async respondToInteraction(command: InteractionResponseCommand) { + return { + acknowledgement: { + operationId: command.operationId, + binding: command.binding, + commandDigest: command.commandDigest, + status: 'accepted', + }, + } + }, async interrupt(options) { const executionId = options?.executionId if (typeof executionId !== 'string') return { cancelled: false } diff --git a/test/tangle-live07-wiring.test.mjs b/test/tangle-live07-wiring.test.mjs index c06b64e..75fc716 100644 --- a/test/tangle-live07-wiring.test.mjs +++ b/test/tangle-live07-wiring.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { resolve } from 'node:path' import test from 'node:test' +import { PROOF_OPERATIONS, proofReceipt } from '../scripts/live-required/contracts.mjs' import { runSandbox, runTangleFlows } from '../scripts/live-required/tangle.mjs' import { cleanupRetainedResourceByRunId } from '../scripts/live-required/tangle-sandbox-braid-stress.mjs' @@ -67,8 +68,80 @@ function passedStressCohort() { } } -test('built-in LIVE-07 wiring emits evidence and one measurement with deduped run IDs', async () => { +function passedInteractiveProof( + invocationId, + { + runner = 'pi', + observations = { + checks: {}, + configuration: {}, + run: {}, + sandbox: {}, + identityContinuity: {}, + processCleanup: {}, + }, + } = {}, +) { + const cloudControl = { + provider: 'tangle-sandbox', + environmentId: 'environment-cloud-interactive', + sessionId: 'session-cloud-interactive', + executionId: 'execution-cloud-interactive', + runId: 'run-cloud-interactive', + requestDigest: `sha256:${'b'.repeat(64)}`, + } + return { + status: 'passed', + measurement: { kind: 'scalar', name: 'LIVE-08', unit: 'verified-flow', value: 1 }, + evidence: proofReceipt({ + invocationId, + operation: PROOF_OPERATIONS.tangleSandboxInteractive, + startedAt: '2026-08-19T00:00:00.000Z', + completedAt: '2026-08-19T00:01:00.000Z', + config: { + endpoint: 'https://sandbox.tangle.tools', + connectionId: 'connection-live-08', + connectionKind: 'tangle-sandbox', + credentialConfigured: true, + model: 'tangle-router/glm-5.2', + runner, + }, + runIds: ['run-interactive'], + environmentId: 'environment-cloud-interactive', + facts: { + environmentId: 'environment-cloud-interactive', + localRunId: 'run-interactive', + stoppedStatus: 'aborted', + cloudControl, + exactResource: true, + processExitedBeforeWorkspaceCleanup: true, + terminalResize: true, + processGroupExitedBeforeWorkspaceCleanup: true, + }, + checks: [ + 'packed-binary', + 'interactive-command', + 'input', + 'detach', + 'reconnect', + 'terminal-resize', + 'same-local-run', + 'same-provider-control-ref', + 'sandbox-observed-before-stop', + 'stop-through-braid', + 'sandbox-observed-stopped', + 'exact-resource-cleanup', + 'process-exited-before-cleanup', + 'process-group-exited-before-cleanup', + ], + observations, + }), + } +} + +test('built-in LIVE-07 and LIVE-08 wiring emits evidence and deduped dispatch', async () => { const stressRunner = async () => passedStressCohort() + const dispatches = [] const sandbox = await runSandbox({ repository, environment: {}, @@ -91,17 +164,25 @@ test('built-in LIVE-07 wiring emits evidence and one measurement with deduped ru evidence: null, }), sandboxRunner: (input) => runSandbox({ ...input, stressRunner }), - matrixRunner: async () => ({ status: 'unavailable', reason: 'not in LIVE-07 scope' }), + interactiveRunner: async (input) => { + dispatches.push(input) + return passedInteractiveProof(input.invocationId) + }, + matrixRunner: async () => ({ status: 'unavailable', reason: 'not in LIVE-09 scope' }), }) assert.deepEqual( flows.measurements.map((measurement) => measurement.name), - ['LIVE-06', 'LIVE-07'], + ['LIVE-06', 'LIVE-07', 'LIVE-08'], ) assert.equal(flows.flows.find((flow) => flow.row === 'LIVE-07')?.evidence?.status, 'passed') + assert.equal(flows.flows.find((flow) => flow.row === 'LIVE-08')?.evidence?.status, 'passed') + assert.equal(dispatches.length, 1) + assert.equal(dispatches[0]?.repository, repository) + assert.equal(typeof dispatches[0]?.invocationId, 'string') assert.deepEqual( flows.unavailable.map((entry) => entry.row), - ['LIVE-08', 'LIVE-09', 'LIVE-10'], + ['LIVE-09', 'LIVE-10'], ) }) @@ -126,6 +207,23 @@ test('LIVE-07 rejects a passing canary presented as a stress cohort', async () = ) }) +test('LIVE-08 rejects a non-Pi runner from the native interactive proof', () => { + assert.throws( + () => passedInteractiveProof('live-required-non-pi-runner', { runner: 'codex' }), + /native Pi harness/u, + ) +}) + +test('LIVE-08 rejects status-only observations from a passed receipt', () => { + assert.throws( + () => + passedInteractiveProof('live-required-status-only-observations', { + observations: { status: 'passed' }, + }), + /observations\.checks/u, + ) +}) + test('fail-safe cleanup derives one exact Braid resource from the first local run ID', async () => { const firstRunId = 'local/run-1' const box = { diff --git a/test/tangle-retained-lifecycle.test.ts b/test/tangle-retained-lifecycle.test.ts index 48784d2..6edb7b8 100644 --- a/test/tangle-retained-lifecycle.test.ts +++ b/test/tangle-retained-lifecycle.test.ts @@ -1,18 +1,24 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { defineAgentProfile } from '@tangle-network/agent-interface' +import { type AgentExactRunControlRef, defineAgentProfile } from '@tangle-network/agent-interface' +import type { SandboxClientLike, SandboxInstanceLike } from '@tangle-network/agent-provider-tangle' +import { capabilitiesForConnection } from '../src/adapters/connections/production-connection-providers.js' +import { createTangleRetainedControlLookup } from '../src/adapters/connections/tangle-retained-control-lookup.js' +import { safeExecutionId } from '../src/adapters/runtime/production-backend-common.js' import { resolveTangleSandboxBackend, resolveTangleSandboxRetainedConnection, } from '../src/adapters/runtime/production-tangle-sandbox-backend.js' -import { capabilitiesForConnection } from '../src/adapters/connections/production-connection-providers.js' +import type { ObservableSandboxClient } from '../src/adapters/runtime/sandbox-observation-types.js' import { createTangleRetainedPlan, startTangleRetainedRun, } from '../src/adapters/runtime/tangle-retained-run.js' -import { withRetainedSandboxPolicy } from '../src/adapters/runtime/tangle-sandbox-retention.js' +import { + retainedSandboxIdentity, + withRetainedSandboxPolicy, +} from '../src/adapters/runtime/tangle-sandbox-retention.js' import { ConnectionRegistry } from '../src/app/connections.js' -import type { ObservableSandboxClient } from '../src/adapters/runtime/sandbox-observation-types.js' import type { ConnectionRecord, ConnectionTransportOptions } from '../src/domain/entities.js' import { createConnectionId } from '../src/domain/ids.js' import { assertConnectionRecord } from '../src/domain/invariants-profile.js' @@ -230,6 +236,62 @@ test('published Sandbox methods recover the exact retained dispatch without an i assert.deepEqual(await prepared.discoverControlRef(input.runId), handle.controlRef) }) +test('retained control lookup uses the hashed identity for long provider sessions', async () => { + const providerSessionId = `session-${'x'.repeat(220)}` + const executionId = safeExecutionId('run/long-provider-session') + const identity = retainedSandboxIdentity(providerSessionId) + const controlRef: AgentExactRunControlRef = { + runId: 'provider-run-long-session', + provider: 'tangle-sandbox', + environmentId: 'sandbox-long-provider-session', + sessionId: providerSessionId, + executionId, + requestDigest: `sha256:${'a'.repeat(64)}`, + } + const box = { + id: controlRef.environmentId, + name: identity.name, + metadata: { + ...identity.metadata, + retainedIdempotencyKey: identity.environmentIdempotencyKey, + sessionId: providerSessionId, + executionId, + }, + async *streamPrompt() {}, + session(sessionId: string) { + assert.equal(sessionId, providerSessionId) + return { + async status() { + return { runControlRef: controlRef } + }, + } as never + }, + } as unknown as SandboxInstanceLike + const client = { + async create() { + return box + }, + async list() { + return [box] + }, + async get(id: string) { + assert.equal(id, box.id) + return box + }, + } as SandboxClientLike + + const lookup = createTangleRetainedControlLookup(client) + const recovered = await lookup({ + connectionId: createConnectionId('connection-tangle-long-session'), + braidRunId: 'run/long-provider-session', + providerSessionId, + executionId, + environmentIdempotencyKey: identity.environmentIdempotencyKey, + }) + + assert.deepEqual(recovered, controlRef) +}) + test('retained resolution admits exact control when provider lookup is configured', async () => { const sandbox = new FakeTangleRetainedSandbox() const { connection, options, input, selection } = setup(sandbox) @@ -262,19 +324,27 @@ test('one retained plan uses exact tags, bounded idle expiry, replay, and result assert.equal(sandbox.createCalls[0]?.name, prepared.environmentName) assert.equal(sandbox.createCalls[0]?.idleTimeoutSeconds, 1_800) assert.equal(sandbox.createCalls[0]?.ephemeral, false) - assert.deepEqual(sandbox.createCalls[0]?.metadata, { + const metadata = sandbox.createCalls[0]?.metadata + assert.ok(metadata) + const { retainedIntentDigest, retainedRunId, ...stableMetadata } = metadata + assert.deepEqual(stableMetadata, { ...prepared.environmentMetadata, retainedIdempotencyKey: prepared.environmentIdempotencyKey, sessionId: prepared.providerSessionId, - executionId: 'run-tangle-retained', + executionId: safeExecutionId('run/tangle-retained'), }) + assert.match(String(retainedIntentDigest), /^sha256:[0-9a-f]{64}$/u) + assert.equal( + retainedRunId, + `retained-intent-run:${String(retainedIntentDigest).slice('sha256:'.length)}`, + ) assert.equal(handle.controlRef.environmentId, sandbox.boxes[0]?.id) assert.equal(handle.controlRef.sessionId, prepared.providerSessionId) - assert.equal(handle.controlRef.executionId, 'run-tangle-retained') + assert.equal(handle.controlRef.executionId, safeExecutionId('run/tangle-retained')) assert.match(handle.controlRef.requestDigest, /^sha256:[0-9a-f]{64}$/u) assert.deepEqual( admissions.map((admission) => admission.phase), - ['environment', 'dispatched'], + ['intent', 'environment', 'dispatched'], ) sandbox.complete(handle.controlRef.executionId, 'RETAINED_OK') @@ -304,6 +374,24 @@ test('one retained plan uses exact tags, bounded idle expiry, replay, and result ) }) +test('retained Tangle dispatch receives the exact requested interaction map', async () => { + const sandbox = new FakeTangleRetainedSandbox() + const { input } = setup(sandbox) + const prepared = await prepareFakeTangleRetainedConnection({ + sandbox, + profile, + runId: input.runId, + }) + const interactions = { permission: true, question: false, plan: true } as const + + await startTangleRetainedRun(createTangleRetainedPlan(prepared, input.runId), { + ...input, + interactions, + }) + + assert.deepEqual(sandbox.dispatches[0]?.interactions, interactions) +}) + test('ambiguous dispatch failure never deletes the retained environment', async () => { const sandbox = new FakeTangleRetainedSandbox() sandbox.failDispatch = true diff --git a/test/tangle-retained-restart.test.ts b/test/tangle-retained-restart.test.ts index be4e3c7..1c60394 100644 --- a/test/tangle-retained-restart.test.ts +++ b/test/tangle-retained-restart.test.ts @@ -270,7 +270,7 @@ test('provider lookup recovers the pre-journal crash window without a saved refe assert.equal(controlRef?.runId, providerRunId) assert.deepEqual( admissions.map((admission) => admission.phase), - ['environment', 'dispatched'], + ['intent', 'environment', 'dispatched'], ) await stream.return?.(undefined) diff --git a/test/terminal-responsive.test.ts b/test/terminal-responsive.test.ts index d8c99f8..c3dacfe 100644 --- a/test/terminal-responsive.test.ts +++ b/test/terminal-responsive.test.ts @@ -10,10 +10,15 @@ import { } from '@earendil-works/pi-tui' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createBraidApplication } from '../src/app/composition.js' -import type { BraidViewModel } from '../src/views/shared/models.js' -import { composerProjectionFor, composerRowBudget } from '../src/views/tui/composer-view.js' +import type { BraidViewModel, EnvironmentView } from '../src/views/shared/models.js' +import { + composerBorderLine, + composerProjectionFor, + composerRowBudget, +} from '../src/views/tui/composer-view.js' import { layoutFor } from '../src/views/tui/layout.js' import { ModalCoordinator } from '../src/views/tui/modal-coordinator.js' +import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' import { TerminalChrome } from '../src/views/tui/terminal-chrome.js' import { BraidShell } from '../src/views/tui/terminal-shell.js' import { createBraidTheme } from '../src/views/tui/theme.js' @@ -36,9 +41,11 @@ function viewForChrome(): BraidViewModel { workspace: '/home/drew/code/.worktrees/braid-integration', profileName: 'Release engineer', runner: 'pi', - model: 'tangle-router/glm-5.2', + model: 'openai-codex/gpt-5.6-luna', effort: 'high', - maxOutputTokens: 8192, + maxVisibleOutputTokens: 8192, + maxReasoningTokens: 4096, + maxTotalOutputTokens: 12_288, connection: 'Local CLI Bridge', conversationTitle: 'New conversation', branch: 'branch-1', @@ -63,6 +70,8 @@ function viewForChrome(): BraidViewModel { tokenStatus: 'complete', costUsd: 0.0312, costStatus: 'reported', + llmLatencyMs: 842, + latencyStatus: 'complete', unknownTokenSources: 0, unknownCostSources: 0, }, @@ -81,13 +90,13 @@ test('chrome uses complete responsive groups at every reference width', () => { view, quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const narrow = plainLines(chrome, 40) assert.equal(narrow.length, 1) - assert.match(narrow[0] ?? '', /Release engineer.*Ctrl\+P commands/u) + assert.equal(narrow[0], 'Release engineer') assert.doesNotMatch(narrow.join('\n'), /\/home\/drew|\.worktrees|…/u) const standardTop = chrome.renderTop(80).join('\n') @@ -95,27 +104,172 @@ test('chrome uses complete responsive groups at every reference width', () => { const standard = [standardTop, standardBottom].join('\n') assert.equal(standardTop, '') assert.match(standardBottom, /profile Release engineer/u) - assert.match(standardBottom, /pi \/ glm-5\.2/u) - assert.match(standard, /Local CLI Bridge/u) - assert.match(standardBottom, /Ctrl\+P commands/u) + assert.match(standardBottom, /pi \/ gpt-5\.6-luna/u) + assert.match(standard, /via Local CLI Bridge/u) + assert.doesNotMatch(standardBottom, /\/ commands|Ctrl\+P/u) assert.doesNotMatch(standard, /braid|cwd|New conversation|branch-1|in 1\.2k|out 567|\$0\.0312/u) assert.doesNotMatch(standard, /…/u) - const wide = plainLines(chrome, 120).join('\n') - assert.match(wide, /profile Release engineer/u) - assert.match(wide, /pi \/ tangle-router\/glm-5\.2/u) - assert.match(wide, /Local CLI Bridge/u) - assert.match(wide, /high/u) + const wideLines = plainLines(chrome, 120) + const wide = wideLines.join('\n') + assert.match(wide, /Release engineer/u) + assert.match( + wide, + /profile Release engineer · harness pi · model openai-codex\/gpt-5\.6-luna · backend Local CLI Bridge/u, + ) + assert.match(wide, / · think high/u) + assert.doesNotMatch(wide, /caps/u) assert.match(wide, /in 1\.2k/u) assert.match(wide, /out 567/u) assert.match(wide, /\$0\.0312/u) + assert.match(wide, /latency 842ms/u) assert.doesNotMatch( wide, /braid|cwd|New conversation|branch-1|output ≤|\/home\/drew|\.worktrees|…/u, ) + assert.equal(wideLines.length, 2) + + const spaciousLines = plainLines(chrome, 200) + assert.equal(spaciousLines.length, 2) + assert.match( + spaciousLines[0] ?? '', + /^profile Release engineer · harness pi · model openai-codex\/gpt-5\.6-luna · backend Local CLI Bridge · think high · caps vis 8\.2k · reas 4\.1k · total 12k$/u, + ) + assert.match(spaciousLines[1] ?? '', /^in 1\.2k · out 567 · \$0\.0312 · latency 842ms$/u) + assert.doesNotMatch(spaciousLines.join('\n'), /(?:^|\n)rofile\b/u) for (const width of [1, 2, 10, 40, 80, 120, 200]) { - for (const line of plainLines(chrome, width)) assert.ok(visibleWidth(line) <= width) + for (const line of plainLines(chrome, width)) { + assert.ok(visibleWidth(line) <= width) + assert.doesNotMatch(line, /[\r\n]/u) + } + } +}) + +test('composer hint chooses a complete slash affordance before narrow truncation', () => { + const label = 'type / for commands · Alt+Enter newline · paste' + const narrow = composerBorderLine(40, label, theme) + assert.match(narrow, /type \/ for commands · Alt\+Enter/u) + assert.doesNotMatch(narrow, /newline|…/u) + + const standard = composerBorderLine(80, label, theme) + assert.match(standard, /type \/ for commands · Alt\+Enter · paste/u) + assert.doesNotMatch(standard, /…/u) + + const wide = composerBorderLine(120, label, theme) + assert.match(wide, /type \/ for commands · Alt\+Enter newline · paste/u) + assert.ok(visibleWidth(narrow) <= 40) + assert.ok(visibleWidth(standard) <= 80) + assert.ok(visibleWidth(wide) <= 120) +}) + +test('command palette stays isolated and keeps close controls at narrow and wide sizes', async () => { + for (const [columns, rows] of [ + [40, 12], + [80, 24], + [120, 40], + ] as const) { + const terminal = new VirtualTerminal(columns, rows) + const tui = new TuiMainScreen(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme, + workspace: '/workspace', + nextOperationId: () => `palette-${columns}`, + }) + const done = view.start() + try { + await terminal.waitForRender() + terminal.sendInput('\u0010') + await terminal.waitForRender() + const screen = terminal.getViewport().join('\n') + assert.match(screen, /Commands/u) + assert.match(screen, /←\/esc close/u) + assert.doesNotMatch(screen, /profile Braid starter/u) + if (columns >= 80) assert.match(screen, /Inspect and manage connections/u) + for (const line of terminal.getViewport()) assert.ok(visibleWidth(line) <= columns) + } finally { + view.stop() + await done + } + } +}) + +test('wide chrome keeps measured sandbox facts as independent context items', () => { + const base = viewForChrome() + const environment = { + id: 'environment-sandbox-facts', + connectionId: 'connection-sandbox', + kind: 'sandbox', + provider: 'tangle-sandbox', + lifecycle: 'active', + lifecycleMode: 'retained', + location: 'remote', + runtimeEndpointHost: 'sandbox.example.test', + machineId: 'machine-a10', + requestedRegion: 'us-central', + verifiedRegion: 'us-central', + storagePersistence: 'persistent-home', + requestedResources: { cpuCores: 4, memoryMB: 8192, diskGB: 80 }, + resourceSample: { + cgroupVersion: 2, + memoryCurrentMb: 512, + memoryPeakMb: 768, + cpuUsageUsec: 2_500, + sampledAt: '2026-08-15T00:00:01.000Z', + }, + gpu: { + provider: 'tangle-sandbox', + accelerator: 'A10', + count: 1, + status: 'ready', + billedCustomerCostUsd: 0.1234, + }, + unavailableTelemetry: [], + createdAt: '2026-08-15T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:01.000Z', + } satisfies EnvironmentView + const run = base.runs[0] + assert.ok(run) + const view: BraidViewModel = { + ...base, + activeRunId: run.id, + environments: [environment], + runs: [{ ...run, environmentId: environment.id, provider: 'tangle-sandbox' }], + } + const chrome = new TerminalChrome(theme) + chrome.setState({ + view, + quitArmed: false, + activityVisible: false, + navigationHint: '/ commands · Ctrl+P', + composerMode: 'queue', + }) + + const wideReference = chrome.render(120).join('\n') + assert.match(wideReference, /host sandbox\.example\.test/u) + assert.match(wideReference, /machine machine-a10/u) + assert.doesNotMatch(wideReference, /unknown|not reported/u) + + const mediumWide = chrome.render(160).join('\n') + assert.match(mediumWide, /mem 512MB/u) + assert.doesNotMatch(mediumWide, /unknown|not reported/u) + + const wide = chrome.render(200).join('\n') + assert.match(wide, /host sandbox\.example\.test/u) + assert.match(wide, /machine machine-a10/u) + assert.match(wide, /region us-central/u) + assert.match(wide, /mem 512MB/u) + assert.match(wide, /size 4cpu · 8GB · 80GB/u) + assert.match(wide, /gpu 1× A10 \$0\.1234/u) + assert.match(wide, /in 1\.2k|out 567|\$0\.0312/u) + for (const width of [40, 80, 120, 200]) { + const lines = chrome.render(width) + assert.doesNotMatch(lines.join('\n'), /unknown|not reported/u) + for (const line of lines) assert.ok(visibleWidth(line) <= width) } }) @@ -139,12 +293,13 @@ test('chrome exposes active-run controls and failure recovery without hiding out }, quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const active = chrome.render(80).join('\n') + assert.match(active, /profile Release engineer · pi \/ gpt-5\.6-luna/u) + assert.match(active, /via not connected/u) assert.match(active, /Ctrl\+C cancel/u) - assert.match(active, /Enter queues · Alt\+S steer/u) chrome.setState({ view: { @@ -162,12 +317,13 @@ test('chrome exposes active-run controls and failure recovery without hiding out }, quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const failed = chrome.render(80).join('\n') + assert.match(failed, /profile Release engineer · pi \/ gpt-5\.6-luna/u) + assert.match(failed, /via Local CLI Bridge/u) assert.match(failed, /failed/u) - assert.match(failed, /\/activity details · \/new/u) assert.doesNotMatch(failed, /operation-that-is-long-enough-to-shorten/u) chrome.setState({ @@ -179,15 +335,46 @@ test('chrome exposes active-run controls and failure recovery without hiding out }, quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const uncertain = chrome.render(80).join('\n') - assert.match(uncertain, /outcome unknown/u) - assert.match(uncertain, /\/activity inspect/u) + assert.match(uncertain, /profile Release engineer · pi \/ gpt-5\.6-luna/u) + assert.match(uncertain, /outcome unverified/u) assert.doesNotMatch(uncertain, /failed|\/new/u) }) +test('completed notices preserve the persistent route and measured context', () => { + const base = viewForChrome() + const chrome = new TerminalChrome(theme) + chrome.setState({ + view: { + ...base, + notice: 'Prepared markdown export', + statusText: 'Prepared markdown export', + }, + quitArmed: false, + activityVisible: false, + navigationHint: '/ commands · Ctrl+P', + composerMode: 'queue', + }) + + const standard = chrome.render(80).join('\n') + assert.match(standard, /profile Release engineer · pi \/ gpt-5\.6-luna/u) + assert.match(standard, /via Local CLI Bridge/u) + assert.match(standard, /Prepared markdown export/u) + assert.doesNotMatch(standard, /\/ commands|Ctrl\+P/u) + + const wide = chrome.render(120).join('\n') + assert.match( + wide, + /profile Release engineer · harness pi · model openai-codex\/gpt-5\.6-luna · backend Local CLI Bridge/u, + ) + assert.match(wide, /Prepared markdown export/u) + assert.match(wide, /in 1\.2k/u) + assert.match(wide, /out 567/u) +}) + test('layout breakpoints preserve transcript room and short-terminal overlays', () => { assert.equal(layoutFor(40, 12).mode, 'narrow') assert.equal(layoutFor(40, 12).overlayFullScreen, true) @@ -228,6 +415,32 @@ test('modal coordination honors the row breakpoint at standard and wide widths', assert.deepEqual(shown, [{ anchor: 'top-left', width: '100%', maxHeight: '100%', margin: 0 }]) }) +test('full-screen modal back closes an input surface with the left action', () => { + let hidden = false + const handle = { + hide() { + hidden = true + }, + focus() {}, + isHidden: () => hidden, + } as unknown as OverlayHandle + const tui = { + terminal: { columns: 40, rows: 12 }, + showOverlay: () => handle, + } as unknown as TUI + const component = { + handleInput() {}, + render: () => [], + invalidate() {}, + } as unknown as Component + const modals = new ModalCoordinator(tui) + modals.open(component) + + assert.equal(modals.backOrCloseIfFullScreen(), true) + assert.equal(hidden, true) + assert.equal(modals.hasOpen(), false) +}) + test('published Pi virtual terminals keep the composer and valid cells at all sizes', async () => { const view = viewForChrome() for (const [columns, rows] of sizes) { @@ -249,11 +462,24 @@ test('published Pi virtual terminals keep the composer and valid cells at all si assert.equal(viewport.length, rows, `${columns}x${rows} row count`) for (const line of viewport) assert.ok(visibleWidth(line) <= columns, `${columns}x${rows} line exceeds width`) - assert.doesNotMatch(viewport.join('\n'), /braid|completed|─/u) + assert.doesNotMatch(viewport.join('\n'), /braid|completed/u) const prompt = viewport.findIndex((line) => line.includes('›')) assert.ok(prompt >= 0, `${columns}x${rows} composer prompt`) - assert.equal(rows - prompt, 4, `${columns}x${rows} composer and context rows`) - assert.match(viewport.at(-1) ?? '', /Release engineer/u) + const contextRows = columns >= 100 ? 6 : 5 + assert.equal(rows - prompt, contextRows, `${columns}x${rows} composer and context rows`) + assert.match(viewport[prompt - 1] ?? '', /─{8,}/u) + assert.match(viewport[prompt] ?? '', /› new message/u) + assert.match(viewport[prompt + 3] ?? '', /type \/ for commands/u) + const context = viewport.slice(prompt + 4) + assert.ok(context.some((line) => line.includes('Release engineer'))) + if (columns >= 100) assert.match(context.at(-1) ?? '', /in 1\.2k|out 567|\$0\.0312/u) + if (columns === 200) { + const identityRow = context.find((line) => line.includes('Release engineer')) + const usageRow = context.find((line) => line.includes('latency 842ms')) + assert.match(identityRow ?? '', /^profile Release engineer/u) + assert.match(usageRow ?? '', /^in 1\.2k · out 567 · \$0\.0312 · latency 842ms$/u) + assert.doesNotMatch(context.join('\n'), /(?:^|\n)rofile\b/u) + } assert.doesNotMatch(viewport.join('\n'), /\/home\/drew|\.worktrees/u) tui.stop() @@ -293,6 +519,7 @@ test('composer derives truthful actions and keeps three usable rows at 40x12', a assert.equal(projection.queuePosition, 2) assert.match(projection.actionLabel, /queue next #2/u) assert.match(projection.actionLabel, /Alt\+S steer/u) + assert.match(projection.hint, /type \/ for commands/u) const steering = composerProjectionFor(active, 'steer') assert.equal(steering.action, 'steer') @@ -327,7 +554,9 @@ test('composer derives truthful actions and keeps three usable rows at 40x12', a const viewport = terminal.getViewport() const prompt = viewport.findIndex((line) => line.includes('›')) assert.ok(prompt >= 0) - assert.equal(viewport.length - prompt, 4, 'composer must retain three usable rows and context') + assert.equal(viewport.length - prompt, 5, 'composer must retain three usable rows and context') + assert.match(viewport[prompt - 1] ?? '', /─{8,}/u) + assert.match(viewport[prompt + 3] ?? '', /type \/ for commands/u) assert.match(viewport.join('\n'), /working · Ctrl\+C stop/u) assert.match(viewport[prompt] ?? '', /› queue #2/u) assert.equal(viewport.length, 12) @@ -362,8 +591,10 @@ test('composer bounds long drafts without changing Pi editor input behavior', () const lines = shell.render(40) const prompt = lines.findIndex((line) => line.includes('›')) assert.ok(prompt >= 0) - assert.equal(lines.length - prompt, 4) - assert.doesNotMatch(lines.join('\n'), /─|alt\+enter newline/u) + assert.equal(lines.length - prompt, 5) + assert.match(lines[prompt - 1] ?? '', /─{8,}/u) + assert.match(lines[prompt + 3] ?? '', /type \/ for commands/u) + assert.doesNotMatch(lines.join('\n'), /alt\+enter newline/u) assert.equal(lines.length, 12) for (const line of lines) assert.ok(visibleWidth(line) <= 40) }) diff --git a/test/terminal-usage-status.test.ts b/test/terminal-usage-status.test.ts index 1b04080..da5b9e0 100644 --- a/test/terminal-usage-status.test.ts +++ b/test/terminal-usage-status.test.ts @@ -7,7 +7,7 @@ import type { BraidViewModel } from '../src/views/shared/models.js' import { activityDocument } from '../src/views/tui/activity-browser.js' import { executionTargetFor } from '../src/views/tui/execution-target.js' import { TerminalChrome } from '../src/views/tui/terminal-chrome.js' -import { metricsFor } from '../src/views/tui/terminal-usage.js' +import { footerMetricsFor, metricsFor } from '../src/views/tui/terminal-usage.js' import { createBraidTheme } from '../src/views/tui/theme.js' const theme = createBraidTheme({ colors: false, highContrast: true, reducedMotion: true }) @@ -21,14 +21,12 @@ test('terminal usage keeps direct, analysis, and worker measurements separate an 'out ≥20', '≥$0.0100', 'calls ≥2', - 'model ≥120ms', - 'workers in 0 · out 0 · $0.0000 · calls 0 · model 0ms', + 'latency ≥120ms', + 'workers in 0 · out 0 · $0.0000 · calls 0 · latency 0ms', ]) - const context = activityDocument(view).context ?? '' - assert.match(context, /in ≥10.*calls ≥2.*model ≥120ms/u) - assert.doesNotMatch(context, /analysis|unknown|missing/u) - assert.match(context, /workers .*calls 0.*model 0ms/u) + assert.equal(activityDocument(view).context, undefined) + assert.deepEqual(footerMetricsFor(view), ['in ≥10', 'out ≥20', '≥$0.0100', 'latency ≥120ms']) }) test('terminal chrome keeps telemetry out of narrow layouts', () => { @@ -37,7 +35,7 @@ test('terminal chrome keeps telemetry out of narrow layouts', () => { view: usageView(), quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) @@ -65,6 +63,7 @@ test('terminal usage presents a complete estimate instead of an observed-cost fl assert.match(metricsFor(estimated).join(' · '), /~\$0\.1500/u) assert.doesNotMatch(metricsFor(estimated).join(' · '), /≥\$0\.1000/u) + assert.doesNotMatch(footerMetricsFor(estimated).join(' · '), /\$/u) const freeEstimate = { ...estimated, @@ -89,7 +88,9 @@ test('execution identity comes from one active run receipt instead of current pr runner: 'codex', model: 'openai/gpt-next', effort: 'medium', - maxOutputTokens: 4096, + maxVisibleOutputTokens: 4096, + maxReasoningTokens: 2048, + maxTotalOutputTokens: 6144, connection: 'Next connection', status: 'running', statusText: 'streaming', @@ -103,7 +104,9 @@ test('execution identity comes from one active run receipt instead of current pr runner: 'pi', model: 'tangle-router/glm-5.2', effort: 'high', - maxOutputTokens: 16_384, + maxVisibleOutputTokens: 16_384, + maxReasoningTokens: 8_192, + maxTotalOutputTokens: 24_576, connection: 'Local CLI Bridge', connectionId: 'connection-run', environmentId: 'environment-run', @@ -142,8 +145,11 @@ test('execution identity comes from one active run receipt instead of current pr profileDigest: 'digest-run', runner: 'pi', model: 'tangle-router/glm-5.2', + backend: 'cli-bridge', effort: 'high', - maxOutputTokens: 16_384, + maxVisibleOutputTokens: 16_384, + maxReasoningTokens: 8_192, + maxTotalOutputTokens: 24_576, connection: 'Local CLI Bridge', connectionId: 'connection-run', environment: view.environments[0], @@ -154,21 +160,25 @@ test('execution identity comes from one active run receipt instead of current pr view, quitArmed: false, activityVisible: true, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const rendered = chrome.render(120).join('\n') - assert.match(rendered, /profile Exact run profile/u) - assert.match(rendered, /pi \/ tangle-router\/glm-5\.2/u) - assert.match(rendered, /Local CLI Bridge/u) + assert.match(rendered, /Exact run profile/u) + assert.match(rendered, /harness pi · model tangle-router\/glm-5\.2/u) + assert.match(rendered, /CLI Bridge/u) + assert.doesNotMatch(rendered, /backend cli-bridge|pi · tangle-router/u) assert.doesNotMatch(rendered, /exec local CLI|active/u) assert.doesNotMatch(rendered, /Next profile|openai\/gpt-next|Next connection/u) + const spacious = chrome.render(200).join('\n') + assert.match(spacious, /backend CLI Bridge · connection Local CLI Bridge/u) + const activity = activityDocument(view) - assert.match(activity.context ?? '', /Exact run profile · pi · tangle-router\/glm-5\.2/u) + assert.equal(activity.context, undefined) const detail = activity.rows[0]?.detailLines.join('\n') ?? '' assert.match(detail, /profile digest: digest-run/u) - assert.match(detail, /max output tokens: 16384/u) + assert.match(detail, /max visible output tokens: 16384/u) const { activeRunId: _activeRunId, ...idleView } = view const idleTarget = executionTargetFor({ ...idleView, status: 'completed' }) @@ -178,12 +188,143 @@ test('execution identity comes from one active run receipt instead of current pr assert.equal(idleTarget.environment, undefined) }) +test('the wide rail shows only observed sandbox facts and measured usage', () => { + const base = usageView() + const view: BraidViewModel = { + ...base, + status: 'running', + statusText: 'streaming', + activeRunId: 'sandbox-run', + runs: [ + { + id: 'sandbox-run', + status: 'running', + completeness: 'streaming', + profileName: base.profileName, + runner: base.runner, + model: base.model, + effort: 'high', + maxVisibleOutputTokens: 16_384, + maxReasoningTokens: 8_192, + maxTotalOutputTokens: 24_576, + provider: 'tangle-sandbox', + connection: 'Tangle Sandbox', + environmentId: 'sandbox-1', + usage: { model: base.model }, + }, + ], + environments: [ + { + id: 'sandbox-1', + connectionId: 'sandbox-connection', + kind: 'sandbox', + provider: 'tangle-sandbox', + lifecycle: 'ready', + location: 'remote', + runtimeEndpointHost: '10.0.0.7', + machineId: 'machine-a10', + verifiedRegion: 'us-central', + requestedResources: { cpuCores: 2, memoryMB: 4_096, diskGB: 20 }, + resourceSample: { + cgroupVersion: 2, + memoryCurrentMb: 512, + memoryPeakMb: 768, + cpuUsageUsec: 2_500, + sampledAt: '2026-08-15T00:00:01.000Z', + }, + gpu: { + provider: 'tangle', + accelerator: 'A10', + count: 1, + status: 'allocated', + billedCustomerCostUsd: 0.0123, + }, + unavailableTelemetry: ['physical-ip'], + createdAt: '2026-08-15T00:00:00.000Z', + updatedAt: '2026-08-15T00:00:01.000Z', + }, + ], + } + const chrome = new TerminalChrome(theme) + chrome.setState({ + view, + quitArmed: false, + activityVisible: false, + navigationHint: '/ commands · Ctrl+P', + composerMode: 'queue', + }) + + const rendered = chrome.render(200).join('\n') + assert.match(rendered, /Sandbox/u) + assert.doesNotMatch(rendered, /backend tangle-sandbox/u) + assert.match(rendered, /host 10\.0\.0\.7/u) + assert.match(rendered, /machine machine-a10/u) + assert.match(rendered, /region us-central/u) + assert.match(rendered, /mem 512MB/u) + assert.match(rendered, /size 2cpu · 4GB · 20GB/u) + assert.match(rendered, /gpu 1× A10 \$0\.0123/u) + assert.match(rendered, /think high · caps vis 16k · reas 8\.2k · total 25k/u) + assert.doesNotMatch(rendered, /fixture\/deterministic|thinking none|unknown|not reported/u) + assert.match(rendered, /in 10|out 20|≥\$0\.0100|latency ≥120ms/u) + assert.doesNotMatch(rendered, /physical-ip|unknown/u) + const estimatedGpuView: BraidViewModel = { + ...view, + environments: view.environments.map((environment) => ({ + ...environment, + ...(environment.gpu === undefined + ? {} + : (() => { + const { billedCustomerCostUsd: _billedCustomerCostUsd, ...gpu } = environment.gpu + return { gpu: { ...gpu, estimatedCustomerCostUsd: 0.42 } } + })()), + })), + } + chrome.setState({ + view: estimatedGpuView, + quitArmed: false, + activityVisible: false, + navigationHint: '/ commands · Ctrl+P', + composerMode: 'queue', + }) + const estimatedGpuFooter = chrome.render(200).join('\n') + assert.match(estimatedGpuFooter, /gpu 1× A10/u) + assert.doesNotMatch(estimatedGpuFooter, /\$0\.4200/u) + chrome.setState({ + view, + quitArmed: false, + activityVisible: false, + navigationHint: '/ commands · Ctrl+P', + composerMode: 'queue', + }) + for (const width of [60, 80, 99]) { + const medium = chrome.render(width).join('\n') + assert.match(medium, /profile Release engineer/u) + assert.match(medium, /Ctrl\+C cancel/u) + if (width >= 80) { + assert.match(medium, /pi \/ gpt-5\.6-luna/u) + assert.match(medium, /via Sandbox/u) + } + assert.doesNotMatch(medium, /host |machine |region |mem |size |gpu |\bin |\bout |\$|latency /u) + assert.doesNotMatch(medium, /unknown|not reported|…/u) + } + for (const width of [40, 60, 80, 99, 100, 120, 160, 200]) { + for (const line of chrome.render(width)) assert.ok(visibleWidth(line) <= width) + } +}) + function usageView(): BraidViewModel { const app = createBraidApplication({ fixture: 'deterministic' }) app.initialize('/workspace') const base = createApplicationUiController(app).view() return { ...base, + profileName: 'Release engineer', + runner: 'pi', + model: 'openai-codex/gpt-5.6-luna', + effort: 'high', + maxVisibleOutputTokens: 16_384, + maxReasoningTokens: 8_192, + maxTotalOutputTokens: 24_576, sessionUsage: { turns: { sourceCount: 2, diff --git a/test/trace-analysis-configuration.test.ts b/test/trace-analysis-configuration.test.ts index 80cfdfb..c935eaa 100644 --- a/test/trace-analysis-configuration.test.ts +++ b/test/trace-analysis-configuration.test.ts @@ -582,10 +582,12 @@ test('runtime-owned trace model call preserves canonical messages, limits, usage if (!result.succeeded) return assert.equal(receivedAuthorization, 'Bearer credential-never-recorded') assert.deepEqual(receivedBody?.messages, [ - { role: 'system', content: 'private analyst instruction' }, - { role: 'user', content: 'private trace question' }, + { + role: 'user', + content: JSON.stringify({ messages: optimizerRequest().request.messages }), + }, ]) - assert.equal(receivedBody?.max_tokens, 64) + assert.equal(receivedBody?.max_tokens, undefined) assert.equal(receivedBody?.temperature, 0.2) assert.deepEqual(receivedBody?.response_format, { type: 'json_schema', @@ -638,6 +640,9 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable default: 'tangle-router/glm-5.2', provider: 'tangle-router', reasoningEffort: 'high', + maxVisibleOutputTokens: 128, + maxReasoningTokens: 256, + maxTotalOutputTokens: 384, }, }, connection: selected, @@ -672,7 +677,7 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable assert.equal(bridge.requests.length, 1) assert.deepEqual( admissions.map((admission) => admission.phase), - ['environment', 'dispatched'], + ['intent', 'environment', 'dispatched'], ) const body = bridge.requests[0]?.body assert.equal(body?.model, 'pi/tangle-router/glm-5.2') @@ -682,7 +687,10 @@ test('runtime-owned CLI Bridge analysis uses the harness executor with portable assert.equal(executionProfile.model?.default, 'tangle-router/glm-5.2') assert.equal(executionProfile.model?.provider, 'tangle-router') assert.equal(executionProfile.model?.reasoningEffort, 'none') - assert.equal(executionProfile.model?.metadata?.maxTokens, 64) + assert.equal(executionProfile.model?.maxVisibleOutputTokens, 64) + assert.equal(executionProfile.model?.maxReasoningTokens, 256) + assert.equal(executionProfile.model?.maxTotalOutputTokens, 384) + assert.equal(Object.hasOwn(executionProfile.model?.metadata ?? {}, 'maxTokens'), false) assert.equal(executionProfile.model?.metadata?.retry, undefined) assert.match(executionProfile.prompt?.systemPrompt ?? '', /text-generation endpoint/u) assert.match(executionProfile.prompt?.systemPrompt ?? '', /messages array/u) diff --git a/test/tui-autocomplete.test.ts b/test/tui-autocomplete.test.ts index ad00156..177020b 100644 --- a/test/tui-autocomplete.test.ts +++ b/test/tui-autocomplete.test.ts @@ -243,7 +243,7 @@ test('the production terminal does not turn a fast profile command into unknown await waitUntil(() => view.editor.isShowingAutocomplete()) terminal.sendInput('profile') terminal.sendInput('\r') - await waitUntil(() => terminal.getViewport().join('\n').includes('profiles')) + await waitUntil(() => terminal.getViewport().join('\n').includes('Braid starter')) const screen = terminal.getViewport().join('\n') assert.doesNotMatch(screen, /unknown command \/profilnew/iu) assert.doesNotMatch(screen, /unknown command \/profilenew/iu) diff --git a/test/tui-core-workflows.test.ts b/test/tui-core-workflows.test.ts index 3b97183..1804518 100644 --- a/test/tui-core-workflows.test.ts +++ b/test/tui-core-workflows.test.ts @@ -712,6 +712,81 @@ test('comparison view leads with both outcomes and pages through every captured assert.match(panel.render(80).join('\n'), /run\.cost_usd/u) }) +test('left and escape return from every full-screen secondary browser', () => { + const comparison = { + baseline: { + label: 'baseline' as const, + runId: 'run-baseline', + sourceDigest: 'digest-baseline', + outcome: 'completed', + cost: '$0.02', + costProvenance: 'measured', + }, + candidate: { + label: 'candidate' as const, + runId: 'run-candidate', + sourceDigest: 'digest-candidate', + outcome: 'completed', + cost: '$0.01', + costProvenance: 'measured', + }, + pairCount: 1, + unpairedBaseline: 0, + unpairedCandidate: 0, + sampleLimit: 'one pair', + fields: [], + pairedFacts: [], + semantic: { status: 'unavailable', reason: 'not run' }, + replayed: false, + } + + for (const key of ['\u001b[D', '\u001b']) { + const analysis = new AnalysisViewPanel(theme) + analysis.setView(analysisView('failure')) + analysis.render(40) + analysis.handleInput(key) + assert.equal(analysis.mode, 'list', `analysis ${JSON.stringify(key)}`) + + const comparisonPanel = new ComparisonViewPanel(theme) + comparisonPanel.setView(comparison) + comparisonPanel.render(40) + comparisonPanel.handleInput(key) + assert.equal(comparisonPanel.mode, 'list', `comparison ${JSON.stringify(key)}`) + } + + for (const key of ['\u001b[D', '\u001b']) { + let graphCloses = 0 + const graph = new GraphView(theme, { onClose: () => (graphCloses += 1) }) + graph.setView(graphView()) + graph.render(40) + graph.handleInput(key) + assert.equal(graphCloses, 1, `graph ${JSON.stringify(key)}`) + + let workerCloses = 0 + const workers = new ActivityBrowserPanel(theme, { + view: () => ({ + ...baseView(), + activity: [ + { + id: 'worker:worker-1', + kind: 'worker', + title: 'worker one', + status: 'completed', + entityType: 'worker', + entityId: 'worker-1', + }, + ], + }), + rows: () => 12, + onClose: () => (workerCloses += 1), + scope: 'workers', + }) + workers.render(40) + workers.handleInput(key) + assert.equal(workerCloses, 1, `worker ${JSON.stringify(key)}`) + } +}) + test('approval actions are navigable and secret responses never render values', async () => { const responses: unknown[] = [] const shell = new InteractionShell({ ...permission, queueTotal: 3 }, theme, (response) => @@ -831,10 +906,10 @@ test('fork and confirmation dialogs expose only short, actionable keys', () => { const lines = dialog.render(40) assertFits(lines, 40) assert.match(lines.join('\n'), /will delete permanently/u) - assert.match(lines.join('\n'), /enter\/y confirm · n\/esc cancel/u) + assert.match(lines.join('\n'), /enter\/y confirm · n\/←\/esc cancel/u) }) -test('digit-leading text, secret, and number answers stay editable', () => { +test('digit-leading answers stay editable and left cancels full-screen text input', () => { const secretResponse: unknown[] = [] const secretShell = new InteractionShell( { @@ -869,9 +944,27 @@ test('digit-leading text, secret, and number answers stay editable', () => { ) textShell.handleInput('ab') textShell.handleInput('\u001b[D') + assert.deepEqual(textResponse, [{ outcome: 'cancel' }]) textShell.handleInput('c') textShell.handleInput('\r') - assert.deepEqual(textResponse, [{ outcome: 'accept', value: 'acb' }]) + assert.deepEqual(textResponse, [{ outcome: 'cancel' }]) + + const plainTextResponse: unknown[] = [] + const plainTextShell = new InteractionShell( + { + ...permission, + interactionId: 'interaction-plain-text', + kind: 'question', + answerSpec: { kind: 'text', required: true, secret: false }, + allowedOutcomes: ['accept', 'reject', 'cancel'], + secret: false, + }, + theme, + (response) => plainTextResponse.push(response), + ) + plainTextShell.handleInput('abc') + plainTextShell.handleInput('\r') + assert.deepEqual(plainTextResponse, [{ outcome: 'accept', value: 'abc' }]) const numberResponse: unknown[] = [] const numberShell = new InteractionShell( @@ -922,7 +1015,7 @@ test('short interaction surfaces keep validation failures visible', () => { assert.match(screen, /Enter a number in the allowed range\./u) assert.match(screen, /alt\+1 approve · alt\+2 reject/u) assert.match(screen, /alt\+3 cancel/u) - assert.match(screen, /enter submit · esc cancel/u) + assert.match(screen, /enter submit · ←\/esc cancel/u) }) test('alt-digit keys reach every allowed outcome without taking editable digits', () => { diff --git a/test/tui.test.ts b/test/tui.test.ts index ff737c6..72d4849 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -17,6 +17,7 @@ import type { BraidRuntimeEvent } from '../src/domain/runtime-events.js' import { DEFAULT_RUN_CAPABILITIES, type ExecutionPort } from '../src/ports/execution.js' import { BraidTerminalApp } from '../src/views/tui/terminal-app.js' import { createBraidTheme } from '../src/views/tui/theme.js' +import { interactionResponseRunCapabilities } from './support/run-capabilities.js' import { VirtualTerminal } from './support/virtual-terminal.js' const SIZES = [ @@ -132,7 +133,7 @@ function automationInteractionExecution(): { let release: (() => void) | undefined return { execution: { - capabilities: () => DEFAULT_RUN_CAPABILITIES, + capabilities: () => interactionResponseRunCapabilities(), async *streamTurn(input): AsyncIterable { yield { type: 'interaction', @@ -259,11 +260,44 @@ test('the real Braid root renders and sends at all four reference sizes', async } }) +test('terminal suspension releases input and resumes the same composer state', async () => { + const terminal = new VirtualTerminal(80, 24) + const tui = new TuiMainScreen(terminal) + const app = createBraidApplication({ fixture: 'deterministic' }) + app.initialize('/workspace') + const view = new BraidTerminalApp({ + controller: createApplicationUiController(app), + tui, + theme: createBraidTheme(false), + workspace: '/workspace', + nextOperationId: () => 'operation-terminal-suspend', + }) + const done = view.start() + try { + terminal.sendInput('before attach') + assert.equal(view.editor.getText(), 'before attach') + + view.suspend() + terminal.sendInput(' ignored') + assert.equal(view.editor.getText(), 'before attach') + + view.resume() + terminal.sendInput(' after') + assert.equal(view.editor.getText(), 'before attach after') + await terminal.waitForRender() + assert.match(terminal.getViewport().join('\n'), /before attach after/u) + } finally { + view.stop() + await done + await app.close() + } +}) + test('composer fallback steers exactly once without queueing or sending', async () => { const harness = await startSteeringTerminal() try { await harness.terminal.waitForRender() - assert.match(harness.terminal.getViewport().join('\n'), /Enter steers/u) + assert.match(harness.terminal.getViewport().join('\n'), /› steer/u) harness.terminal.sendInput('correct course') harness.terminal.sendInput('\r') await waitUntil(() => harness.calls.steer === 1) @@ -301,10 +335,10 @@ test('Alt+S changes an active composer from queue to steer', async () => { const harness = await startSteeringTerminal(true) try { await harness.terminal.waitForRender() - assert.match(harness.terminal.getViewport().join('\n'), /Enter queues · Alt\+S steer/u) + assert.match(harness.terminal.getViewport().join('\n'), /› queue #1/u) harness.terminal.sendInput('\u001bs') await harness.terminal.waitForRender() - assert.match(harness.terminal.getViewport().join('\n'), /Enter steers · Alt\+S queue/u) + assert.match(harness.terminal.getViewport().join('\n'), /› steer/u) harness.terminal.sendInput('change direction') harness.terminal.sendInput('\r') @@ -430,7 +464,9 @@ test('the searchable command overlay restores editor focus after close', async ( assert.doesNotMatch(overlay, /alt\+enter newline/u) terminal.sendInput('\u001b') await terminal.waitForRender() - assert.match(terminal.getViewport().join('\n'), /profile Braid starter.*Ctrl\+P commands/u) + const restored = terminal.getViewport().join('\n') + assert.match(restored, /profile Braid starter/u) + assert.match(restored, /type \/ for commands/u) terminal.sendInput('focus restored') assert.equal(view.editor.getText(), 'focus restored') @@ -461,7 +497,7 @@ test('Ctrl+K exposes one five-row run switcher and updates the branch runner', a for (const label of ['Profile', 'Connection', 'Runner', 'Model', 'Thinking']) { assert.match(switcher, new RegExp(`\\b${label}\\b`, 'u')) } - assert.match(switcher, /enter to change · esc to close/u) + assert.match(switcher, /enter to change · ←\/esc close/u) terminal.sendInput('\u001b[B') terminal.sendInput('\u001b[B') diff --git a/test/w6-ui.test.ts b/test/w6-ui.test.ts index a470c45..434c61a 100644 --- a/test/w6-ui.test.ts +++ b/test/w6-ui.test.ts @@ -4,11 +4,13 @@ import { TuiMainScreen, visibleWidth } from '@earendil-works/pi-tui' import { createApplicationUiController } from '../src/adapters/tui/application-ui-controller.js' import { createBraidApplication } from '../src/app/composition.js' import { MemoryJournal } from '../src/app/journal.js' +import type { ProfileSummary } from '../src/app/profiles.js' import { FixedClock } from '../src/ports/clock.js' import type { BraidUiController } from '../src/views/shared/intents.js' import type { BraidViewModel, InteractionView, ViewStatus } from '../src/views/shared/models.js' import { ActivityView } from '../src/views/tui/activity.js' import { AnalysisViewPanel } from '../src/views/tui/analysis.js' +import { profileItems } from '../src/views/tui/configuration-presenters.js' import { ConnectionSetupViewPanel } from '../src/views/tui/connection-setup.js' import { DetailsViewPanel } from '../src/views/tui/details.js' import { DynamicAutocompleteProvider } from '../src/views/tui/dynamic-autocomplete.js' @@ -303,25 +305,41 @@ test('stable chrome keeps identity and status outside transcript history', () => view: viewFor('completed'), quitArmed: false, activityVisible: false, - navigationHint: 'Ctrl+P commands', + navigationHint: '/ commands · Ctrl+P', composerMode: 'queue', }) const lines = chrome.render(40) const firstLine = lines[0] ?? '' assert.equal(lines.length, 1) - assert.match(firstLine, /reviewer.*Ctrl\+P commands/u) + assert.equal(firstLine, 'reviewer') assert.doesNotMatch(firstLine, /fixture|deterministic/u) assert.ok(visibleWidth(firstLine) <= 40) const standard = chrome.render(80) assert.equal(standard.length, 1) - assert.match(standard[0] ?? '', /reviewer.*pi \/ deterministic/u) - assert.match(standard[0] ?? '', /fixture/u) - assert.doesNotMatch(standard[0] ?? '', /deterministic fixture/u) - assert.match(standard[0] ?? '', /Ctrl\+P commands/u) + assert.match(standard[0] ?? '', /reviewer.*pi/u) + assert.match(standard[0] ?? '', /via local/u) + assert.doesNotMatch(standard[0] ?? '', /fixture|deterministic fixture/u) + assert.doesNotMatch(standard[0] ?? '', /\/ commands/u) + assert.doesNotMatch(standard[0] ?? '', /Ctrl\+P/u) + const wide = chrome.render(120).join('\n') + assert.match(wide, /profile reviewer · harness pi · backend local/u) + assert.doesNotMatch(wide, /fixture|deterministic|50ms|in 6|out 12/u) for (const line of standard) assert.ok(visibleWidth(line) <= 80) }) +test('empty transcript keeps its starter hint beside the composer', () => { + const transcript = new TranscriptView(theme) + transcript.setViewportRows(8) + transcript.setView({ + ...viewFor('empty'), + messages: [], + runs: [], + }) + const lines = transcript.render(80) + assert.equal(lines.at(-1)?.trimEnd(), ' Ask reviewer anything.') +}) + test('terminal chrome aggregates known conversation metrics without filling gaps', () => { const baseRun = { status: 'completed' as const, completeness: 'complete' as const } const view = viewFor('completed') @@ -558,7 +576,7 @@ test('terminal presentation translates runtime result labels without changing de test('one searchable selector preserves query and supports keyboard selection', () => { let selected = '' - let cancelled = false + let cancellations = 0 const selector = new SearchableSelector({ title: 'items', items: [ @@ -570,14 +588,50 @@ test('one searchable selector preserves query and supports keyboard selection', selected = item.value }, onCancel: () => { - cancelled = true + cancellations += 1 }, }) selector.setQuery('quit') selector.handleInput('\r') assert.equal(selected, 'quit') selector.handleInput('\u001b') - assert.equal(cancelled, true) + selector.handleInput('\u001b[D') + assert.equal(cancellations, 2) +}) + +test('profile selector rows keep the profile identity intact at responsive widths', () => { + const profile = { + id: 'profile-braid-starter', + name: 'Braid starter', + tags: [], + source: { + kind: 'inline', + reference: 'workspace', + label: 'Braid starter', + writable: false, + trusted: true, + }, + digest: 'digest', + runner: 'pi', + model: 'openai-codex/gpt-5.6-luna', + tools: [], + skills: [], + connections: [], + } satisfies ProfileSummary + const [item] = profileItems([profile], profile.name, profile.id) + assert(item) + assert.doesNotMatch(item.description ?? '', /Braid starter/u) + + const selector = new SearchableSelector({ + title: 'profiles', + items: [item], + theme, + onSelect: () => {}, + onCancel: () => {}, + }) + for (const width of [40, 80, 120]) { + for (const line of selector.render(width)) assert.ok(visibleWidth(line) <= width) + } }) test('searching a selector resets stale navigation to the best match', () => { @@ -619,6 +673,8 @@ test('slash command autocomplete refreshes descriptions after capabilities chang const interaction: InteractionView = { runId: 'run-1', interactionId: 'interaction-1', + profileName: 'Product engineer', + runner: 'pi', kind: 'permission', prompt: 'Allow the tool?', subject: { type: 'file', title: 'secret.txt', preview: ['read-only preview'] }, @@ -638,6 +694,8 @@ test('interaction shell masks secret answers and accepts alt-digit outcomes', () shell.handleInput('TOPSECRET') const rendered = shell.render(80).join('\n') assert.equal(rendered.includes('TOPSECRET'), false) + assert.match(rendered, /Product engineer · pi/u) + assert.doesNotMatch(rendered, /run-1/u) shell.handleInput('\u001b1') shell.handleInput('\r') assert.deepEqual(response, { outcome: 'once', value: 'TOPSECRET' }) @@ -873,7 +931,7 @@ test('conversation shortcut opens the searchable conversation picker', async () const screen = terminal.getViewport().join('\n') assert.match(screen, /conversation/u) assert.match(screen, /New conversation/u) - assert.match(screen, /type to filter · enter to choose · esc to close/u) + assert.match(screen, /type to filter · enter to choose · ←\/esc close/u) view.stop() await done })