tumwater 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -108
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-args.js +24 -0
- package/dist/src/agent/agent-backend.js +2 -2
- package/dist/src/agent/backend-traits.js +60 -0
- package/dist/src/agent/claude/claude-account.js +61 -8
- package/dist/src/agent/claude/claude-args.js +12 -15
- package/dist/src/agent/claude/claude-backend.js +51 -41
- package/dist/src/agent/claude/claude-env.js +7 -3
- package/dist/src/agent/claude/claude-stream.js +7 -16
- package/dist/src/agent/image-input.js +107 -0
- package/dist/src/agent/notes-shim.js +41 -0
- package/dist/src/agent/opencode/opencode-args.js +61 -0
- package/dist/src/agent/opencode/opencode-backend.js +255 -0
- package/dist/src/agent/opencode/opencode-catalog.js +184 -0
- package/dist/src/agent/opencode/opencode-config.js +188 -0
- package/dist/src/agent/opencode/opencode-env.js +36 -0
- package/dist/src/agent/opencode/opencode-session-id.js +37 -0
- package/dist/src/agent/opencode/opencode-stream.js +278 -0
- package/dist/src/agent/role-notes-write.js +16 -32
- package/dist/src/agent/run-agent.js +95 -18
- package/dist/src/agent/session-ledger.js +57 -1
- package/dist/src/backlog/backlog-archive.js +296 -0
- package/dist/src/backlog/backlog-cli.js +573 -0
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +357 -33
- package/dist/src/backlog/backlog-md.js +98 -18
- package/dist/src/backlog/backlog-render.js +62 -11
- package/dist/src/backlog/backlog-structure.js +261 -58
- package/dist/src/backlog/backlog-write.js +197 -319
- package/dist/src/backlog/backlog.js +113 -16
- package/dist/src/backlog/question-answer.js +339 -0
- package/dist/src/baseline/main-baseline.js +75 -88
- package/dist/src/baseline/main-red.js +38 -30
- package/dist/src/brief/brief-edit.js +43 -0
- package/dist/src/{brief.js → brief/brief.js} +66 -10
- package/dist/src/budget/budget.js +32 -15
- package/dist/src/budget/fallback-breaker.js +8 -7
- package/dist/src/build/build-check-detect.js +8 -7
- package/dist/src/build/build-check-events.js +68 -22
- package/dist/src/build/build-check-report.js +22 -23
- package/dist/src/build/build-check-scoped.js +51 -62
- package/dist/src/build/build-check.js +15 -14
- package/dist/src/build/build-info.js +2 -1
- package/dist/src/build/build-stage.js +23 -22
- package/dist/src/build/dep-install.js +22 -21
- package/dist/src/build/host-sleep.js +1 -1
- package/dist/src/change/change-data.js +25 -14
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +58 -0
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +46 -14
- package/dist/src/cli/cli-editor.js +64 -0
- package/dist/src/cli/cli-flag-specs.js +54 -16
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +458 -49
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +208 -59
- package/dist/src/cli/completion.js +207 -0
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +305 -110
- package/dist/src/cli/log-commands.js +127 -21
- package/dist/src/cli/notify-command.js +33 -18
- package/dist/src/cli/question-commands.js +29 -224
- package/dist/src/cli.js +121 -40
- package/dist/src/collections.js +66 -16
- package/dist/src/concurrency/check-permit.js +16 -17
- package/dist/src/concurrency/lock.js +6 -5
- package/dist/src/concurrency/provider-lease.js +69 -0
- package/dist/src/concurrency/semaphore.js +258 -36
- package/dist/src/config/config-aliases.js +106 -0
- package/dist/src/config/config-editable-keys.js +3 -3
- package/dist/src/config/config-fallbacks.js +379 -0
- package/dist/src/config/config-field-checks.js +19 -10
- package/dist/src/config/config-keys.js +160 -0
- package/dist/src/config/config-live.js +64 -12
- package/dist/src/config/config-schema.js +34 -22
- package/dist/src/config/config-validation.js +132 -48
- package/dist/src/config/config-views.js +119 -337
- package/dist/src/config/config-write.js +138 -177
- package/dist/src/config/config.js +60 -49
- package/dist/src/config/model-selector.js +28 -2
- package/dist/src/doctor/doctor-backlog.js +546 -49
- package/dist/src/doctor/doctor-checks.js +534 -56
- package/dist/src/doctor/doctor-holds.js +193 -53
- package/dist/src/doctor/doctor-install.js +30 -0
- package/dist/src/doctor/doctor-launch-services.js +2 -2
- package/dist/src/doctor/doctor-model-checks.js +549 -157
- package/dist/src/doctor/doctor-notify.js +27 -0
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +80 -22
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +226 -102
- package/dist/src/events/event-labels.js +116 -0
- package/dist/src/events/event-legacy.js +140 -0
- package/dist/src/events/event-read.js +58 -22
- package/dist/src/events/event-window.js +27 -7
- package/dist/src/events/events.js +18 -14
- package/dist/src/events/notify.js +137 -38
- package/dist/src/failure/failure-cluster.js +12 -9
- package/dist/src/failure/failure-data.js +96 -48
- package/dist/src/failure/failure-render.js +69 -58
- package/dist/src/failure/failure-state-change.js +33 -26
- package/dist/src/failure/time-spend.js +79 -59
- package/dist/src/files/file-queue.js +16 -4
- package/dist/src/files/files.js +18 -6
- package/dist/src/files/json-object.js +18 -3
- package/dist/src/files/tail.js +1 -1
- package/dist/src/gates/bootstrap-gates.js +5 -4
- package/dist/src/gates/budget-gates.js +250 -34
- package/dist/src/gates/disk-gate.js +18 -18
- package/dist/src/gates/gate-polls.js +117 -69
- package/dist/src/gates/maintenance-quota.js +29 -29
- package/dist/src/gates/pause-gates.js +11 -11
- package/dist/src/gates/readiness.js +2 -1
- package/dist/src/gates/role-cap-gates.js +16 -21
- package/dist/src/gates/startup-gate.js +75 -31
- package/dist/src/gates/streak-gate.js +18 -16
- package/dist/src/git/commit-message.js +12 -5
- package/dist/src/git/git-diff.js +16 -16
- package/dist/src/git/git-run.js +8 -8
- package/dist/src/git/git.js +86 -55
- package/dist/src/git/slots-state.js +14 -5
- package/dist/src/git/worktree-pool.js +25 -18
- package/dist/src/git/worktree-use.js +15 -14
- package/dist/src/git/worktree.js +18 -18
- package/dist/src/gui/gui-args.js +10 -14
- package/dist/src/gui/gui-backlog-commands.js +273 -0
- package/dist/src/gui/gui-command.js +1 -1
- package/dist/src/gui/gui-config-commands.js +440 -0
- package/dist/src/gui/gui-endpoint-commands.js +16 -669
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +116 -56
- package/dist/src/gui/gui-operator-commands.js +145 -0
- package/dist/src/gui/gui-prompt-commands.js +234 -0
- package/dist/src/gui/gui-server.js +28 -8
- package/dist/src/gui/gui-team-commands.js +164 -0
- package/dist/src/gui/http-body.js +17 -1
- package/dist/src/history/history-data.js +194 -98
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +37 -58
- package/dist/src/inbox/inbox-attachments.js +75 -14
- package/dist/src/inbox/inbox-cancel.js +7 -5
- package/dist/src/inbox/inbox-edit.js +1 -1
- package/dist/src/inbox/inbox-submit.js +44 -4
- package/dist/src/inbox/inbox-top.js +83 -0
- package/dist/src/inbox/inbox.js +8 -2
- package/dist/src/inbox/pending-prompt.js +1 -1
- package/dist/src/inbox/prompt-commands.js +170 -80
- package/dist/src/init/init-templates.js +16 -14
- package/dist/src/init/init.js +12 -11
- package/dist/src/loop/leftover.js +28 -27
- package/dist/src/loop/loop-fallback.js +2 -2
- package/dist/src/loop/loop-pi.js +106 -52
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +139 -13
- package/dist/src/loop/loop-tick.js +194 -86
- package/dist/src/loop/loop.js +72 -36
- package/dist/src/loop/model-fallback.js +3 -2
- package/dist/src/loop/revision.js +16 -7
- package/dist/src/merge/backlog-conflicts.js +155 -0
- package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
- package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
- package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
- package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
- package/dist/src/merge/merge-drain.js +212 -0
- package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
- package/dist/src/merge/merge-pipeline.js +99 -0
- package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
- package/dist/src/merge/merge-queue-render.js +75 -0
- package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
- package/dist/src/merge/merge-slot.js +340 -0
- package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
- package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
- package/dist/src/merge/merge-verify.js +212 -0
- package/dist/src/notes-cli.js +97 -0
- package/dist/src/operator/operator-commands.js +154 -47
- package/dist/src/operator/operator-intent.js +87 -49
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +62 -27
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
- package/dist/src/orchestrator/orchestrator.js +158 -100
- package/dist/src/orchestrator/retention.js +29 -12
- package/dist/src/orchestrator/startup-migration-steps.js +51 -0
- package/dist/src/orchestrator/startup-migrations.js +40 -0
- package/dist/src/paths.js +128 -37
- package/dist/src/pi/pi-args.js +26 -3
- package/dist/src/pi/pi-backend.js +15 -23
- package/dist/src/pi/pi-bin.js +18 -12
- package/dist/src/pi/pi-event-line.js +17 -1
- package/dist/src/pi/pi-failure.js +87 -0
- package/dist/src/pi/pi-models.js +126 -38
- package/dist/src/pi/pi-run-result.js +1 -1
- package/dist/src/pi/pi-settings.js +86 -0
- package/dist/src/pi/pi-stream.js +131 -107
- package/dist/src/pi/pi-watchdogs.js +47 -43
- package/dist/src/pi-extension/bounded-output.js +20 -5
- package/dist/src/pi-extension/context-budget.js +3 -1
- package/dist/src/pi-extension/context-shake.js +54 -21
- package/dist/src/pi-extension/context-window.js +37 -0
- package/dist/src/pi-extension/full-output.js +2 -2
- package/dist/src/pi-extension/strip-thinking.js +80 -0
- package/dist/src/pi-extension/trim-system-prompt.js +38 -0
- package/dist/src/process/launch-services.js +11 -10
- package/dist/src/process/process-table.js +1 -1
- package/dist/src/process/process.js +7 -7
- package/dist/src/process/run-marker.js +29 -6
- package/dist/src/process/supervisor.js +47 -25
- package/dist/src/prompt/dialect.js +30 -10
- package/dist/src/prompt/gate-prompts.js +170 -67
- package/dist/src/prompt/principles.js +1 -0
- package/dist/src/prompt/prompt-followup.js +35 -20
- package/dist/src/prompt/prompt.js +249 -91
- package/dist/src/redeploy/redeploy-policy.js +10 -10
- package/dist/src/redeploy/redeploy.js +6 -4
- package/dist/src/redeploy/redeployer.js +63 -55
- package/dist/src/redeploy/self-reload.js +1 -1
- package/dist/src/report/report-data.js +264 -255
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +270 -33
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +33 -0
- package/dist/src/review/review-followup.js +16 -4
- package/dist/src/review/review-precheck.js +14 -13
- package/dist/src/review/review-verdict.js +14 -0
- package/dist/src/review/review.js +81 -39
- package/dist/src/review/secret-leaks.js +127 -0
- package/dist/src/review/suite-rerun.js +4 -3
- package/dist/src/roles/legacy-role-ids.js +21 -0
- package/dist/src/roles/legacy-role-migration.js +62 -0
- package/dist/src/roles/loop-ids.js +19 -2
- package/dist/src/roles/role-catalog.js +125 -135
- package/dist/src/roles/role-guidance.js +64 -31
- package/dist/src/roles/role-labels.js +10 -0
- package/dist/src/roles/role-render.js +194 -18
- package/dist/src/roles/role-view.js +216 -17
- package/dist/src/roles/roles.js +18 -15
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +25 -9
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +43 -24
- package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
- package/dist/src/status/status-budget.js +115 -0
- package/dist/src/status/status-data.js +77 -184
- package/dist/src/status/status-inbox.js +47 -0
- package/dist/src/status/status-install.js +27 -0
- package/dist/src/status/status-polls.js +68 -33
- package/dist/src/status/status-roles.js +105 -0
- package/dist/src/{fleet → team}/error-storm.js +10 -10
- package/dist/src/{fleet → team}/failure-spread.js +8 -8
- package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
- package/dist/src/{fleet → team}/reclaim.js +5 -4
- package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
- package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
- package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
- package/dist/src/text/datetime.js +42 -2
- package/dist/src/text/format.js +10 -1
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +35 -34
- package/dist/src/text/suggest.js +3 -2
- package/dist/src/text/text.js +2 -2
- package/dist/src/tick/qa-coverage.js +2 -1
- package/dist/src/tick/stage-check.js +147 -26
- package/dist/src/tick/telemetry-digest.js +10 -9
- package/dist/src/tick/tick-apply.js +101 -72
- package/dist/src/tick/tick-detail-data.js +32 -18
- package/dist/src/tick/tick-detail.js +62 -21
- package/dist/src/tick/tick-finalize.js +29 -22
- package/dist/src/tick/tick-outcome.js +2 -1
- package/dist/src/tick/tick-prompt.js +94 -26
- package/dist/src/tick/tick-resume.js +29 -13
- package/dist/src/tick/tick-stage.js +91 -47
- package/dist/src/tick/tick-timing.js +60 -59
- package/dist/src/tick/tick-usage.js +53 -23
- package/dist/src/tick/tick-verdict.js +76 -10
- package/dist/src/ui/badges.js +118 -67
- package/dist/src/ui/gui/gui-client-boot.js +209 -30
- package/dist/src/ui/gui/gui-client-composer.js +102 -23
- package/dist/src/ui/gui/gui-client-drawer.js +330 -75
- package/dist/src/ui/gui/gui-client-history.js +96 -38
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +94 -52
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +63 -43
- package/dist/src/ui/gui/gui-client-operator.js +129 -39
- package/dist/src/ui/gui/gui-client-pending.js +36 -32
- package/dist/src/ui/gui/gui-client-report.js +339 -46
- package/dist/src/ui/gui/gui-client-settings.js +148 -14
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +19 -19
- package/dist/src/ui/gui/gui-styles.js +12 -4
- package/dist/src/ui/progress-data.js +5 -3
- package/dist/src/ui/status-model.js +184 -122
- package/dist/src/ui/status-payload.js +119 -32
- package/dist/src/ui/status-render.js +122 -52
- package/dist/src/ui/team-alerts.js +341 -0
- package/dist/src/ui/tick-progress-model.js +14 -14
- package/dist/src/ui/tone.js +11 -8
- package/dist/src/ui/transcript-tail.js +1 -0
- package/dist/src/ui/transcript.js +23 -16
- package/dist/src/ui/tui/tui-app.js +1 -0
- package/dist/src/ui/tui/tui-backlog.js +85 -11
- package/dist/src/ui/tui/tui-editor-answer.js +97 -0
- package/dist/src/ui/tui/tui-editor-attach.js +107 -0
- package/dist/src/ui/tui/tui-editor-brief.js +107 -0
- package/dist/src/ui/tui/tui-editor-budget.js +155 -0
- package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
- package/dist/src/ui/tui/tui-editor-entry.js +230 -0
- package/dist/src/ui/tui/tui-editor-interval.js +122 -0
- package/dist/src/ui/tui/tui-editor-notes.js +114 -0
- package/dist/src/ui/tui/tui-editor-pause.js +266 -0
- package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
- package/dist/src/ui/tui/tui-editor-queued.js +98 -0
- package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
- package/dist/src/ui/tui/tui-editor-retire.js +83 -0
- package/dist/src/ui/tui/tui-editor-search.js +73 -0
- package/dist/src/ui/tui/tui-editor-session.js +97 -0
- package/dist/src/ui/tui/tui-editors.js +152 -0
- package/dist/src/ui/tui/tui-frame.js +67 -55
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +49 -44
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +91 -1704
- package/dist/src/ui/tui/tui-pane-activity.js +99 -0
- package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
- package/dist/src/ui/tui/tui-pane-brief.js +30 -0
- package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
- package/dist/src/ui/tui/tui-pane-health.js +124 -0
- package/dist/src/ui/tui/tui-pane-history.js +316 -0
- package/dist/src/ui/tui/tui-pane-pending.js +74 -0
- package/dist/src/ui/tui/tui-pane-queued.js +143 -0
- package/dist/src/ui/tui/tui-pane-report.js +122 -0
- package/dist/src/ui/tui/tui-pane-session.js +101 -0
- package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
- package/dist/src/ui/tui/tui-pane.js +66 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +45 -25
- package/dist/src/verdict/fix-claim.js +141 -38
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +92 -8
- package/dist/src/verdict/reply-contract.js +17 -5
- package/dist/src/version.js +3 -2
- package/package.json +4 -2
- package/dist/src/agent/mcp/role-notes-server.js +0 -160
- package/dist/src/landing/backlog-conflicts.js +0 -116
- package/dist/src/landing/landing-drain.js +0 -204
- package/dist/src/landing/landing-pipeline.js +0 -96
- package/dist/src/landing/landing-queue-render.js +0 -31
- package/dist/src/landing/landing-slot.js +0 -246
- package/dist/src/landing/landing-vetting.js +0 -196
- package/dist/src/pi-extension/role-notes.js +0 -40
- package/dist/src/ui/fleet-alerts.js +0 -174
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
2
|
+
/** Minting an OpenCode session id (plans/opencode-backend.md "Sessions", finding 3) — a pure
|
|
3
|
+
* reproduction of OpenCode's `Identifier.descending()` (packages/schema/src/identifier.ts), so
|
|
4
|
+
* a fresh run's `--session` id is accepted by the free-tier gate. A `ses_…` id in any other
|
|
5
|
+
* shape is refused with a 403 (finding 3), so this must match OpenCode exactly.
|
|
6
|
+
*
|
|
7
|
+
* The id is `ses_` + 12 hex digits + 14 base-62 characters. The hex is the low 48 bits of the
|
|
8
|
+
* bitwise NOT of (Unix milliseconds × 4096 + a per-millisecond counter), big-endian; the 14
|
|
9
|
+
* characters are drawn from `0-9A-Za-z` one random byte each. */
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
/** The 62-character alphabet OpenCode draws a session id's trailing characters from. */
|
|
12
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
13
|
+
/** Per-process minting state, mirroring OpenCode's per-millisecond counter: the counter
|
|
14
|
+
* restarts at 1 for each new millisecond, and increments within the same millisecond so two
|
|
15
|
+
* ids minted in one tick still differ in their hex. */
|
|
16
|
+
let lastMintMs = -1;
|
|
17
|
+
let counter = 0;
|
|
18
|
+
/** Mint an OpenCode session id. `now` is a Unix-millisecond epoch (injected for tests);
|
|
19
|
+
* `randomBytes` supplies the 14 base-62 characters (defaults to `crypto.randomBytes`, so the
|
|
20
|
+
* trailing part is random, never guessed from the visible prefix). */
|
|
21
|
+
export function mintOpencodeSessionId(now = Date.now(), randomBytes = (n) => crypto.randomBytes(n)) {
|
|
22
|
+
if (now === lastMintMs)
|
|
23
|
+
counter += 1;
|
|
24
|
+
else {
|
|
25
|
+
lastMintMs = now;
|
|
26
|
+
counter = 1;
|
|
27
|
+
}
|
|
28
|
+
// `Identifier.descending()`: current = now × 4096 + counter, then the low 48 bits of ~current.
|
|
29
|
+
const current = BigInt(now) * 0x1000n + BigInt(counter);
|
|
30
|
+
const hex = ((~current) & 0xffffffffffffn).toString(16).padStart(12, "0");
|
|
31
|
+
const bytes = randomBytes(14);
|
|
32
|
+
let suffix = "";
|
|
33
|
+
for (let i = 0; i < 14; i += 1) {
|
|
34
|
+
suffix += ALPHABET[bytes[i] % 62];
|
|
35
|
+
}
|
|
36
|
+
return `ses_${hex}${suffix}`;
|
|
37
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/** The opencode adapter's native-to-canonical stream translator (plans/opencode-backend.md
|
|
2
|
+
* §6, "The stream translator"). `opencode run --format json` writes one JSON object per line
|
|
3
|
+
* whose vocabulary differs from pi's in every dimension the readers care about: each step is
|
|
4
|
+
* opened by `step_start` and closed by `step_finish` carrying the step's cost and tokens; a
|
|
5
|
+
* tool is reported as a `tool_use` event only ONCE it has completed or errored (never while
|
|
6
|
+
* it runs); and errors arrive as their own `{type: "error", error: {type, message,
|
|
7
|
+
* status?, response?}}` line rather than as prose in a message. Rather than teach ~fifteen
|
|
8
|
+
* readers a second dialect, every native line is translated here into pi's vocabulary — the
|
|
9
|
+
* canonical stream — so `PiStreamParser`, the
|
|
10
|
+
* raw log, the transcript, progress, and the GUI all stay unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Pure translation: no spawn, no backend registration, no config. The factory keeps per-run
|
|
13
|
+
* state (the open step buffer, the queued tool calls, the unmapped native types seen, whether
|
|
14
|
+
* the run was refused by the free tier), so call it once per run. Unmapped native types and a
|
|
15
|
+
* malformed (non-JSON) line are recorded once each on the seam's optional `warnings()`, and
|
|
16
|
+
* `opencode_free_tier_refused` is added there once when any step was refused. `--thinking` is
|
|
17
|
+
* never passed, so no `reasoning` event appears; the mapping below still recognizes one so a
|
|
18
|
+
* future `--thinking` run degrades gracefully. */
|
|
19
|
+
import { canonicalUsageBlock, isJsonObject, nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
|
|
20
|
+
/** opencode's tool names → the canonical pi names the readers already know. `shell`→`bash` and
|
|
21
|
+
* `patch`→`edit` (pi has one edit tool); anything else is lower-cased so a future tool still
|
|
22
|
+
* reads as a name rather than as `undefined`. */
|
|
23
|
+
const TOOL_NAMES = {
|
|
24
|
+
shell: "bash",
|
|
25
|
+
patch: "edit",
|
|
26
|
+
};
|
|
27
|
+
/** A Retry-After hint spelled out in a rate-limit error's message text ("retry after 30s" /
|
|
28
|
+
* "retry in 30 seconds"). */
|
|
29
|
+
const RETRY_AFTER = /retry (?:after|in) (\d+)\s*s(?:econds?)?\b/i;
|
|
30
|
+
/** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
|
|
31
|
+
* backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
|
|
32
|
+
* `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
|
|
33
|
+
* unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
|
|
34
|
+
* its classification table). */
|
|
35
|
+
function classifyError(err) {
|
|
36
|
+
const message = typeof err.message === "string" ? err.message : "";
|
|
37
|
+
const type = typeof err.type === "string" ? err.type : "";
|
|
38
|
+
const status = err.status;
|
|
39
|
+
// The free tier refuses intermittently (a provider failure, not a config error): three
|
|
40
|
+
// refusals in a row move the tier to its fallback through the existing model-failure
|
|
41
|
+
// episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
|
|
42
|
+
// text naming the free tier. A `provider.auth` with any other status (a 401 bad
|
|
43
|
+
// credential) is not a refusal and falls through to the config-error row below.
|
|
44
|
+
if ((type === "provider.auth" && status === 403) || /free\s?tier|FreeTierError/i.test(message))
|
|
45
|
+
return { kind: "transientBackend", backendKind: "server", freeTier: true };
|
|
46
|
+
// Rate limiting — 429 or rate-limit text — is the world saying "later", inherently
|
|
47
|
+
// retryable; carry the echoed Retry-After hint when the message spells it.
|
|
48
|
+
if (status === 429 || /\b429\b|rate limit|too many requests/i.test(message)) {
|
|
49
|
+
const retry = RETRY_AFTER.exec(message);
|
|
50
|
+
return { kind: "transientRateLimit", ...(retry ? { retryAfterSeconds: Number(retry[1]) } : {}) };
|
|
51
|
+
}
|
|
52
|
+
// An unknown model or variant, and the malformed `ses_` id, are permanent config errors
|
|
53
|
+
// (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`, or
|
|
54
|
+
// the model-not-found / 401 text.
|
|
55
|
+
if (type === "provider.no-route" ||
|
|
56
|
+
type === "unknown" ||
|
|
57
|
+
status === 401 ||
|
|
58
|
+
/unknown model|unknown provider|model not found|provider not found|unauthorized|status\s*401|\b401\b/i.test(message))
|
|
59
|
+
return { kind: "configError" };
|
|
60
|
+
// Context overflow that compaction could not fix. Matched STRICTLY (the same shape pi's
|
|
61
|
+
// own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
|
|
62
|
+
// in passing — a plugin error, say — is never misread as an overflow that would then be
|
|
63
|
+
// trusted by PiStreamParser over its own tighter regex.
|
|
64
|
+
if (/context (size|length|window)?\s*(has been |was )?exceeded|exceeds? (the )?context|too (long|large) for .*context/i.test(message))
|
|
65
|
+
return { kind: "contextExceeded" };
|
|
66
|
+
// Transient server, connection and timeout failures.
|
|
67
|
+
if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
|
|
68
|
+
return { kind: "transientBackend", backendKind: "server" };
|
|
69
|
+
if (/connection|econn|refused|reset|terminated/i.test(message))
|
|
70
|
+
return { kind: "transientBackend", backendKind: "connection" };
|
|
71
|
+
if (/timed out|timeout/i.test(message))
|
|
72
|
+
return { kind: "transientBackend", backendKind: "timeout" };
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
/** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
|
|
76
|
+
* as `totalTokens`, and `cost.total` = the step's USD cost (plans/opencode-backend.md §6).
|
|
77
|
+
* `totalTokens` feeds `peakContextTokens`; `cost.total` accumulates into `costUsd`. */
|
|
78
|
+
function canonicalUsage(tokens, cost) {
|
|
79
|
+
const cache = objectOrEmpty(tokens.cache);
|
|
80
|
+
return canonicalUsageBlock(nonNegativeNumber(tokens.input, 0), nonNegativeNumber(cache.read, 0), nonNegativeNumber(cache.write, 0), nonNegativeNumber(tokens.output, 0), cost);
|
|
81
|
+
}
|
|
82
|
+
/** Build a fresh per-run opencode translator. */
|
|
83
|
+
export function opencodeTranslator(opts = {}) {
|
|
84
|
+
/** True once the run's session has been announced (the first event with a non-empty
|
|
85
|
+
* `sessionID` emits `session` then `agent_start`, once). */
|
|
86
|
+
let sessionStarted = false;
|
|
87
|
+
/** The open step: its buffered content blocks and the tool calls reported so far. OpenCode
|
|
88
|
+
* reports each call only once it has ended, so the calls are queued and emitted AFTER their
|
|
89
|
+
* step's `message_end`, in order — the assistant message, then its tool executions, the
|
|
90
|
+
* order every reader of the raw log assumes. */
|
|
91
|
+
let step;
|
|
92
|
+
const seen = new Set();
|
|
93
|
+
const warnings = new Set();
|
|
94
|
+
let sawFreeTier = false;
|
|
95
|
+
const record = (kind) => {
|
|
96
|
+
if (seen.has(kind))
|
|
97
|
+
return;
|
|
98
|
+
seen.add(kind);
|
|
99
|
+
warnings.add(kind);
|
|
100
|
+
};
|
|
101
|
+
/** Emit the open step (if any) as ONE canonical `message_end` (role assistant) carrying
|
|
102
|
+
* its buffered blocks and, when `withUsage`, its usage; then one `tool_execution_start`
|
|
103
|
+
* followed by a `tool_execution_end` for each queued call in order. A cut-off step (stream
|
|
104
|
+
* ended or an error arrived before `step_finish`) flushes without usage. */
|
|
105
|
+
const flush = (withUsage) => {
|
|
106
|
+
const s = step;
|
|
107
|
+
if (!s)
|
|
108
|
+
return [];
|
|
109
|
+
step = undefined;
|
|
110
|
+
const message = { role: "assistant", content: s.content };
|
|
111
|
+
if (withUsage)
|
|
112
|
+
message.usage = s.usage;
|
|
113
|
+
const out = [JSON.stringify({ type: "message_end", message })];
|
|
114
|
+
for (const call of s.calls) {
|
|
115
|
+
out.push(JSON.stringify({ type: "tool_execution_start", toolCallId: call.id, toolName: call.name, args: call.args }));
|
|
116
|
+
out.push(JSON.stringify({ type: "tool_execution_end", toolCallId: call.id, isError: call.isError }));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
const translate = (line) => {
|
|
121
|
+
const parsed = parseJsonObject(line);
|
|
122
|
+
if (!parsed) {
|
|
123
|
+
// A malformed / non-JSON line on stdout is skipped with one warning for the run.
|
|
124
|
+
record("malformed");
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const event = parsed;
|
|
128
|
+
// The first event with a non-empty `sessionID` announces the session and the agent start.
|
|
129
|
+
// An error raised before a session exists has `sessionID: ""` (or none) and emits neither.
|
|
130
|
+
let out = [];
|
|
131
|
+
if (!sessionStarted) {
|
|
132
|
+
const sid = event.sessionID;
|
|
133
|
+
if (typeof sid === "string" && sid !== "") {
|
|
134
|
+
sessionStarted = true;
|
|
135
|
+
out.push(JSON.stringify({
|
|
136
|
+
type: "session",
|
|
137
|
+
id: sid,
|
|
138
|
+
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
|
|
139
|
+
}));
|
|
140
|
+
out.push(JSON.stringify({ type: "agent_start" }));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const type = event.type;
|
|
144
|
+
switch (type) {
|
|
145
|
+
case "step_start":
|
|
146
|
+
step = { content: [], calls: [] };
|
|
147
|
+
break;
|
|
148
|
+
case "text": {
|
|
149
|
+
// A content block with no open buffer opens one: `step_start` is sometimes missing, and
|
|
150
|
+
// edit-run.jsonl's final `text` lands after its last `step_finish`, so a block dropped
|
|
151
|
+
// for want of a buffer would lose the run's last turn.
|
|
152
|
+
step ??= { content: [], calls: [] };
|
|
153
|
+
step.content.push({ type: "text", text: objectOrEmpty(event.part).text ?? "" });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "reasoning": {
|
|
157
|
+
step ??= { content: [], calls: [] };
|
|
158
|
+
const thinking = objectOrEmpty(event.part).text ?? "";
|
|
159
|
+
step.content.push({ type: "thinking", thinking: typeof thinking === "string" ? thinking : "" });
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
case "tool_use": {
|
|
163
|
+
// As above, a tool reported with no open buffer opens one.
|
|
164
|
+
step ??= { content: [], calls: [] };
|
|
165
|
+
const partValue = objectOrEmpty(event.part);
|
|
166
|
+
const state = objectOrEmpty(partValue.state);
|
|
167
|
+
const input = objectOrEmpty(state.input);
|
|
168
|
+
// `arguments` is the tool's `input` plus a `path` alias of `filePath` —
|
|
169
|
+
// describeToolCall reads either.
|
|
170
|
+
const args = { ...input };
|
|
171
|
+
if (typeof input.filePath === "string")
|
|
172
|
+
args.path = input.filePath;
|
|
173
|
+
// V2 2.0.32 emits the native tool name in `part.tool` (not `part.name`); the live
|
|
174
|
+
// captures agree.
|
|
175
|
+
const name = typeof partValue.tool === "string" ? partValue.tool : "";
|
|
176
|
+
// Own-property test, not a bare index: a tool name like `constructor` or
|
|
177
|
+
// `__proto__` would otherwise inherit a function from the table's prototype chain and
|
|
178
|
+
// bypass the lower-cased fallback (the same guard claude-stream.ts applies).
|
|
179
|
+
const canonicalName = Object.hasOwn(TOOL_NAMES, name) ? (TOOL_NAMES[name] ?? name.toLowerCase()) : name.toLowerCase();
|
|
180
|
+
const id = typeof partValue.id === "string" ? partValue.id : "";
|
|
181
|
+
step.content.push({ type: "toolCall", id, name: canonicalName, arguments: args });
|
|
182
|
+
step.calls.push({ id, name: canonicalName, args, isError: state.status === "error" });
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "step_finish": {
|
|
186
|
+
if (!step)
|
|
187
|
+
break;
|
|
188
|
+
const part = objectOrEmpty(event.part);
|
|
189
|
+
const tokens = objectOrEmpty(part.tokens);
|
|
190
|
+
const cache = objectOrEmpty(tokens.cache);
|
|
191
|
+
step.usage = canonicalUsage({
|
|
192
|
+
input: tokens.input,
|
|
193
|
+
output: tokens.output,
|
|
194
|
+
cache: { read: cache.read, write: cache.write },
|
|
195
|
+
}, nonNegativeNumber(part.cost, 0));
|
|
196
|
+
out.push(...flush(true));
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
case "error": {
|
|
200
|
+
// Errors carry their payload in `event.error` (`{type, message, status?, response?}`,
|
|
201
|
+
// plan finding 8); classification reads type first, message second.
|
|
202
|
+
const err = objectOrEmpty(event.error);
|
|
203
|
+
const cls = classifyError(err);
|
|
204
|
+
if (cls.freeTier)
|
|
205
|
+
sawFreeTier = true;
|
|
206
|
+
const message = typeof err.message === "string" ? err.message : "";
|
|
207
|
+
// The open step (if any) is cut off by the error and flushed without usage first.
|
|
208
|
+
out.push(...flush(false));
|
|
209
|
+
const m = { role: "assistant", content: [], stopReason: "error" };
|
|
210
|
+
if (message !== "")
|
|
211
|
+
m.errorMessage = message;
|
|
212
|
+
// tumwaterKind/backendKind ride the canonical EVENT (not its message): the parser
|
|
213
|
+
// reads event.tumwaterKind and trusts it before its text regexes.
|
|
214
|
+
out.push(JSON.stringify({
|
|
215
|
+
type: "message_end",
|
|
216
|
+
message: m,
|
|
217
|
+
...(cls.kind !== undefined ? { tumwaterKind: cls.kind } : {}),
|
|
218
|
+
...(cls.backendKind !== undefined ? { backendKind: cls.backendKind } : {}),
|
|
219
|
+
...(cls.retryAfterSeconds !== undefined ? { retryAfterSeconds: cls.retryAfterSeconds } : {}),
|
|
220
|
+
}));
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
default:
|
|
224
|
+
// Record the unknown type ("" for a type-less line), once, in first-seen order.
|
|
225
|
+
record(typeof type === "string" ? type : "unknown");
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
translate,
|
|
232
|
+
finalize() {
|
|
233
|
+
// The native stream ended with a step still open (a killed run, or a stream that just
|
|
234
|
+
// went idle): flush it as a message_end without usage so the last turn is not lost.
|
|
235
|
+
return flush(false);
|
|
236
|
+
},
|
|
237
|
+
noteOpencodeUsagePartial() {
|
|
238
|
+
warnings.add("opencode_usage_partial");
|
|
239
|
+
},
|
|
240
|
+
warnings() {
|
|
241
|
+
// The free-tier refusal is recorded lazily so a run reports it exactly once.
|
|
242
|
+
if (sawFreeTier)
|
|
243
|
+
warnings.add("opencode_free_tier_refused");
|
|
244
|
+
return [...warnings];
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
/** Turn an `opencode session export` object into the run's two authoritative canonical lines,
|
|
249
|
+
* `run_cost` and `run_usage`, read from the export's `info`. The live stream drops the final
|
|
250
|
+
* step's usage in most runs (finding 5), so the export — not the sum of the streamed steps — is
|
|
251
|
+
* the run's real cost and token totals. Anything malformed returns `[]`; part 5/7 logs
|
|
252
|
+
* `opencode_usage_partial` when this is empty. */
|
|
253
|
+
export function opencodeExportLines(exportJson) {
|
|
254
|
+
if (typeof exportJson !== "object" || exportJson === null || Array.isArray(exportJson))
|
|
255
|
+
return [];
|
|
256
|
+
const info = exportJson.info;
|
|
257
|
+
if (typeof info !== "object" || info === null)
|
|
258
|
+
return [];
|
|
259
|
+
const cost = nonNegativeNumber(info.cost, undefined);
|
|
260
|
+
if (cost === undefined)
|
|
261
|
+
return [];
|
|
262
|
+
// `info.tokens` must itself be a JSON object: a missing, scalar, or array (torn) section
|
|
263
|
+
// returns [] — never a zeroed run_usage — so part 5/7's `opencode_usage_partial` trigger
|
|
264
|
+
// fires. isJsonObject rejects the array a bare `typeof === "object"` lets through.
|
|
265
|
+
const rawTokens = info.tokens;
|
|
266
|
+
if (!isJsonObject(rawTokens))
|
|
267
|
+
return [];
|
|
268
|
+
const tokens = rawTokens;
|
|
269
|
+
const input = nonNegativeNumber(tokens.input, 0);
|
|
270
|
+
const output = nonNegativeNumber(tokens.output, 0);
|
|
271
|
+
const cache = objectOrEmpty(tokens.cache);
|
|
272
|
+
const cacheRead = nonNegativeNumber(cache.read, 0);
|
|
273
|
+
const cacheWrite = nonNegativeNumber(cache.write, 0);
|
|
274
|
+
return [
|
|
275
|
+
JSON.stringify({ type: "run_cost", total: cost }),
|
|
276
|
+
JSON.stringify({ type: "run_usage", input, output, cacheRead, cacheWrite }),
|
|
277
|
+
];
|
|
278
|
+
}
|
|
@@ -1,34 +1,18 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
/**
|
|
2
|
-
* The one write path for a role's notebook, shared by
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* The one write path for a role's notebook, shared by every surface that touches it: the
|
|
4
|
+
* `tumwater-notes` command (src/notes-cli.ts), `tumwater role --notes-file` /
|
|
5
|
+
* `--clear-notes` (cli-query-commands.ts), the dashboard drawer (gui-operator-commands.ts)
|
|
6
|
+
* and the TUI editors (tui-editor-notes.ts, tui-pane-transcript.ts). Kept backend-neutral —
|
|
7
|
+
* no pi, claude or opencode types — so every surface quotes the SAME 4 KB cap, the SAME
|
|
8
|
+
* validation, and the SAME validate-then-atomic-write execution, and cannot drift on what
|
|
9
|
+
* a valid note is or what a rejected write does.
|
|
8
10
|
*/
|
|
9
11
|
import { writeTextAtomic } from "../files/files.js";
|
|
10
12
|
import { gotSuffix } from "../text/text.js";
|
|
11
13
|
/** The cap on a role's note, in UTF-8 bytes. Small enough to cost little on every turn, large
|
|
12
14
|
* enough for the codebase facts a role wants to keep. */
|
|
13
15
|
export const ROLE_NOTES_MAX_BYTES = 4096;
|
|
14
|
-
/** The tool's parameter schema (plain JSON Schema), shared by the pi extension and the MCP
|
|
15
|
-
* server so both declare the same `{ text }` contract. */
|
|
16
|
-
export const ROLE_NOTES_PARAMETERS = {
|
|
17
|
-
type: "object",
|
|
18
|
-
properties: {
|
|
19
|
-
text: {
|
|
20
|
-
type: "string",
|
|
21
|
-
description: `The full replacement note, at most ${ROLE_NOTES_MAX_BYTES} bytes. Empty text clears it.`,
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
required: ["text"],
|
|
25
|
-
additionalProperties: false,
|
|
26
|
-
};
|
|
27
|
-
/** The human-facing description both surfaces carry: what the note is for, its cap, and the
|
|
28
|
-
* replacement/clear semantics. */
|
|
29
|
-
export const ROLE_NOTES_DESCRIPTION = `Replace this role's notebook: the short note the role's own earlier ticks left for the ` +
|
|
30
|
-
`next tick (where things live, what was ruled out, what to look at next; at most ` +
|
|
31
|
-
`${ROLE_NOTES_MAX_BYTES} bytes). Pass the full replacement text; empty text clears it.`;
|
|
32
16
|
/** Validate note text against the byte cap. Returns null when the text is acceptable, or an
|
|
33
17
|
* error naming the byte count and the fix — the same "shorten it to at most" wording
|
|
34
18
|
* text/text.ts's tooLongMessage gives every other bounded text, so the model that overran
|
|
@@ -47,19 +31,19 @@ export function validateRoleNote(text) {
|
|
|
47
31
|
export function writeRoleNote(notesPath, text) {
|
|
48
32
|
writeTextAtomic(notesPath, text);
|
|
49
33
|
}
|
|
50
|
-
/** Validate and write one
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* same no-write-on-failure behavior. */
|
|
34
|
+
/** Validate and write one note's text, returning the success message surfaces show. `text` is
|
|
35
|
+
* `unknown` because a caller may hand it a malformed value (a malformed heredoc, a stray
|
|
36
|
+
* argument) without having enforced the string contract: coercing a malformed `text` to ""
|
|
37
|
+
* would clear the notebook, the destructive reading of a typo the writer never sees, so a bad
|
|
38
|
+
* value throws instead and the caller renders it as an error. Shared so every surface
|
|
39
|
+
* rejects the same inputs with the same wording and the same no-write-on-failure behavior. */
|
|
56
40
|
export function saveRoleNote(notesPath, text) {
|
|
57
41
|
if (typeof text !== "string") {
|
|
58
|
-
throw new Error(`
|
|
42
|
+
throw new Error(`a note must be a string, not ${typeof text}${text === undefined ? "" : gotSuffix(text)} — pass the full replacement note, or "" to clear it`);
|
|
59
43
|
}
|
|
60
44
|
const error = validateRoleNote(text);
|
|
61
45
|
if (error)
|
|
62
46
|
throw new Error(error);
|
|
63
47
|
writeRoleNote(notesPath, text);
|
|
64
|
-
return text === "" ? "
|
|
48
|
+
return text === "" ? "notes cleared" : `notes saved (${Buffer.byteLength(text, "utf8")} bytes)`;
|
|
65
49
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// vocabulary: plain
|
|
1
2
|
/** The backend-neutral agent runner (plans/agent-backends.md \"The seam\"): everything a run
|
|
2
3
|
* needs regardless of which agent it drives — the spawn, the `tumwater_run` raw-log marker,
|
|
3
4
|
* the watchdog wiring, abort, the group kill and marker sweep, stderr capture, and result
|
|
@@ -20,6 +21,11 @@ import { DEFAULT_AGENT, agentBackend, registerAgentBackend } from "./agent-backe
|
|
|
20
21
|
import { recordSession } from "./session-ledger.js";
|
|
21
22
|
import { piBackend } from "../pi/pi-backend.js";
|
|
22
23
|
import { claudeBackend } from "./claude/claude-backend.js";
|
|
24
|
+
import { opencodeBackend } from "./opencode/opencode-backend.js";
|
|
25
|
+
import { seamImageInput } from "./image-input.js";
|
|
26
|
+
import { ensureNotesShim } from "./notes-shim.js";
|
|
27
|
+
import { nearestTumwaterRoot } from "../paths.js";
|
|
28
|
+
import { piModelsPath, readPiProviders } from "../pi/pi-models.js";
|
|
23
29
|
/** How many trailing stderr characters are carried into the next chunk's warning match, so a
|
|
24
30
|
* warning split across two `data` events is still seen. The warnings are short lines, so a few
|
|
25
31
|
* hundred characters always cover a straddle. */
|
|
@@ -35,6 +41,7 @@ export const MAX_NATIVE_LINE_CHARS = 16 * 1024 * 1024;
|
|
|
35
41
|
// explicit and lets a test register a fake under its own id.
|
|
36
42
|
registerAgentBackend(piBackend);
|
|
37
43
|
registerAgentBackend(claudeBackend);
|
|
44
|
+
registerAgentBackend(opencodeBackend);
|
|
38
45
|
/** Prefix of PiRunResult.errorMessage when the agent process never started (no session file
|
|
39
46
|
* is created for such a run). The resolved binary follows it, so a configured agentBin
|
|
40
47
|
* reads "failed to spawn <bin>" rather than blaming an ambient "pi". */
|
|
@@ -42,7 +49,7 @@ const SPAWN_ERROR_PREFIX = "failed to spawn";
|
|
|
42
49
|
/** The spawn-error message for a resolved binary: the default source keeps today's text
|
|
43
50
|
* ("failed to spawn pi: …"); a configured source names the binary and where it came from —
|
|
44
51
|
* the backend's own env var and config key (agentBinConfigKey) — so a wrong
|
|
45
|
-
* `
|
|
52
|
+
* `backends.claude.bin` does not read as pi's legacy `agentBin`. */
|
|
46
53
|
function spawnErrorMessage(resolved, errMessage, backend) {
|
|
47
54
|
const source = resolved.source === "default"
|
|
48
55
|
? ""
|
|
@@ -88,12 +95,52 @@ export function runAgent(opts) {
|
|
|
88
95
|
marker.label = opts.label;
|
|
89
96
|
rawLog.stream.write(JSON.stringify(marker) + "\n");
|
|
90
97
|
}
|
|
91
|
-
|
|
98
|
+
// The first blind image read an authoring run made (plans/image-input.md "Detecting a
|
|
99
|
+
// blind read"): the path, plus an indirection to the child so the parser's parse-time
|
|
100
|
+
// callback (which fires during feed, after spawn) can kill it. Unset for a gate run,
|
|
101
|
+
// which only records the read. Declared before the parser so its onBlindRead closure
|
|
102
|
+
// (which assigns them at parse time, after spawn) can reference them.
|
|
103
|
+
let blindRead;
|
|
104
|
+
let terminateRun;
|
|
105
|
+
const parser = new PiStreamParser(opts.onToolCallStart, {
|
|
106
|
+
// The seam's effective image input (src/agent/image-input.ts): a text-only seam makes
|
|
107
|
+
// every image read blind even when pi adds no note — a claim its doctor probe proved
|
|
108
|
+
// false — which the parser checks against its own note rule.
|
|
109
|
+
imageInput: seamImageInput(opts.config, readPiProviders(piModelsPath())),
|
|
110
|
+
onBlindRead: (path) => {
|
|
111
|
+
// Kill an authoring run at once, before the agent goes on working from a picture it
|
|
112
|
+
// never saw (terminateChild is what the watchdogs use). Gate runs (reviewer,
|
|
113
|
+
// resolver) only record the read and keep going.
|
|
114
|
+
if (opts.kind === "author") {
|
|
115
|
+
blindRead = path;
|
|
116
|
+
terminateRun?.();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
92
120
|
// The run's cross-group attribution mark, minted before the spawn so the environment can
|
|
93
121
|
// carry it and the exit sweep can name it.
|
|
94
122
|
const runMarker = makeRunMarker();
|
|
95
123
|
const resolved = backend.resolveBin(opts.config);
|
|
96
|
-
|
|
124
|
+
// Install the notes shim under the team root's `.tumwater/bin` so every backend's run finds
|
|
125
|
+
// `tumwater-notes` first on PATH (notes-shim.ts). A failure to write it logs one warning and
|
|
126
|
+
// the run goes on without it — notes would then be unwritable by the command, but the run
|
|
127
|
+
// itself is not blocked.
|
|
128
|
+
let binDir;
|
|
129
|
+
try {
|
|
130
|
+
// Install the shim only under a real team root — the nearest ancestor holding a
|
|
131
|
+
// `.tumwater`. A session dir with no such ancestor (a lab script, a smoke run, a
|
|
132
|
+
// prompt-lab replay) must never mint one: nearestTumwaterRoot is undefined then, and the
|
|
133
|
+
// run goes on without the shim, exactly as if the install had failed — writing
|
|
134
|
+
// `<fallback>/.tumwater` from a run created a phantom harness root in a shared directory
|
|
135
|
+
// and broke every other suite on the host (BUGS.md 2026-10-18).
|
|
136
|
+
const root = nearestTumwaterRoot(opts.sessionDir);
|
|
137
|
+
if (root !== undefined)
|
|
138
|
+
binDir = ensureNotesShim(root);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
opts.onAgentWarning?.("could not install the tumwater-notes shim; this run will not find tumwater-notes on PATH");
|
|
142
|
+
}
|
|
143
|
+
const runSpec = {
|
|
97
144
|
cwd: opts.cwd,
|
|
98
145
|
prompt: opts.prompt,
|
|
99
146
|
config: opts.config,
|
|
@@ -104,8 +151,10 @@ export function runAgent(opts) {
|
|
|
104
151
|
agentBin: resolved.bin,
|
|
105
152
|
runMarker,
|
|
106
153
|
notesPath: opts.notesPath,
|
|
154
|
+
...(binDir !== undefined ? { binDir } : {}),
|
|
107
155
|
...(opts.maxBudgetUsd !== undefined ? { maxBudgetUsd: opts.maxBudgetUsd } : {}),
|
|
108
|
-
}
|
|
156
|
+
};
|
|
157
|
+
const invocation = backend.invocation(runSpec);
|
|
109
158
|
// Record the run in the session ledger before its spawn, so a later resume can pick the
|
|
110
159
|
// newest session of its own kind instead of whatever file is newest in the dir. The id is
|
|
111
160
|
// the backend's own when it mints one ahead of the run (claude's UUID, or the resumed
|
|
@@ -147,8 +196,8 @@ export function runAgent(opts) {
|
|
|
147
196
|
const runTmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-run-"));
|
|
148
197
|
const child = spawn(resolved.bin, invocation.args, {
|
|
149
198
|
cwd: opts.cwd,
|
|
150
|
-
//
|
|
151
|
-
//
|
|
199
|
+
// A backend that feeds the prompt on stdin (every registered one) gets a pipe the runner
|
|
200
|
+
// writes and ends below; one that returns no stdin keeps stdio[0] "ignore".
|
|
152
201
|
stdio: [invocation.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
|
|
153
202
|
// pi sets its process title at startup, and so does every npm its tool calls run: on
|
|
154
203
|
// macOS each would register with LaunchServices and leak a launchservicesd port.
|
|
@@ -168,20 +217,23 @@ export function runAgent(opts) {
|
|
|
168
217
|
// an unknown flag, an unresumable session, or a not-logged-in exit — leaves this write
|
|
169
218
|
// nothing to feed. Once the prompt exceeds the OS pipe buffer the write then fails EPIPE,
|
|
170
219
|
// and an unhandled 'error' event on the stdin stream is thrown from the event loop and
|
|
171
|
-
// takes the whole process (the whole
|
|
220
|
+
// takes the whole process (the whole team) down. The child's own exit status and stderr
|
|
172
221
|
// already report why the run died, so swallowing the failed write adds no silence the
|
|
173
222
|
// caller needs to break; git.ts's patchId carries the same guard for a patch-id that
|
|
174
223
|
// dies before reading its input.
|
|
175
224
|
child.stdin?.on("error", () => { });
|
|
176
225
|
child.stdin?.end(invocation.stdin);
|
|
177
226
|
}
|
|
227
|
+
// Armed now that the child exists, so the parser's parse-time onBlindRead (fires during
|
|
228
|
+
// feed, always after spawn) can reach it.
|
|
229
|
+
terminateRun = () => terminateChild(child);
|
|
178
230
|
// The run's two watchdog clocks (src/pi/pi-watchdogs.ts): the tick deadline and the quiet
|
|
179
231
|
// watchdog (quiet-kill plus the stalled-tool-call warning). The flags their timers set
|
|
180
232
|
// (timedOut/timedOutProgressing/quietKilled/sawOutput) read live off the returned handle.
|
|
181
233
|
const wd = startPiWatchdogs({
|
|
182
234
|
progressCount: () => parser.progressCount,
|
|
183
235
|
openToolCalls: () => parser.openToolCalls,
|
|
184
|
-
tickTimeoutMs: opts.config.
|
|
236
|
+
tickTimeoutMs: opts.config.runTimeoutSeconds * 1000,
|
|
185
237
|
quietMs: opts.config.quietTimeoutSeconds * 1000,
|
|
186
238
|
stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
|
|
187
239
|
// The run's backend id: claude never sends content updates, so the watchdog judges its
|
|
@@ -272,6 +324,13 @@ export function runAgent(opts) {
|
|
|
272
324
|
if (settled)
|
|
273
325
|
return;
|
|
274
326
|
settled = true;
|
|
327
|
+
// A blind image read (plans/image-input.md "Detecting a blind read"): surface it to the
|
|
328
|
+
// caller once, at settle, with the action its kind earned — the authoring run was killed
|
|
329
|
+
// ("fail"), a gate run only recorded it ("logged"). The caller logs the image_blind event
|
|
330
|
+
// with its own loop/tick context.
|
|
331
|
+
if (parser.imageBlind) {
|
|
332
|
+
opts.onBlindRead?.(parser.imageBlind.path, opts.kind === "author" ? "fail" : "logged");
|
|
333
|
+
}
|
|
275
334
|
// Best-effort: an unremovable scratch dir (read-only subdir, a straggler still writing) must
|
|
276
335
|
// not throw out of the close handler before the run's result resolves.
|
|
277
336
|
try {
|
|
@@ -395,7 +454,19 @@ export function runAgent(opts) {
|
|
|
395
454
|
feedCanonical(decoder.end(), true);
|
|
396
455
|
for (const canonical of translator.finalize?.() ?? [])
|
|
397
456
|
parser.feed(canonical + "\n", onLine);
|
|
398
|
-
|
|
457
|
+
// A blind image read on an authoring run deliberately killed it (terminateRun above),
|
|
458
|
+
// so its exit is a kill like the watchdogs' — the half-done work is discarded.
|
|
459
|
+
const killed = aborted || wd.timedOut || wd.quietKilled || blindRead !== undefined;
|
|
460
|
+
// The backend's post-run reconciliation (opencode's authoritative session export), only
|
|
461
|
+
// when the run is not already settled (a failed spawn settles in the 'error' handler
|
|
462
|
+
// before 'close') and was not killed — a killed run's session is half-done and no export
|
|
463
|
+
// should spend its 10 s on it. Its lines are written and parsed like streamed ones, so
|
|
464
|
+
// they reach the raw log and the parser's authoritative fold (run_cost/run_usage).
|
|
465
|
+
if (!settled && !killed) {
|
|
466
|
+
for (const canonical of backend.afterRun?.(runSpec, invocation, translator) ?? []) {
|
|
467
|
+
parser.feed(canonical + "\n", onLine);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
399
470
|
const failed = killed ||
|
|
400
471
|
parser.stopReason === "error" ||
|
|
401
472
|
(code !== 0 && !parser.finalText.trim());
|
|
@@ -408,21 +479,25 @@ export function runAgent(opts) {
|
|
|
408
479
|
cls.transientPiCrash !== true &&
|
|
409
480
|
stderrBackend === undefined &&
|
|
410
481
|
(parser.configError || cls.configError === true);
|
|
482
|
+
// The blind-read failure names both the file and the model (plans/image-input.md
|
|
483
|
+
// "Failing the tick"): take it over any generic exit text so lastError is actionable.
|
|
484
|
+
const errorMessage = blindRead !== undefined
|
|
485
|
+
? `read ${blindRead}, but ${opts.config.model ?? "the model"} cannot read images`
|
|
486
|
+
: aborted
|
|
487
|
+
? "stopped by tumwater shutdown"
|
|
488
|
+
: wd.quietKilled
|
|
489
|
+
? `killed as hung: no agent progress for over ${opts.config.quietTimeoutSeconds}s`
|
|
490
|
+
: wd.timedOut
|
|
491
|
+
? wd.timedOutProgressing
|
|
492
|
+
? `timed out after ${wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
|
|
493
|
+
: `timed out after ${wd.timeoutBudgetMs / 1000}s`
|
|
494
|
+
: (parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `the agent exited ${code}` : undefined));
|
|
411
495
|
finish(resultFromParser({
|
|
412
496
|
ok: !failed,
|
|
413
497
|
transientPiCrash: cls.transientPiCrash === true,
|
|
414
498
|
transientBackend: parser.transientBackend || cls.transientBackend === true,
|
|
415
499
|
backendKind: parser.backendFailureKind ?? stderrBackend,
|
|
416
500
|
configError,
|
|
417
|
-
errorMessage: aborted
|
|
418
|
-
? "aborted by harness shutdown"
|
|
419
|
-
: wd.quietKilled
|
|
420
|
-
? `killed as hung: no pi progress for over ${opts.config.quietTimeoutSeconds}s`
|
|
421
|
-
: wd.timedOut
|
|
422
|
-
? wd.timedOutProgressing
|
|
423
|
-
? `timed out after ${wd.timeoutBudgetMs / 1000}s while still making progress — session and worktree edits preserved for resume`
|
|
424
|
-
: `timed out after ${wd.timeoutBudgetMs / 1000}s`
|
|
425
|
-
: (parser.errorMessage ?? (failed ? stderr.trim().slice(-500) || `pi exited ${code}` : undefined)),
|
|
426
501
|
// Kept distinct on purpose: a hung tool call leaves its session and worktree edits
|
|
427
502
|
// intact, so the loop resumes them (quiet_killed) instead of discarding them as an
|
|
428
503
|
// unfulfilled timeout does. A deadline that fired on a run still making progress
|
|
@@ -430,6 +505,8 @@ export function runAgent(opts) {
|
|
|
430
505
|
timedOut: wd.timedOut,
|
|
431
506
|
timedOutProgressing: wd.timedOut && wd.timedOutProgressing,
|
|
432
507
|
quietKilled: wd.quietKilled,
|
|
508
|
+
imageBlind: parser.imageBlind,
|
|
509
|
+
errorMessage,
|
|
433
510
|
}));
|
|
434
511
|
});
|
|
435
512
|
});
|