tumwater 0.1.3 → 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 +63 -28
- 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-eligibility.js +332 -43
- package/dist/src/backlog/backlog-md.js +79 -17
- 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 +178 -441
- 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 +32 -30
- package/dist/src/{brief-edit.js → brief/brief-edit.js} +12 -11
- package/dist/src/{brief.js → brief/brief.js} +33 -8
- 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 +13 -11
- package/dist/src/change/change-render.js +11 -10
- package/dist/src/cli/brief-command.js +9 -7
- package/dist/src/cli/cli-args.js +25 -5
- package/dist/src/cli/cli-command-args.js +43 -10
- package/dist/src/cli/cli-editor.js +13 -1
- package/dist/src/cli/cli-flag-specs.js +31 -6
- package/dist/src/cli/cli-marker-commands.js +13 -10
- package/dist/src/cli/cli-query-commands.js +439 -56
- package/dist/src/cli/cli-repo-gate.js +1 -1
- package/dist/src/cli/cli-run.js +124 -68
- package/dist/src/cli/completion.js +70 -12
- package/dist/src/cli/config-commands.js +14 -11
- package/dist/src/cli/help.js +264 -124
- 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 +13 -224
- package/dist/src/cli.js +78 -36
- 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 +107 -325
- 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 +494 -68
- package/dist/src/doctor/doctor-checks.js +509 -58
- package/dist/src/doctor/doctor-holds.js +139 -78
- 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 +2 -3
- package/dist/src/doctor/doctor-orphans.js +10 -9
- package/dist/src/doctor/doctor-render.js +36 -9
- package/dist/src/doctor/doctor.js +76 -25
- package/dist/src/doctor/image-probe.js +244 -0
- package/dist/src/doctor/probe.js +64 -0
- package/dist/src/events/event-format.js +219 -106
- 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 +80 -30
- 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 +6 -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 +79 -59
- 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 -716
- package/dist/src/gui/gui-endpoint-common.js +49 -0
- package/dist/src/gui/gui-endpoints.js +105 -54
- 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 +25 -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 +179 -104
- package/dist/src/history/history-render.js +76 -0
- package/dist/src/history/history.js +29 -70
- package/dist/src/inbox/inbox-attachments.js +66 -10
- 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 +114 -73
- 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 +104 -50
- package/dist/src/loop/loop-recovery.js +15 -15
- package/dist/src/loop/loop-state.js +130 -13
- package/dist/src/loop/loop-tick.js +190 -88
- package/dist/src/loop/loop.js +68 -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 -20
- 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 +113 -45
- package/dist/src/operator/operator-intent.js +74 -53
- package/dist/src/operator/operator-requests.js +52 -52
- package/dist/src/operator/retire.js +47 -28
- package/dist/src/operator/revert.js +71 -0
- package/dist/src/orchestrator/orchestrator-launch.js +74 -14
- package/dist/src/orchestrator/orchestrator-scheduling.js +74 -43
- package/dist/src/orchestrator/orchestrator.js +149 -96
- 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 +124 -38
- 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 +4 -3
- 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 +36 -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 +29 -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 +259 -254
- package/dist/src/report/report-fold.js +353 -0
- package/dist/src/report/report-render.js +235 -44
- package/dist/src/request-timeouts.js +1 -1
- package/dist/src/review/known-flakes.js +34 -14
- package/dist/src/review/protected-edits.js +4 -3
- 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 +55 -34
- 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 +109 -131
- 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 +16 -14
- package/dist/src/scheduling/backoff.js +29 -19
- package/dist/src/scheduling/claims.js +15 -8
- package/dist/src/scheduling/once-round.js +11 -11
- package/dist/src/scheduling/quiet-hours.js +25 -32
- package/dist/src/scheduling/scheduling.js +28 -25
- 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 +72 -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 +50 -39
- 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 +84 -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} +39 -38
- 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 +142 -25
- 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 +23 -22
- package/dist/src/tick/tick-detail.js +37 -24
- 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 +67 -53
- 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 +75 -10
- package/dist/src/ui/badges.js +113 -69
- package/dist/src/ui/gui/gui-client-boot.js +202 -29
- 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 +94 -37
- package/dist/src/ui/gui/gui-client-labels.js +24 -0
- package/dist/src/ui/gui/gui-client-loops.js +90 -47
- package/dist/src/ui/gui/gui-client-markdown.js +1 -0
- package/dist/src/ui/gui/gui-client-model.js +60 -43
- package/dist/src/ui/gui/gui-client-operator.js +73 -43
- package/dist/src/ui/gui/gui-client-pending.js +34 -31
- package/dist/src/ui/gui/gui-client-report.js +330 -47
- package/dist/src/ui/gui/gui-client-settings.js +126 -18
- package/dist/src/ui/gui/gui-client-sound.js +2 -1
- package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +190 -74
- package/dist/src/ui/gui/gui-client.js +26 -20
- package/dist/src/ui/gui/gui-page.js +18 -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 +176 -102
- package/dist/src/ui/status-payload.js +114 -36
- package/dist/src/ui/status-render.js +121 -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 +9 -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 +61 -57
- package/dist/src/ui/tui/tui-history.js +12 -7
- package/dist/src/ui/tui/tui-input.js +44 -50
- package/dist/src/ui/tui/tui-keymap.js +1 -0
- package/dist/src/ui/tui/tui-keys.js +87 -1821
- 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 +52 -28
- package/dist/src/ui/tui/tui-panes.js +294 -0
- package/dist/src/ui/tui/tui.js +44 -26
- package/dist/src/verdict/fix-claim.js +56 -33
- package/dist/src/verdict/no-change.js +19 -0
- package/dist/src/verdict/refusal.js +43 -11
- 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 -56
- package/dist/src/landing/landing-slot.js +0 -256
- 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
package/dist/src/pi/pi-stream.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { extractRefusal, hasVerdictLine, isNegatedRefusal, isNothingToDo } from "../verdict/reply-contract.js";
|
|
2
|
-
import { applyToolExecutionEvent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
|
|
2
|
+
import { applyToolExecutionEvent, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
|
|
3
3
|
import { describeToolCall } from "../text/phrases.js";
|
|
4
|
-
import { objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../files/json-object.js";
|
|
4
|
+
import { objectOrEmpty, nonNegativeNumber, parseJsonObject, isJsonObject } from "../files/json-object.js";
|
|
5
|
+
import { backendKind, isBackendFailureKind, isPermanentConfigError, TRANSIENT_BACKEND } from "./pi-failure.js";
|
|
5
6
|
/** One wire number from the model server's usage block, or 0 when unusable. Only a finite,
|
|
6
7
|
* non-negative number passes: a string (some OpenAI-compatible servers serialize usage
|
|
7
8
|
* numbers as strings) would turn the `+=` accumulators into string concatenation and then
|
|
@@ -12,6 +13,29 @@ import { objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../files/json
|
|
|
12
13
|
function usageNumber(value) {
|
|
13
14
|
return nonNegativeNumber(value, 0);
|
|
14
15
|
}
|
|
16
|
+
/** The text prefix of a pi `read` result that carries an image (plans/image-input.md
|
|
17
|
+
* "Detecting a blind read"): such a read's text block begins `Read image file [<path>…]`, and
|
|
18
|
+
* pi's non-vision note and its processing-failure notes sit inside that block. A text-only
|
|
19
|
+
* read (a code file) is not phrased this way. */
|
|
20
|
+
const IMAGE_READ_PREFIX = "Read image file [";
|
|
21
|
+
/** pi's note when the current model does not support images — the blind-read signal: the image
|
|
22
|
+
* is stripped from the request and the agent would answer about a picture it never saw. */
|
|
23
|
+
const BLIND_IMAGE_NOTE = "[Current model does not support images";
|
|
24
|
+
/** The `path` a read tool call's raw args name, or undefined when they carry none — the read
|
|
25
|
+
* tool opens a file by `path` (with optional `start`/`end` offsets), so the blind read blames
|
|
26
|
+
* the right file. A torn or foreign args object reads as no path (never throws). */
|
|
27
|
+
function readPathOf(args) {
|
|
28
|
+
if (!isJsonObject(args))
|
|
29
|
+
return undefined;
|
|
30
|
+
return typeof args.path === "string" && args.path ? args.path : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The text blocks of one tool-execution end's `result` (the same normalization
|
|
33
|
+
* messageContent applies to a message), or [] when the result is not an object. */
|
|
34
|
+
function resultContent(result) {
|
|
35
|
+
if (!isJsonObject(result))
|
|
36
|
+
return [];
|
|
37
|
+
return messageContent(result);
|
|
38
|
+
}
|
|
15
39
|
/** The content blocks of one pi message, normalized: a `content` that is not an array (torn
|
|
16
40
|
* or foreign wire JSON) reads as no blocks, and a non-object entry reads as an empty block,
|
|
17
41
|
* so the `.filter`/`.some` chains below never throw on a shape the message interface does not
|
|
@@ -41,7 +65,7 @@ const CONTEXT_ERROR = /context (size|length|window)?\s*(has been |was )?exceeded
|
|
|
41
65
|
* Module-private: only PiStreamParser.feedLine below matches against it. */
|
|
42
66
|
const TRANSIENT_SERVER_TIMEOUT = /predict stream timed out/i;
|
|
43
67
|
/** The provider rejecting the request with HTTP 429 — rate limiting. This is the world saying
|
|
44
|
-
* "later", not the session failing: the
|
|
68
|
+
* "later", not the session failing: the team's single largest error source (BUGS.md 2026-09-21)
|
|
45
69
|
* and by definition retryable. Matched against every error text feedLine sees, since pi renders
|
|
46
70
|
* the provider's status and message variously ("429 \"Rate limit exceeded\"", OpenAI's
|
|
47
71
|
* "Too Many Requests"). Retry-After, when the provider echoes it into the error text, is
|
|
@@ -51,90 +75,10 @@ const TRANSIENT_RATE_LIMIT = /\b429\b|too many requests|rate limit/i;
|
|
|
51
75
|
* retrying. Tolerates the spellings error text actually uses ("Retry-After: 30",
|
|
52
76
|
* "retry after 30s"). Module-private: only PiStreamParser.feedLine matches it. */
|
|
53
77
|
const RETRY_AFTER = /retry[- ]after:?\s*(\d{1,4})/i;
|
|
54
|
-
/** The provider rejecting the request with a permanent 4xx client error — a bad model id,
|
|
55
|
-
* credential, or request shape (the observed `400 … model_not_supported` on a mistyped
|
|
56
|
-
* provider suffix, BUGS.md 2026-10-06). Unlike a 429 or 408/5xx it can never succeed until the
|
|
57
|
-
* configuration changes, so retrying it at suite speed loops forever (a reviewer failure is
|
|
58
|
-
* strike-free by design). 408 (request timeout) and 429 are rate/timeouts, not config errors,
|
|
59
|
-
* and are matched by the transient patterns above; this pattern covers 400/401/403/404 and the
|
|
60
|
-
* provider's own permanent-error spellings. Exported so the landing path can hold a pin
|
|
61
|
-
* instead of re-queuing it. */
|
|
62
|
-
const PERMANENT_CONFIG_ERROR = /\b(?:400|401|403|404)\b|model_not_supported|invalid_request_error/i;
|
|
63
|
-
/** True when an error text names a permanent provider configuration error (see
|
|
64
|
-
* PERMANENT_CONFIG_ERROR). Pure, exported so the landing path and the stderr path classify
|
|
65
|
-
* with one rule. */
|
|
66
|
-
export function isPermanentConfigError(text) {
|
|
67
|
-
return PERMANENT_CONFIG_ERROR.test(text);
|
|
68
|
-
}
|
|
69
|
-
/** undici's spelling of a severed HTTP stream: the provider (or its own idle timeout) cuts
|
|
70
|
-
* the request while pi is still reading it, and undici's fetch rejects with the bare word
|
|
71
|
-
* "terminated" — the 2026-08-22 idle-timeout diagnosis. Anchored to the END of the error text
|
|
72
|
-
* on purpose: the real message is the bare word, optionally behind a prefix and closing
|
|
73
|
-
* punctuation, while an unrelated error that merely contains the word ("worker terminated
|
|
74
|
-
* with exit code 1") must not read as a backend failure. Module-private: matchBackendFailure
|
|
75
|
-
* below, the parser, and backendKind all share the one instance. */
|
|
76
|
-
const STREAM_SEVERED = /(^|[^a-z])terminated[.!\s]*$/i;
|
|
77
|
-
/** The connection-class backend-failure phrases: fetch-level "Connection error.", the
|
|
78
|
-
* refusals and resets Node surfaces. One home shared by the TRANSIENT_BACKEND gate and the
|
|
79
|
-
* backendKind classifier, so a spelling added to one cannot go missing from the other. */
|
|
80
|
-
const CONNECTION_ERROR = /connection error|connection refused|connection reset|econn(refused|reset)/i;
|
|
81
|
-
/** The timeout-class backend-failure phrase (OpenAI-style "Request timed out"). Shared by the
|
|
82
|
-
* TRANSIENT_BACKEND gate and the backendKind classifier. */
|
|
83
|
-
const TIMEOUT_ERROR = /request timed out/i;
|
|
84
|
-
/** The server-class backend-failure phrases: 5xx status texts, oMLX's model-server pause, and
|
|
85
|
-
* a provider pausing itself fleet-wide with a 503 whose body carries prose rather than any
|
|
86
|
-
* status phrase ("Provider '<name>' is currently failing for model '<id>' and has been paused
|
|
87
|
-
* by the provider"). Shared by the TRANSIENT_BACKEND gate and the backendKind classifier. */
|
|
88
|
-
const SERVER_ERROR = /internal server error|bad gateway|service unavailable|gateway timeout|currently failing for model|paused by the provider/i;
|
|
89
|
-
/** The model-load spelling (oMLX's "Failed to load model"): its backendKind is the
|
|
90
|
-
* "model-load" default, so only the gate names the pattern. */
|
|
91
|
-
const MODEL_LOAD_ERROR = /failed to load model/i;
|
|
92
|
-
/** The backend-failure error texts pi actually surfaces, matched against every error text
|
|
93
|
-
* feedLine sees — the same shapes as the rate-limit regex's various renderings.
|
|
94
|
-
* Deliberately does NOT match the 429 texts: feedLine checks TRANSIENT_RATE_LIMIT first and
|
|
95
|
-
* only falls through here, so a rate limit stays a rate limit (with its Retry-After hint)
|
|
96
|
-
* and never counts as a backend failure. Composed from the per-kind patterns above so each
|
|
97
|
-
* kind's spellings — including the stream-severed one (STREAM_SEVERED) — live in exactly one
|
|
98
|
-
* place. Only matchBackendFailure and PiStreamParser.feedLine match it; backendKind below
|
|
99
|
-
* classifies which phrase matched. */
|
|
100
|
-
const TRANSIENT_BACKEND = new RegExp(`${CONNECTION_ERROR.source}|${TIMEOUT_ERROR.source}|${SERVER_ERROR.source}|${MODEL_LOAD_ERROR.source}|${STREAM_SEVERED.source}`, "i");
|
|
101
|
-
/** Which backend-failure kind an error text belongs to — pure, exported so the parser's
|
|
102
|
-
* classification is unit-testable without a stream (pi-parser.test.ts feeds the texts
|
|
103
|
-
* directly). Each check is the same per-kind pattern the TRANSIENT_BACKEND gate composes, so
|
|
104
|
-
* the gate and the classifier cannot disagree; the order matches that alternation. */
|
|
105
|
-
export function backendKind(text) {
|
|
106
|
-
if (CONNECTION_ERROR.test(text))
|
|
107
|
-
return "connection";
|
|
108
|
-
if (TIMEOUT_ERROR.test(text))
|
|
109
|
-
return "timeout";
|
|
110
|
-
if (SERVER_ERROR.test(text))
|
|
111
|
-
return "server";
|
|
112
|
-
if (STREAM_SEVERED.test(text))
|
|
113
|
-
return "stream-severed";
|
|
114
|
-
return "model-load";
|
|
115
|
-
}
|
|
116
|
-
/** The backend-failure kind `text` names, or undefined when it names none — the stderr
|
|
117
|
-
* classifier (src/pi/pi-backend.ts's classifyExit) needs "no match" distinct from
|
|
118
|
-
* backendKind's "model-load" default. Gated by TRANSIENT_BACKEND, so a text the event path
|
|
119
|
-
* ignores is ignored on stderr too. Pure; exported for pi-backend.ts's classifyExit. */
|
|
120
|
-
export function matchBackendFailure(text) {
|
|
121
|
-
return TRANSIENT_BACKEND.test(text) ? backendKind(text) : undefined;
|
|
122
|
-
}
|
|
123
|
-
/** The valid `backendKind` values, for validating a translator-supplied string at the wire
|
|
124
|
-
* boundary (the same untrusted-JSON policy every other field read here applies). */
|
|
125
|
-
const BACKEND_FAILURE_KINDS = [
|
|
126
|
-
"connection",
|
|
127
|
-
"timeout",
|
|
128
|
-
"server",
|
|
129
|
-
"model-load",
|
|
130
|
-
"stream-severed",
|
|
131
|
-
];
|
|
132
|
-
function isBackendFailureKind(value) {
|
|
133
|
-
return typeof value === "string" && BACKEND_FAILURE_KINDS.includes(value);
|
|
134
|
-
}
|
|
135
78
|
/** Accumulates pi's JSON event stream into a PiRunResult. Exported for tests. */
|
|
136
79
|
export class PiStreamParser {
|
|
137
80
|
onToolCallStart;
|
|
81
|
+
image;
|
|
138
82
|
finalText = "";
|
|
139
83
|
/** True once any assistant message contains the nothing-to-do sentinel, so a
|
|
140
84
|
* declaration in an intermediate turn survives later messages overwriting finalText. */
|
|
@@ -190,9 +134,10 @@ export class PiStreamParser {
|
|
|
190
134
|
* the loop's transient retry covers it, waiting out retryAfterSeconds when present. */
|
|
191
135
|
transientRateLimit = false;
|
|
192
136
|
/** True when any event reports a provider-wide failure that is not rate limiting — the
|
|
193
|
-
* connection down, a 5xx, the model failing to load (TRANSIENT_BACKEND
|
|
194
|
-
* the same sense: the session is healthy and a later attempt succeeds. The
|
|
195
|
-
* (src/
|
|
137
|
+
* connection down, a 5xx, the model failing to load (TRANSIENT_BACKEND, in pi-failure.ts).
|
|
138
|
+
* Transient in the same sense: the session is healthy and a later attempt succeeds. The
|
|
139
|
+
* team-wide hold (src/team/team-hold.ts) consumes it beside the 429 flag, grouped by
|
|
140
|
+
* backendFailureKind. */
|
|
196
141
|
transientBackend = false;
|
|
197
142
|
/** Which kind of backend failure the run saw (backendKind's classification of the same
|
|
198
143
|
* text that set transientBackend) — carried so the hold's storm test can group by kind
|
|
@@ -203,7 +148,7 @@ export class PiStreamParser {
|
|
|
203
148
|
retryAfterSeconds;
|
|
204
149
|
/** True when any error text names a permanent provider configuration error (a 4xx other
|
|
205
150
|
* than 408/429, or the provider's model_not_supported/invalid_request_error spelling): the
|
|
206
|
-
* request can never succeed until the config changes, so the
|
|
151
|
+
* request can never succeed until the config changes, so the merge path holds the pin
|
|
207
152
|
* rather than re-queuing it at suite speed (BUGS.md 2026-10-06). Matched only when the text
|
|
208
153
|
* did not match rate-limit or a transient backend pattern first. */
|
|
209
154
|
configError = false;
|
|
@@ -223,6 +168,12 @@ export class PiStreamParser {
|
|
|
223
168
|
rateWindows = [];
|
|
224
169
|
/** True when any rate-limit reading reported the account spending into overage. */
|
|
225
170
|
usedOverage = false;
|
|
171
|
+
/** The one `read` tool call this run made on an image the model cannot see (plans/
|
|
172
|
+
* image-input.md "Detecting a blind read"): the image path and the seam's effective image
|
|
173
|
+
* input are reported at once, so the runner can kill an authoring run instead of letting
|
|
174
|
+
* the agent answer about a picture it never saw. The FIRST blind read wins — later ones
|
|
175
|
+
* add nothing. */
|
|
176
|
+
imageBlind;
|
|
226
177
|
/** True when the run's LAST assistant message carried no text and no tool call
|
|
227
178
|
* (thinking-only or empty). A compliant finish always ends with a text block (the
|
|
228
179
|
* SUMMARY/sentinel line), so this signals a generation cut off mid-stream — typically
|
|
@@ -242,10 +193,9 @@ export class PiStreamParser {
|
|
|
242
193
|
/** True when pi auto-compacted the session during (or at the end of) the run. */
|
|
243
194
|
compacted = false;
|
|
244
195
|
/** Incremented for every parsed event that represents real forward progress — message,
|
|
245
|
-
* turn, and tool boundaries, retries, session events
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* deltas and content-free tool_execution_updates alike) must not reset the harness's hang
|
|
196
|
+
* turn, and tool boundaries, retries, session events, and content-bearing updates
|
|
197
|
+
* (message_update deltas and tool_execution_updates alike). A content-free keepalive never
|
|
198
|
+
* counts: a zombie stream's periodic empty updates must not reset the harness's hang
|
|
249
199
|
* watchdog. */
|
|
250
200
|
progressCount = 0;
|
|
251
201
|
/** Tool calls started but not yet ended — pi runs one message's tool calls concurrently by
|
|
@@ -254,13 +204,20 @@ export class PiStreamParser {
|
|
|
254
204
|
* empty-content update right after start, and a content-free keepalive must not mask a hang.
|
|
255
205
|
* Feeds runPi's stall warning; entries clear at tool_execution_end. */
|
|
256
206
|
openToolCalls = [];
|
|
207
|
+
/** Read tool calls started but not yet ended, keyed by pi's toolCallId so concurrent reads
|
|
208
|
+
* attribute their end to the right start (pi runs one message's tool calls concurrently, so
|
|
209
|
+
* several can be open at once and end in completion order — the same rule
|
|
210
|
+
* openToolCalls tracks). The value is the call's `args.path`, captured at start so the
|
|
211
|
+
* result on end has a path to blame when the read was blind. */
|
|
212
|
+
readPaths = new Map();
|
|
257
213
|
buffer = "";
|
|
258
214
|
/** `onToolCallStart` observes every tool call as it starts, with pi's tool name ("" when pi
|
|
259
215
|
* omitted it) and raw args — a hook for a caller that audits what a run did (the review
|
|
260
216
|
* gate's suite-rerun tripwire, src/review/suite-rerun.ts) without the parser storing every
|
|
261
217
|
* call of every run. */
|
|
262
|
-
constructor(onToolCallStart) {
|
|
218
|
+
constructor(onToolCallStart, image = {}) {
|
|
263
219
|
this.onToolCallStart = onToolCallStart;
|
|
220
|
+
this.image = image;
|
|
264
221
|
}
|
|
265
222
|
/** Apply a translator-supplied `tumwaterKind`, trusted before the text regexes. An unknown
|
|
266
223
|
* kind is ignored (the same forward-compatible policy as an unknown event type). */
|
|
@@ -326,6 +283,33 @@ export class PiStreamParser {
|
|
|
326
283
|
return;
|
|
327
284
|
this.retryAfterSeconds = Math.max(0, Math.round(value));
|
|
328
285
|
}
|
|
286
|
+
/** Decide whether one finished `read` call returned an image the model cannot see, and if so
|
|
287
|
+
* record the FIRST such read and fire the `onBlindRead` hook (plans/image-input.md
|
|
288
|
+
* "Detecting a blind read"). An image read is a result whose content holds an `image` block,
|
|
289
|
+
* or a text block starting `Read image file [`; it is blind when that text carries pi's
|
|
290
|
+
* non-vision note, or when the runner told us the seam's effective image input is false —
|
|
291
|
+
* the second arm catches a claim doctor's probe proved false, where pi sends the image and
|
|
292
|
+
* adds no note. pi's processing-failure notes (`[Image omitted: could not be converted…]` /
|
|
293
|
+
* `…resized…`) are NOT blind on a vision-capable model: they name a conversion/file problem,
|
|
294
|
+
* not a capability gap (a non-vision model's read carries its own note, which IS blind). */
|
|
295
|
+
detectBlindRead(path, result) {
|
|
296
|
+
if (this.imageBlind)
|
|
297
|
+
return; // the first blind read is the one that stops the run
|
|
298
|
+
const blocks = resultContent(result);
|
|
299
|
+
const isImageRead = blocks.some((c) => c.type === "image") ||
|
|
300
|
+
blocks.some((c) => typeof c.text === "string" && c.text.startsWith(IMAGE_READ_PREFIX));
|
|
301
|
+
if (!isImageRead)
|
|
302
|
+
return;
|
|
303
|
+
const text = blocks
|
|
304
|
+
.filter((c) => typeof c.text === "string")
|
|
305
|
+
.map((c) => c.text)
|
|
306
|
+
.join("\n");
|
|
307
|
+
const blind = text.includes(BLIND_IMAGE_NOTE) || this.image.imageInput === false;
|
|
308
|
+
if (!blind)
|
|
309
|
+
return;
|
|
310
|
+
this.imageBlind = { path };
|
|
311
|
+
this.image.onBlindRead?.(path);
|
|
312
|
+
}
|
|
329
313
|
feed(chunk, onLine) {
|
|
330
314
|
this.buffer += chunk;
|
|
331
315
|
// Scan with an offset instead of slicing the remainder off after every line: each old
|
|
@@ -350,12 +334,18 @@ export class PiStreamParser {
|
|
|
350
334
|
this.buffer = this.buffer.slice(start);
|
|
351
335
|
}
|
|
352
336
|
feedLine(line) {
|
|
353
|
-
// Streaming deltas are ~72% of log bytes in observed runs
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
|
|
337
|
+
// Streaming deltas are ~72% of log bytes in observed runs. pi's JSON protocol strips the
|
|
338
|
+
// cumulative message snapshot from them, so the delta event is all they carry — and a
|
|
339
|
+
// content-free one (a zombie stream's keepalive, or the pre-strip shape) proves nothing:
|
|
340
|
+
// it must not reset the hang watchdog. A content-bearing delta is real generation, and
|
|
341
|
+
// counting it is what keeps a long thinking block on a slow model from being quiet-killed
|
|
342
|
+
// as hung (BUGS.md 2026-10-17) — the same content rule tool_execution_update gets below.
|
|
343
|
+
// Nothing else in this parser acts on the type, so the line is done either way.
|
|
344
|
+
if (piEventType(line) === "message_update") {
|
|
345
|
+
if (messageUpdateHasContent(parseJsonObject(line)))
|
|
346
|
+
this.progressCount += 1;
|
|
358
347
|
return;
|
|
348
|
+
}
|
|
359
349
|
// pi's stream is one JSON object per line: a torn, non-JSON, or valid-JSON-scalar line is
|
|
360
350
|
// noise, not an event — parseJsonObject reads it as no data (the same policy parseEventLine
|
|
361
351
|
// applies to the harness event log and parsePiEventLine applies to pi's log lines) instead
|
|
@@ -404,10 +394,28 @@ export class PiStreamParser {
|
|
|
404
394
|
// number). A backend that emits none keeps the summed per-message figure (pi).
|
|
405
395
|
if (event.type === "run_cost")
|
|
406
396
|
this.costUsd = usageNumber(event.total);
|
|
407
|
-
// Likewise a run_usage replaces the summed
|
|
408
|
-
// the stream-start snapshot, about 8x low).
|
|
409
|
-
|
|
410
|
-
|
|
397
|
+
// Likewise a run_usage replaces the summed per-message token figures (claude's per-message
|
|
398
|
+
// usage is the stream-start snapshot, about 8x low). OpenCode's export drives a run_usage
|
|
399
|
+
// that carries the run's whole token split — input, output, cacheRead, cacheWrite — and
|
|
400
|
+
// replaces all four summed fields, the way run_cost replaces the cost sum; a stream without
|
|
401
|
+
// one keeps its summed figures. A run_usage bearing only outputTokens (claude) keeps the
|
|
402
|
+
// historical behavior of replacing just the output sum. The split's sum is a cumulative RUN
|
|
403
|
+
// total, not a single request, so it never touches peakContextTokens (a per-request peak
|
|
404
|
+
// the stream keeps computing); the export carries no total and none is invented here.
|
|
405
|
+
if (event.type === "run_usage") {
|
|
406
|
+
if (event.input !== undefined || event.cacheRead !== undefined || event.cacheWrite !== undefined) {
|
|
407
|
+
const input = usageNumber(event.input);
|
|
408
|
+
const cacheRead = usageNumber(event.cacheRead);
|
|
409
|
+
const cacheWrite = usageNumber(event.cacheWrite);
|
|
410
|
+
const output = usageNumber(event.output);
|
|
411
|
+
this.outputTokens = output;
|
|
412
|
+
this.promptTokens = input + cacheRead + cacheWrite;
|
|
413
|
+
this.cacheReadTokens = cacheRead;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
this.outputTokens = usageNumber(event.outputTokens);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
411
419
|
// A canonical rate_limit event carries an account-window reading: collect the newest per
|
|
412
420
|
// type, note any overage, and treat a rejected window as the transient rate limit it is.
|
|
413
421
|
if (event.type === "rate_limit")
|
|
@@ -415,11 +423,10 @@ export class PiStreamParser {
|
|
|
415
423
|
if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
|
|
416
424
|
this.billing = event.billing;
|
|
417
425
|
// Every structured event (turn/tool/message boundaries, retries, session) is real
|
|
418
|
-
// progress —
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
// toolUpdateHasContent applies to the stall clock).
|
|
426
|
+
// progress, and so is a content-bearing update — but a content-free one never is: bash
|
|
427
|
+
// emits an empty tool_execution_update right after start, and a zombie stream's keepalive
|
|
428
|
+
// updates must not reset the hang watchdog any more than a content-free message delta
|
|
429
|
+
// would (message_update deltas are counted above, before this point, by the same rule).
|
|
423
430
|
if (!(event.type === "tool_execution_update" && !toolUpdateHasContent(event.partialResult)))
|
|
424
431
|
this.progressCount += 1;
|
|
425
432
|
if (event.type === "compaction_start")
|
|
@@ -443,6 +450,23 @@ export class PiStreamParser {
|
|
|
443
450
|
describeToolCall(event.toolName ?? "", event.args) || "tool", command);
|
|
444
451
|
if (event.type === "tool_execution_start")
|
|
445
452
|
this.onToolCallStart?.(event.toolName ?? "", event.args);
|
|
453
|
+
// The blind-read detector (plans/image-input.md "Detecting a blind read"): remember
|
|
454
|
+
// each read call's path from its start, and on its end decide whether the image it
|
|
455
|
+
// returned was one the model cannot see — so the runner can kill an authoring run
|
|
456
|
+
// instead of letting the agent answer about a picture it never saw.
|
|
457
|
+
if (event.type === "tool_execution_start" && event.toolName === "read") {
|
|
458
|
+
const p = readPathOf(event.args);
|
|
459
|
+
if (p !== undefined)
|
|
460
|
+
this.readPaths.set(String(event.toolCallId), p);
|
|
461
|
+
}
|
|
462
|
+
else if (event.type === "tool_execution_end") {
|
|
463
|
+
const id = String(event.toolCallId);
|
|
464
|
+
const p = this.readPaths.get(id);
|
|
465
|
+
if (p !== undefined) {
|
|
466
|
+
this.readPaths.delete(id);
|
|
467
|
+
this.detectBlindRead(p, event.result);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
446
470
|
}
|
|
447
471
|
if (event.type !== "message_end" || event.message?.role !== "assistant")
|
|
448
472
|
return;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* handler reads back; run-agent.ts feeds the parser live through the deps accessors, so no
|
|
8
8
|
* notification path is needed when events land. */
|
|
9
9
|
import { bufferedCommandStallMs, commandBuffersOutput } from "./command-shape.js";
|
|
10
|
+
import { streamsToolOutput } from "../agent/backend-traits.js";
|
|
10
11
|
/** Start one pi run's two watchdog clocks: the tick deadline (kills the process tree when it
|
|
11
12
|
* fires, recording whether the run was still progressing) and the quiet/stall interval
|
|
12
13
|
* (quiet-kills a run that stopped making progress and warns on stalled tool calls). Returns
|
|
@@ -79,7 +80,7 @@ export function startPiWatchdogs(deps) {
|
|
|
79
80
|
// only a periodic look at which open tool calls have gone silent.
|
|
80
81
|
// A run that has not yet produced progress is starting, not hung: process creation and
|
|
81
82
|
// model connect are legitimately slow on a loaded machine (a full test suite, a busy
|
|
82
|
-
//
|
|
83
|
+
// team), and charging that startup latency as pi silence quiet-kills runs that never
|
|
83
84
|
// had the chance to speak (BUGS.md 2026-09-23). The one extra quiet window that fix
|
|
84
85
|
// granted a zero-progress run still false-killed under merge-check load: the kill runs
|
|
85
86
|
// on an interval against the wall clock, so the firing check can predate the child's
|
|
@@ -87,7 +88,7 @@ export function startPiWatchdogs(deps) {
|
|
|
87
88
|
// but not yet drained (a firing timer phase precedes the poll phase that delivers
|
|
88
89
|
// stdout, and a suspended machine resumes the same way). The 2026-09-23 fix therefore
|
|
89
90
|
// gave a byte-silent run no quiet kill at all, delegating its bound to the tick timeout
|
|
90
|
-
// — which holds only while
|
|
91
|
+
// — which holds only while runTimeoutSeconds is near the quiet window's scale (BUGS.md
|
|
91
92
|
// 2026-09-29: the live 54000 s config left a wedged model connection 15 hours in a
|
|
92
93
|
// concurrency slot). A zero-byte run now gets a finite bound of its own, independent of
|
|
93
94
|
// the tick timeout: max(two quiet windows, 30 min). Startup latency under healthy load
|
|
@@ -128,7 +129,7 @@ export function startPiWatchdogs(deps) {
|
|
|
128
129
|
// ANY of its calls — every one is judged by the buffered wall-clock threshold
|
|
129
130
|
// (plans/agent-backends.md "Watchdogs and progress"); pi keeps its per-command
|
|
130
131
|
// classification, where only a piped or redirected command is buffered.
|
|
131
|
-
if (deps.agent
|
|
132
|
+
if (!streamsToolOutput(deps.agent) || commandBuffersOutput(call.command || call.label)) {
|
|
132
133
|
const openMs = Date.now() - call.lastActivityAt;
|
|
133
134
|
const bufferedStallMs = bufferedCommandStallMs(deps.stallMs);
|
|
134
135
|
if (openMs >= bufferedStallMs) {
|
|
@@ -13,13 +13,26 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Limits are constants on purpose (opinionated defaults over configuration). The read
|
|
15
15
|
* limit (~12k chars ≈ 300 lines) mirrors the prompt's CONTEXT_BUDGET_RULE; the bash limit
|
|
16
|
-
* is looser because test logs and build output legitimately need more tail.
|
|
16
|
+
* is looser because test logs and build output legitimately need more tail. Both were tuned on
|
|
17
|
+
* the 128k local fallback, so a model that declares a smaller window gets them scaled down in
|
|
18
|
+
* proportion (`outputLimits`): a 12k-char result is twice the share of a 64k window.
|
|
17
19
|
*/
|
|
20
|
+
import { windowScale } from "./context-window.js";
|
|
18
21
|
import { writeFullOutput } from "./full-output.js";
|
|
19
22
|
/** Char budget for a `read` tool result (~300 lines, matching CONTEXT_BUDGET_RULE). */
|
|
20
23
|
export const READ_LIMIT_CHARS = 12_000;
|
|
21
24
|
/** Char budget for a `bash` tool result — looser, so test/build tails survive. */
|
|
22
25
|
export const BASH_LIMIT_CHARS = 16_000;
|
|
26
|
+
/** The smallest share of each tuned limit a small window scales it to: a quarter keeps a test
|
|
27
|
+
* log's tail readable (4,000 chars of bash) even at a 32k window. */
|
|
28
|
+
const MIN_LIMIT_SHARE = 0.25;
|
|
29
|
+
/** The budgets for a model's declared context window: READ_LIMIT_CHARS and BASH_LIMIT_CHARS at
|
|
30
|
+
* or above the 128k window they were tuned on (and when the window is unknown), scaled down in
|
|
31
|
+
* proportion below it — 6,000 and 8,000 chars at 64k — and never under a quarter of each. */
|
|
32
|
+
export function outputLimits(contextWindow) {
|
|
33
|
+
const scale = Math.max(MIN_LIMIT_SHARE, windowScale(contextWindow));
|
|
34
|
+
return { read: Math.round(READ_LIMIT_CHARS * scale), bash: Math.round(BASH_LIMIT_CHARS * scale) };
|
|
35
|
+
}
|
|
23
36
|
/** Extra code points reserved when snapping cut points to line boundaries, so the
|
|
24
37
|
* snapped head+tail plus marker still fit inside the limit even after rounding. */
|
|
25
38
|
const SNAP_SLACK = 240;
|
|
@@ -136,9 +149,10 @@ export function boundBashResult(text, details, writeFullOutput, limitChars = BAS
|
|
|
136
149
|
const fullPath = details?.fullOutputPath ?? writeFullOutput?.(text) ?? null;
|
|
137
150
|
return boundText(text, limitChars, fullPath);
|
|
138
151
|
}
|
|
139
|
-
/** The pi extension entry point: bound oversized read and bash results in place
|
|
152
|
+
/** The pi extension entry point: bound oversized read and bash results in place, at the budgets
|
|
153
|
+
* for the current model's declared window (read per result, so a model change is honored). */
|
|
140
154
|
export default function boundedOutput(pi) {
|
|
141
|
-
pi.on("tool_result", (event) => {
|
|
155
|
+
pi.on("tool_result", (event, ctx) => {
|
|
142
156
|
if (event.toolName !== "read" && event.toolName !== "bash")
|
|
143
157
|
return undefined;
|
|
144
158
|
const blocks = event.content;
|
|
@@ -148,9 +162,10 @@ export default function boundedOutput(pi) {
|
|
|
148
162
|
const textBlock = blocks.find((b) => b?.type === "text" && typeof b.text === "string");
|
|
149
163
|
if (!textBlock || typeof textBlock.text !== "string")
|
|
150
164
|
return undefined;
|
|
165
|
+
const limits = outputLimits(ctx?.model?.contextWindow);
|
|
151
166
|
const bounded = event.toolName === "read"
|
|
152
|
-
? boundReadResult(textBlock.text, event.input)
|
|
153
|
-
: boundBashResult(textBlock.text, event.details, (text) => writeFullOutput(text, event.toolCallId));
|
|
167
|
+
? boundReadResult(textBlock.text, event.input, limits.read)
|
|
168
|
+
: boundBashResult(textBlock.text, event.details, (text) => writeFullOutput(text, event.toolCallId), limits.bash);
|
|
154
169
|
if (bounded === textBlock.text)
|
|
155
170
|
return undefined;
|
|
156
171
|
return { content: [{ type: "text", text: bounded }] };
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* lossy summary; past it, a run that keeps reading hits the window and lands nothing. context-shake
|
|
10
10
|
* reclaims the oldest bulky tool results first, so the notes now sit above that reclaim line: the
|
|
11
11
|
* 70% note names the crossing, context-shake elides what it can in the same turn_end, and at 85%
|
|
12
|
-
* the note is the stop-reading backstop for a run whose remaining results were not reclaimable.
|
|
12
|
+
* the note is the stop-reading backstop for a run whose remaining results were not reclaimable. (On
|
|
13
|
+
* a window under 128k context-shake crosses earlier, under pi's compaction line — see its
|
|
14
|
+
* `shakeLimits` — while these notes keep their percentages.) The
|
|
13
15
|
* failure mode the numbers exist for is the fallback's local model (Qwen3.8-27B): its longest ticks
|
|
14
16
|
* peaked at 96–110k tokens (coverage, steward, telemetry, plan; 2026-09-29..10-01) while the
|
|
15
17
|
* prompt's reading budget could only say "your window is finite". On a large-window model the
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bundled pi extension: when a run crosses 70% of its context window
|
|
2
|
+
* Bundled pi extension: when a run crosses 70% of its context window (less on a window under
|
|
3
|
+
* 128k, where pi compacts sooner — see `shakeLimits`), reclaim the space held
|
|
3
4
|
* by old, bulky `read` and `bash` tool results by replacing each with a short pointer the model
|
|
4
5
|
* can follow back to the full text. Loaded on every pi run via `-e <this file>` from
|
|
5
6
|
* src/pi/pi-args.ts, after bounded-output and before context-budget.
|
|
@@ -10,7 +11,7 @@
|
|
|
10
11
|
* old tool results with recoverable references and keep the recent window intact. pi 1.0.0 can
|
|
11
12
|
* do the same from an extension — a `turn_end` handler appends persisted `context_edit` entries
|
|
12
13
|
* (pi docs, extensions.md "Events and concurrency"; session-format.md "ContextEditEntry"). On a
|
|
13
|
-
* large-window primary (1M) the 70% line sits far above the largest
|
|
14
|
+
* large-window primary (1M) the 70% line sits far above the largest team tick, so the
|
|
14
15
|
* extension plans nothing and costs nothing there.
|
|
15
16
|
*
|
|
16
17
|
* The selection logic is a pure, exported function (`shakePlan`) so every rule is unit-testable
|
|
@@ -24,18 +25,45 @@ import { compactTokens } from "../text/format.js";
|
|
|
24
25
|
import { writeFullOutput } from "./full-output.js";
|
|
25
26
|
import { appendToolResultNote } from "./tool-result-content.js";
|
|
26
27
|
import { readContextUsage } from "./context-usage.js";
|
|
27
|
-
|
|
28
|
+
import { compactionPercent, declaredWindow, windowScale } from "./context-window.js";
|
|
29
|
+
/** The fill percentage at which the first shake pass runs, on the 128k window it was tuned on
|
|
30
|
+
* (see `shakeLimits` for a smaller one). */
|
|
28
31
|
export const SHAKE_PERCENT = 70;
|
|
29
32
|
/** The fill percentage at which a second pass runs, only when the first did not bring the run
|
|
30
33
|
* back under `SHAKE_PERCENT` (the note-heavy path climbs here again). */
|
|
31
34
|
export const SHAKE_MAX_PERCENT = 85;
|
|
32
35
|
/** The newest slice of context, in estimated tokens, that is never elided — the model still
|
|
33
|
-
* needs what it was just reading. */
|
|
36
|
+
* needs what it was just reading. Tuned on the 128k window. */
|
|
34
37
|
export const SHAKE_PROTECTED_TOKENS = 20_000;
|
|
35
38
|
/** A tool result shorter than this is left alone: its pointer would save little. */
|
|
36
39
|
export const SHAKE_MIN_CHARS = 2_000;
|
|
37
|
-
/** A pass that would reclaim fewer than this many estimated tokens is not worth the edit.
|
|
40
|
+
/** A pass that would reclaim fewer than this many estimated tokens is not worth the edit. Tuned
|
|
41
|
+
* on the 128k window. */
|
|
38
42
|
export const SHAKE_MIN_RECLAIM_TOKENS = 10_000;
|
|
43
|
+
/** How far, in percentage points, the second pass stays under pi's own compaction line: 2.5
|
|
44
|
+
* puts it at exactly SHAKE_MAX_PERCENT on the 128k window. */
|
|
45
|
+
const COMPACTION_MARGIN_PERCENT = 2.5;
|
|
46
|
+
/** The shake limits for a declared context window. At or above the 128k window they were tuned
|
|
47
|
+
* on — and when the window is unknown — they are the SHAKE_* constants. Below it:
|
|
48
|
+
* - the protected slice and the reclaim floor scale in proportion (10k and 5k tokens at 64k);
|
|
49
|
+
* - both passes move under pi's compaction line (window − 16,384), which on a small window
|
|
50
|
+
* falls below 85% — at 64k it is 75%, so an 85% pass would never run before pi replaced the
|
|
51
|
+
* run's reads with a lossy summary. The second pass sits COMPACTION_MARGIN_PERCENT under
|
|
52
|
+
* that line and the first keeps its 15-point lead (57.5% and 72.5% at 64k).
|
|
53
|
+
* Pure. */
|
|
54
|
+
export function shakeLimits(contextWindow) {
|
|
55
|
+
const window = declaredWindow(contextWindow);
|
|
56
|
+
const scale = windowScale(window);
|
|
57
|
+
const maxPercent = window === null
|
|
58
|
+
? SHAKE_MAX_PERCENT
|
|
59
|
+
: Math.max(0, Math.min(SHAKE_MAX_PERCENT, compactionPercent(window) - COMPACTION_MARGIN_PERCENT));
|
|
60
|
+
return {
|
|
61
|
+
percent: Math.max(0, Math.min(SHAKE_PERCENT, maxPercent - (SHAKE_MAX_PERCENT - SHAKE_PERCENT))),
|
|
62
|
+
maxPercent,
|
|
63
|
+
protectedTokens: Math.round(SHAKE_PROTECTED_TOKENS * scale),
|
|
64
|
+
minReclaimTokens: Math.round(SHAKE_MIN_RECLAIM_TOKENS * scale),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
39
67
|
/** The fixed prefix of every elision pointer, used to recognize a result that was already
|
|
40
68
|
* elided (and must never be elided again). */
|
|
41
69
|
export const ELIDED_PREFIX = "[elided by tumwater:";
|
|
@@ -52,7 +80,7 @@ function percentOf(usage) {
|
|
|
52
80
|
}
|
|
53
81
|
// Both ratio inputs are read through nonNegativeNumber: a NaN/Infinity/negative/string wire
|
|
54
82
|
// value is as unusable as a missing one, and a NaN ratio would slip past shakePlan's
|
|
55
|
-
// `percent <
|
|
83
|
+
// `percent < limits.percent` guard (every comparison against NaN is false) and plan a pass on
|
|
56
84
|
// an unknown fill. A non-positive window has no ratio either.
|
|
57
85
|
const tokens = nonNegativeNumber(usage.tokens, null);
|
|
58
86
|
const window = nonNegativeNumber(usage.contextWindow, null);
|
|
@@ -85,8 +113,8 @@ function replacementFor(msg, fullOutputPath) {
|
|
|
85
113
|
}
|
|
86
114
|
/** Whether one message is even a candidate, ignoring the reclaim floor: a bulky, non-error,
|
|
87
115
|
* not-already-elided read or bash result that sits outside the protected newest slice. */
|
|
88
|
-
function isCandidate(msg, newerTokens) {
|
|
89
|
-
if (newerTokens <
|
|
116
|
+
function isCandidate(msg, newerTokens, protectedTokens) {
|
|
117
|
+
if (newerTokens < protectedTokens)
|
|
90
118
|
return false;
|
|
91
119
|
if (msg.role !== "toolResult")
|
|
92
120
|
return false;
|
|
@@ -104,14 +132,16 @@ function isCandidate(msg, newerTokens) {
|
|
|
104
132
|
}
|
|
105
133
|
/**
|
|
106
134
|
* Plan a shake pass: the `context_edit` replacements that reclaim the most space from old
|
|
107
|
-
* bulky tool results. Empty unless the run is at or above
|
|
108
|
-
* the newest
|
|
109
|
-
* `
|
|
135
|
+
* bulky tool results. Empty unless the run is at or above the first pass's percent, something
|
|
136
|
+
* outside the newest protected slice is reclaimable, and the pass clears the reclaim floor
|
|
137
|
+
* together — all three from `shakeLimits` for the usage's window. Deterministic and
|
|
138
|
+
* filesystem-free except the injected
|
|
110
139
|
* `writeFullOutput`, which is only called for a bash result pi has not already persisted.
|
|
111
140
|
*/
|
|
112
141
|
export function shakePlan(messages, usage, writeFullOutput) {
|
|
113
142
|
const percent = percentOf(usage);
|
|
114
|
-
|
|
143
|
+
const limits = shakeLimits(usage.contextWindow);
|
|
144
|
+
if (percent === null || percent < limits.percent)
|
|
115
145
|
return EMPTY_PLAN;
|
|
116
146
|
// Tokens newer than each message, so a candidate is one the model has already moved past.
|
|
117
147
|
const newerTokens = new Array(messages.length).fill(0);
|
|
@@ -120,11 +150,11 @@ export function shakePlan(messages, usage, writeFullOutput) {
|
|
|
120
150
|
newerTokens[i] = running;
|
|
121
151
|
running += estimateTokens(messages[i]?.text ?? "");
|
|
122
152
|
}
|
|
123
|
-
const candidates = messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0));
|
|
153
|
+
const candidates = messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0, limits.protectedTokens));
|
|
124
154
|
// The pointer is far shorter than the result it replaces, so the full text's tokens are a
|
|
125
155
|
// safe upper bound on the reclaim; bail before touching the writer when even that is short.
|
|
126
156
|
const upperBound = candidates.reduce((sum, msg) => sum + estimateTokens(msg.text), 0);
|
|
127
|
-
if (upperBound <
|
|
157
|
+
if (upperBound < limits.minReclaimTokens)
|
|
128
158
|
return EMPTY_PLAN;
|
|
129
159
|
const edits = [];
|
|
130
160
|
let reclaimedTokens = 0;
|
|
@@ -141,7 +171,7 @@ export function shakePlan(messages, usage, writeFullOutput) {
|
|
|
141
171
|
edits.push({ targetId: msg.entryId, toolName: msg.toolName, replacement, reclaimedTokens: reclaimed });
|
|
142
172
|
reclaimedTokens += reclaimed;
|
|
143
173
|
}
|
|
144
|
-
if (reclaimedTokens <
|
|
174
|
+
if (reclaimedTokens < limits.minReclaimTokens)
|
|
145
175
|
return EMPTY_PLAN;
|
|
146
176
|
return { edits, reclaimedTokens, elidedCount: edits.length };
|
|
147
177
|
}
|
|
@@ -200,7 +230,8 @@ function textOf(msg) {
|
|
|
200
230
|
.join("\n");
|
|
201
231
|
}
|
|
202
232
|
/** The pi extension entry point: shake at 70% (and again at 85% when the first pass did not
|
|
203
|
-
* hold)
|
|
233
|
+
* hold) — on a window under 128k, at `shakeLimits`'s lower crossings — leaving each pointer in
|
|
234
|
+
* the context and the one-line summary on the next result. */
|
|
204
235
|
export default function contextShake(pi) {
|
|
205
236
|
let lastShake = 0;
|
|
206
237
|
let pendingNote = null;
|
|
@@ -211,12 +242,14 @@ export default function contextShake(pi) {
|
|
|
211
242
|
const percent = percentOf(usage);
|
|
212
243
|
if (percent === null)
|
|
213
244
|
return undefined;
|
|
214
|
-
// One pass per crossing:
|
|
245
|
+
// One pass per crossing: the first pass's percent, then the second's only if the run
|
|
246
|
+
// climbed back there.
|
|
215
247
|
let threshold = 0;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
248
|
+
const limits = shakeLimits(usage.contextWindow);
|
|
249
|
+
if (lastShake < limits.percent && percent >= limits.percent)
|
|
250
|
+
threshold = limits.percent;
|
|
251
|
+
else if (lastShake < limits.maxPercent && percent >= limits.maxPercent)
|
|
252
|
+
threshold = limits.maxPercent;
|
|
220
253
|
if (threshold === 0)
|
|
221
254
|
return undefined;
|
|
222
255
|
lastShake = threshold;
|