tumwater 0.4.2 → 0.4.4
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 +6 -6
- package/dist/build-info.json +2 -2
- package/dist/src/agent/agent-backend.js +6 -6
- package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
- package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
- package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
- package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
- package/dist/src/agent/backend-traits.js +2 -2
- package/dist/src/agent/claude/claude-account.js +6 -27
- package/dist/src/agent/claude/claude-backend.js +3 -3
- package/dist/src/agent/claude/claude-env.js +27 -31
- package/dist/src/agent/claude/claude-stream.js +111 -54
- package/dist/src/agent/codex/codex-account.js +8 -0
- package/dist/src/agent/codex/codex-args.js +5 -5
- package/dist/src/agent/codex/codex-backend.js +2 -2
- package/dist/src/agent/codex/codex-env.js +28 -15
- package/dist/src/agent/codex/codex-hook-trust.js +39 -20
- package/dist/src/agent/codex/codex-rollout.js +93 -56
- package/dist/src/agent/codex/codex-stream.js +1 -1
- package/dist/src/agent/env-scrub.js +18 -0
- package/dist/src/agent/image-input.js +68 -11
- package/dist/src/agent/notes-shim.js +2 -2
- package/dist/src/agent/opencode/opencode-backend.js +26 -24
- package/dist/src/agent/opencode/opencode-catalog.js +85 -36
- package/dist/src/agent/opencode/opencode-config.js +32 -14
- package/dist/src/agent/opencode/opencode-env.js +12 -13
- package/dist/src/agent/opencode/opencode-stream.js +47 -25
- package/dist/src/agent/session-ledger.js +23 -27
- package/dist/src/agent/subscription-account.js +2 -83
- package/dist/src/backlog/backlog-archive.js +3 -4
- package/dist/src/backlog/backlog-cli-view.js +81 -41
- package/dist/src/backlog/backlog-cli.js +57 -26
- package/dist/src/backlog/backlog-eligibility.js +56 -122
- package/dist/src/backlog/backlog-md.js +5 -5
- package/dist/src/backlog/backlog-parts.js +65 -27
- package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
- package/dist/src/backlog/backlog-release.js +12 -12
- package/dist/src/backlog/backlog-structure.js +114 -61
- package/dist/src/backlog/backlog.js +3 -2
- package/dist/src/baseline/main-baseline.js +148 -84
- package/dist/src/baseline/main-guardrails.js +2 -2
- package/dist/src/baseline/main-watch.js +84 -50
- package/dist/src/brief/brief.js +26 -13
- package/dist/src/budget/budget-fallback-view.js +100 -243
- package/dist/src/budget/budget-wiring.js +29 -65
- package/dist/src/budget/budget.js +4 -5
- package/dist/src/budget/fallback-breaker.js +2 -2
- package/dist/src/build/build-check-detect.js +43 -14
- package/dist/src/build/build-check-events.js +2 -2
- package/dist/src/build/build-check-report.js +10 -0
- package/dist/src/build/build-check-scoped.js +40 -18
- package/dist/src/build/build-check.js +13 -5
- package/dist/src/build/build-stage.js +6 -4
- package/dist/src/change/change-data.js +1 -4
- package/dist/src/cli/cli-command-args.js +89 -52
- package/dist/src/cli/cli-completion-command.js +75 -0
- package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
- package/dist/src/cli/cli-doctor-command.js +103 -0
- package/dist/src/cli/cli-flag-specs.js +42 -22
- package/dist/src/cli/cli-init.js +36 -14
- package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
- package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
- package/dist/src/cli/cli-query-commands.js +2 -2
- package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
- package/dist/src/cli/cli-repo-gate.js +2 -2
- package/dist/src/cli/cli-role-command.js +2 -41
- package/dist/src/cli/cli-start-summary.js +4 -3
- package/dist/src/cli/cli-start-supervisor.js +190 -0
- package/dist/src/cli/cli-start.js +94 -244
- package/dist/src/cli/completion.js +85 -49
- package/dist/src/cli/follow-view.js +20 -2
- package/dist/src/cli/help.js +6 -13
- package/dist/src/cli.js +70 -176
- package/dist/src/collections.js +39 -27
- package/dist/src/concurrency/check-slots.js +70 -0
- package/dist/src/concurrency/lock.js +23 -1
- package/dist/src/concurrency/run-permit.js +49 -0
- package/dist/src/concurrency/semaphore.js +46 -250
- package/dist/src/config/config-editable-keys.js +4 -7
- package/dist/src/config/config-fallbacks.js +81 -101
- package/dist/src/config/config-field-checks.js +66 -37
- package/dist/src/config/config-keys.js +66 -40
- package/dist/src/config/config-live.js +4 -28
- package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
- package/dist/src/config/config-request.js +163 -102
- package/dist/src/config/config-schema.js +10 -12
- package/dist/src/config/config-selector-checks.js +1 -1
- package/dist/src/config/config-validation-agents.js +2 -2
- package/dist/src/config/config-validation-roles.js +8 -72
- package/dist/src/config/config-validation.js +4 -39
- package/dist/src/config/config-views.js +26 -50
- package/dist/src/config/config-write.js +128 -102
- package/dist/src/config/config.js +5 -14
- package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
- package/dist/src/doctor/doctor-backlog-plans.js +53 -120
- package/dist/src/doctor/doctor-backlog.js +16 -14
- package/dist/src/doctor/doctor-check-names.js +3 -6
- package/dist/src/doctor/doctor-checks.js +8 -8
- package/dist/src/doctor/doctor-claude-checks.js +70 -36
- package/dist/src/doctor/doctor-codex-checks.js +6 -6
- package/dist/src/doctor/doctor-contention-checks.js +57 -117
- package/dist/src/doctor/doctor-fallback-checks.js +48 -26
- package/dist/src/doctor/doctor-holds.js +8 -47
- package/dist/src/doctor/doctor-install.js +1 -1
- package/dist/src/doctor/doctor-launch-services.js +1 -1
- package/dist/src/doctor/doctor-model-checks.js +178 -94
- package/dist/src/doctor/doctor-notify.js +2 -2
- package/dist/src/doctor/doctor-opencode-checks.js +5 -4
- package/dist/src/doctor/doctor-orphans.js +122 -21
- package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
- package/dist/src/doctor/doctor-pi-checks.js +2 -2
- package/dist/src/doctor/doctor-principles.js +3 -3
- package/dist/src/doctor/doctor-project-checks.js +24 -11
- package/dist/src/doctor/doctor-role-notes.js +1 -1
- package/dist/src/doctor/doctor.js +8 -11
- package/dist/src/events/event-format.js +44 -64
- package/dist/src/events/event-labels.js +1 -5
- package/dist/src/events/event-legacy.js +3 -5
- package/dist/src/events/{notify.js → event-notify.js} +6 -6
- package/dist/src/events/event-read.js +1 -1
- package/dist/src/events/event-window.js +1 -1
- package/dist/src/events/events.js +8 -0
- package/dist/src/failure/failure-cluster.js +9 -18
- package/dist/src/failure/failure-data.js +12 -8
- package/dist/src/failure/failure-render.js +46 -36
- package/dist/src/failure/failure-state-change.js +54 -120
- package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
- package/dist/src/files/files.js +30 -0
- package/dist/src/files/json-object.js +8 -8
- package/dist/src/files/stat-cache.js +12 -8
- package/dist/src/gates/budget-gates.js +85 -84
- package/dist/src/gates/gate-polls.js +32 -74
- package/dist/src/gates/pause-gates.js +16 -2
- package/dist/src/gates/readiness.js +1 -1
- package/dist/src/gates/role-cap-gates.js +44 -35
- package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
- package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
- package/dist/src/git/git-diff.js +6 -2
- package/dist/src/git/git.js +75 -45
- package/dist/src/git/worktree-pool.js +3 -6
- package/dist/src/gui/gui-admin-endpoints.js +3 -3
- package/dist/src/gui/gui-args.js +12 -0
- package/dist/src/gui/gui-backlog-commands.js +4 -4
- package/dist/src/gui/gui-backlog-endpoints.js +61 -41
- package/dist/src/gui/gui-command.js +43 -24
- package/dist/src/gui/gui-config-commands.js +2 -64
- package/dist/src/gui/gui-endpoint-commands.js +1 -1
- package/dist/src/gui/gui-endpoint-common.js +19 -1
- package/dist/src/gui/gui-history-endpoints.js +100 -52
- package/dist/src/gui/gui-operator-commands.js +43 -48
- package/dist/src/gui/gui-prompt-commands.js +25 -25
- package/dist/src/gui/gui-report-endpoints.js +1 -1
- package/dist/src/gui/gui-server.js +1 -2
- package/dist/src/gui/gui-team-commands.js +2 -1
- package/dist/src/history/history-data.js +71 -44
- package/dist/src/history/history-queued.js +49 -27
- package/dist/src/history/history.js +54 -36
- package/dist/src/inbox/prompt-commands.js +59 -42
- package/dist/src/init/init.js +88 -40
- package/dist/src/merge/merge-backlog-conflicts.js +1 -1
- package/dist/src/merge/merge-check-failures.js +12 -9
- package/dist/src/merge/merge-core.js +7 -32
- package/dist/src/merge/merge-drain.js +11 -13
- package/dist/src/merge/merge-pipeline.js +6 -7
- package/dist/src/merge/merge-post-review-drain.js +11 -14
- package/dist/src/merge/merge-queue-render.js +3 -3
- package/dist/src/merge/merge-requeue.js +1 -1
- package/dist/src/merge/merge-slot.js +0 -8
- package/dist/src/merge/merge-stack.js +17 -18
- package/dist/src/merge/merge-to-main.js +13 -58
- package/dist/src/merge/merge-verify.js +12 -21
- package/dist/src/notes-cli.js +52 -42
- package/dist/src/operator/operator-commands.js +80 -47
- package/dist/src/operator/operator-intent.js +3 -3
- package/dist/src/orchestrator/orchestrator-launch.js +31 -63
- package/dist/src/orchestrator/orchestrator-poll.js +44 -69
- package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
- package/dist/src/orchestrator/orchestrator.js +8 -21
- package/dist/src/orchestrator/scheduling-admit.js +101 -68
- package/dist/src/orchestrator/scheduling-scope.js +18 -27
- package/dist/src/orchestrator/scheduling-skip.js +10 -14
- package/dist/src/orchestrator/startup-migrations.js +1 -1
- package/dist/src/paths.js +10 -9
- package/dist/src/pi/pi-args.js +5 -2
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/pi/pi-bin.js +3 -3
- package/dist/src/pi/pi-event-line.js +28 -1
- package/dist/src/pi/pi-listed-models.js +13 -0
- package/dist/src/pi/pi-models.js +43 -29
- package/dist/src/pi/pi-settings.js +43 -20
- package/dist/src/pi/pi-stream.js +41 -8
- package/dist/src/pi/pi-watchdogs.js +43 -15
- package/dist/src/pi/pi.js +2 -2
- package/dist/src/pi-extension/context-shake.js +32 -20
- package/dist/src/pi-extension/kill-guard.js +69 -0
- package/dist/src/process/process-group.js +51 -26
- package/dist/src/process/process.js +20 -12
- package/dist/src/process/run-marker.js +33 -15
- package/dist/src/process/supervisor.js +40 -28
- package/dist/src/prompt/dialect.js +1 -1
- package/dist/src/prompt/gate-notes.js +2 -2
- package/dist/src/prompt/gate-prompts.js +1 -44
- package/dist/src/prompt/notes-backlog.js +66 -37
- package/dist/src/prompt/principles.js +6 -4
- package/dist/src/prompt/prompt-followup.js +12 -4
- package/dist/src/prompt/prompt.js +88 -38
- package/dist/src/redeploy/redeployer.js +21 -1
- package/dist/src/report/report-data.js +53 -37
- package/dist/src/report/report-fold.js +16 -9
- package/dist/src/report/report-render.js +21 -194
- package/dist/src/report/report-since-data.js +4 -3
- package/dist/src/report/report-since-render.js +173 -0
- package/dist/src/report/report-usage-fold.js +8 -8
- package/dist/src/report/report.js +46 -22
- package/dist/src/review/diff-rules.js +35 -21
- package/dist/src/review/post-merge-review-render.js +9 -3
- package/dist/src/review/post-merge-review.js +48 -41
- package/dist/src/review/review-precheck.js +14 -4
- package/dist/src/review/review.js +48 -170
- package/dist/src/review/secret-leaks.js +26 -16
- package/dist/src/roles/legacy-role-migration.js +6 -3
- package/dist/src/roles/role-catalog.js +11 -3
- package/dist/src/roles/role-guidance.js +16 -12
- package/dist/src/roles/role-render.js +7 -46
- package/dist/src/roles/role-view.js +10 -33
- package/dist/src/run/acceptance-greps.js +88 -45
- package/dist/src/run/ci-parity.js +19 -8
- package/dist/src/run/run-apply.js +12 -15
- package/dist/src/run/run-detail-data.js +121 -68
- package/dist/src/run/run-detail-render.js +92 -35
- package/dist/src/run/run-detail.js +72 -41
- package/dist/src/run/run-prompt.js +63 -50
- package/dist/src/run/run-resume.js +76 -29
- package/dist/src/run/run-stage.js +141 -90
- package/dist/src/run/run-timing.js +8 -2
- package/dist/src/run/run-usage.js +31 -1
- package/dist/src/run/run-verdict.js +128 -50
- package/dist/src/run/stage-check.js +60 -50
- package/dist/src/run/stage-checks-shared.js +28 -0
- package/dist/src/run/stage-name-checks.js +111 -92
- package/dist/src/run/stage-path-checks.js +69 -42
- package/dist/src/run/stage-prose-checks.js +55 -40
- package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
- package/dist/src/runner/runner-outcome.js +7 -2
- package/dist/src/runner/runner-pi.js +9 -22
- package/dist/src/runner/runner-recovery.js +5 -13
- package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
- package/dist/src/runner/runner-run.js +103 -50
- package/dist/src/runner/runner-state-load.js +18 -28
- package/dist/src/runner/runner-state.js +0 -14
- package/dist/src/runner/runner.js +41 -25
- package/dist/src/scheduling/backoff.js +8 -3
- package/dist/src/scheduling/claim-entries.js +2 -1
- package/dist/src/scheduling/claims.js +0 -4
- package/dist/src/scheduling/scheduling.js +53 -66
- package/dist/src/status/status-budget.js +4 -43
- package/dist/src/status/status-data.js +35 -39
- package/dist/src/status/status-role-notes.js +2 -2
- package/dist/src/status/status-roles.js +9 -18
- package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
- package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
- package/dist/src/team/team-hold.js +105 -70
- package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
- package/dist/src/team/team-polls.js +53 -201
- package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
- package/dist/src/team/team-state.js +4 -13
- package/dist/src/text/datetime.js +16 -7
- package/dist/src/text/markdown.js +1 -1
- package/dist/src/text/phrases.js +29 -15
- package/dist/src/text/suggest.js +1 -1
- package/dist/src/text/text.js +6 -5
- package/dist/src/ui/alert-feeds.js +46 -111
- package/dist/src/ui/badges.js +17 -88
- package/dist/src/ui/gui/gui-client-boot.js +2 -8
- package/dist/src/ui/gui/gui-client-composer.js +1 -1
- package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
- package/dist/src/ui/gui/gui-client-drawer.js +1 -1
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +28 -14
- package/dist/src/ui/gui/gui-client-model.js +0 -12
- package/dist/src/ui/gui/gui-client-operator.js +7 -5
- package/dist/src/ui/gui/gui-client-pending.js +7 -6
- package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
- package/dist/src/ui/gui/gui-client-report.js +1 -1
- package/dist/src/ui/gui/gui-client-roles.js +0 -1
- package/dist/src/ui/gui/gui-client-settings.js +43 -84
- package/dist/src/ui/gui/gui-client-team.js +12 -6
- package/dist/src/ui/gui/gui-client.js +7 -1
- package/dist/src/ui/progress-data.js +107 -44
- package/dist/src/ui/run-progress-model.js +20 -4
- package/dist/src/ui/status-model.js +2 -24
- package/dist/src/ui/status-payload.js +11 -30
- package/dist/src/ui/status-render.js +13 -24
- package/dist/src/ui/team-alert-backlog.js +14 -204
- package/dist/src/ui/team-alert-shared.js +6 -29
- package/dist/src/ui/team-alert-status.js +9 -170
- package/dist/src/ui/team-alert-tail.js +19 -215
- package/dist/src/ui/team-alerts.js +8 -15
- package/dist/src/ui/transcript-run.js +27 -16
- package/dist/src/ui/tui/tui-backlog.js +2 -2
- package/dist/src/ui/tui/tui-editor-answer.js +12 -10
- package/dist/src/ui/tui/tui-editor-attach.js +4 -3
- package/dist/src/ui/tui/tui-editor-backends.js +4 -3
- package/dist/src/ui/tui/tui-editor-brief.js +6 -9
- package/dist/src/ui/tui/tui-editor-budget.js +4 -3
- package/dist/src/ui/tui/tui-editor-config.js +80 -68
- package/dist/src/ui/tui/tui-editor-entry.js +12 -20
- package/dist/src/ui/tui/tui-editor-pause.js +51 -57
- package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
- package/dist/src/ui/tui/tui-editor-queued.js +11 -14
- package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
- package/dist/src/ui/tui/tui-editor-release.js +4 -3
- package/dist/src/ui/tui/tui-editor-retire.js +7 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
- package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
- package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
- package/dist/src/ui/tui/tui-editor-search.js +4 -3
- package/dist/src/ui/tui/tui-editor-session.js +17 -4
- package/dist/src/ui/tui/tui-editors.js +5 -10
- package/dist/src/ui/tui/tui-frame.js +182 -134
- package/dist/src/ui/tui/tui-guard.js +20 -0
- package/dist/src/ui/tui/tui-input.js +167 -98
- package/dist/src/ui/tui/tui-keymap.js +51 -29
- package/dist/src/ui/tui/tui-keys.js +1 -1
- package/dist/src/ui/tui/tui-pane-activity.js +5 -4
- package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +252 -230
- package/dist/src/ui/tui/tui-pane-history.js +67 -74
- package/dist/src/ui/tui/tui-pane-pending.js +288 -247
- package/dist/src/ui/tui/tui-pane-queued.js +25 -27
- package/dist/src/ui/tui/tui-pane-report.js +2 -1
- package/dist/src/ui/tui/tui-pane-session.js +15 -10
- package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
- package/dist/src/ui/tui/tui-pane.js +14 -11
- package/dist/src/ui/tui/tui-panes.js +38 -33
- package/dist/src/ui/tui/tui.js +4 -7
- package/dist/src/verdict/fix-claim.js +107 -56
- package/dist/src/verdict/refusal.js +1 -1
- package/dist/src/verdict/reply-contract.js +3 -4
- package/package.json +5 -5
- package/dist/src/concurrency/provider-lease.js +0 -105
- package/dist/src/review/review-followup.js +0 -87
- package/dist/src/review/review-run.js +0 -395
- package/dist/src/runner/model-fallback.js +0 -76
- package/dist/src/runner/runner-fallback.js +0 -66
- package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
- package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
- /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
- /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
- /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
- /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
- /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
package/dist/src/pi/pi-stream.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { extractRefusal, hasVerdictLine, isNegatedRefusal, isNothingToDo } from "../verdict/reply-contract.js";
|
|
2
|
-
import { applyToolExecutionEvent, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
|
|
2
|
+
import { applyToolExecutionEvent, foldPendingReply, messageUpdateHasContent, piEventType, toolCallCommand, toolUpdateHasContent, } from "./pi-event-line.js";
|
|
3
3
|
import { describeToolCall } from "../text/phrases.js";
|
|
4
4
|
import { objectOrEmpty, nonNegativeNumber, parseJsonObject, isJsonObject } from "../files/json-object.js";
|
|
5
5
|
import { backendKind, isBackendFailureKind, isPermanentConfigError, TRANSIENT_BACKEND } from "./pi-failure.js";
|
|
@@ -204,6 +204,10 @@ export class PiStreamParser {
|
|
|
204
204
|
* empty-content update right after start, and a content-free keepalive must not mask a hang.
|
|
205
205
|
* Feeds runPi's stall warning; entries clear at tool_execution_end. */
|
|
206
206
|
openToolCalls = [];
|
|
207
|
+
/** The model request pi has sent and the provider has not yet answered (see PendingReply), or
|
|
208
|
+
* undefined. Feeds runPi's unanswered-request warning; stamped with this parser's clock, since
|
|
209
|
+
* it observes the request live. */
|
|
210
|
+
pendingReply = undefined;
|
|
207
211
|
/** Read tool calls started but not yet ended, keyed by pi's toolCallId so concurrent reads
|
|
208
212
|
* attribute their end to the right start (pi runs one message's tool calls concurrently, so
|
|
209
213
|
* several can be open at once and end in completion order — the same rule
|
|
@@ -251,6 +255,17 @@ export class PiStreamParser {
|
|
|
251
255
|
* treat a `rejected` window as the transient rate limit it is (with the resetsAt-derived
|
|
252
256
|
* wait the translator attached). */
|
|
253
257
|
applyRateLimit(event) {
|
|
258
|
+
const reading = this.readRateWindow(event);
|
|
259
|
+
this.upsertRateWindow(reading);
|
|
260
|
+
if (reading.status === "rejected") {
|
|
261
|
+
this.transientRateLimit = true;
|
|
262
|
+
this.setRetryAfterSeconds(event.retryAfterSeconds);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** Build one `RateWindow` reading from a canonical `rate_limit` event: derive the `type`
|
|
266
|
+
* (falling back to `unknown` when pi names none), default the `status` to "" when absent,
|
|
267
|
+
* and copy through the four optional fields that the event carries. */
|
|
268
|
+
readRateWindow(event) {
|
|
254
269
|
const type = typeof event.rateLimitType === "string" && event.rateLimitType ? event.rateLimitType : "unknown";
|
|
255
270
|
const reading = { type, status: typeof event.status === "string" ? event.status : "" };
|
|
256
271
|
if (typeof event.resetsAt === "number" && Number.isFinite(event.resetsAt))
|
|
@@ -261,9 +276,16 @@ export class PiStreamParser {
|
|
|
261
276
|
reading.overageStatus = event.overageStatus;
|
|
262
277
|
if (event.isUsingOverage === true)
|
|
263
278
|
reading.isUsingOverage = true;
|
|
264
|
-
|
|
279
|
+
return reading;
|
|
280
|
+
}
|
|
281
|
+
/** Insert one reading, keeping the newest per type: replace the window whose `type` matches
|
|
282
|
+
* and, when the reading is status-less (a non-binding window's utilization), carry the last
|
|
283
|
+
* known status of the window it replaces; note overage so a caller can see a rate limit hit
|
|
284
|
+
* even before it is rejected. */
|
|
285
|
+
upsertRateWindow(reading) {
|
|
286
|
+
const existing = this.rateWindows.findIndex((w) => w.type === reading.type);
|
|
265
287
|
// A reading without a status (a non-binding window's utilization) keeps the last known one.
|
|
266
|
-
if (reading.status === ""
|
|
288
|
+
if (reading.status === "")
|
|
267
289
|
reading.status = this.rateWindows[existing]?.status ?? "";
|
|
268
290
|
if (existing >= 0)
|
|
269
291
|
this.rateWindows[existing] = reading;
|
|
@@ -271,10 +293,6 @@ export class PiStreamParser {
|
|
|
271
293
|
this.rateWindows.push(reading);
|
|
272
294
|
if (reading.isUsingOverage === true)
|
|
273
295
|
this.usedOverage = true;
|
|
274
|
-
if (reading.status === "rejected") {
|
|
275
|
-
this.transientRateLimit = true;
|
|
276
|
-
this.setRetryAfterSeconds(event.retryAfterSeconds);
|
|
277
|
-
}
|
|
278
296
|
}
|
|
279
297
|
/** Fold a provider Retry-After hint into `retryAfterSeconds`: ignore a non-number or
|
|
280
298
|
* non-finite value, and clamp a negative or fractional one to whole seconds. */
|
|
@@ -355,6 +373,7 @@ export class PiStreamParser {
|
|
|
355
373
|
if (event.type === "billing" && (event.billing === "subscription" || event.billing === "api"))
|
|
356
374
|
this.billing = event.billing;
|
|
357
375
|
this.countProgress(event);
|
|
376
|
+
this.pendingReply = foldPendingReply(this.pendingReply, event);
|
|
358
377
|
this.applyToolExecution(event);
|
|
359
378
|
if (event.type !== "message_end" || event.message?.role !== "assistant")
|
|
360
379
|
return;
|
|
@@ -369,8 +388,10 @@ export class PiStreamParser {
|
|
|
369
388
|
// Nothing else in this parser acts on the type, so the line is done either way.
|
|
370
389
|
applyMessageUpdate(line) {
|
|
371
390
|
if (piEventType(line) === "message_update") {
|
|
372
|
-
|
|
391
|
+
const hasContent = messageUpdateHasContent(parseJsonObject(line));
|
|
392
|
+
if (hasContent)
|
|
373
393
|
this.progressCount += 1;
|
|
394
|
+
this.pendingReply = foldPendingReply(this.pendingReply, { type: "message_update" }, { hasContent });
|
|
374
395
|
return true;
|
|
375
396
|
}
|
|
376
397
|
return false;
|
|
@@ -494,6 +515,12 @@ export class PiStreamParser {
|
|
|
494
515
|
}
|
|
495
516
|
applyAssistantMessage(msg) {
|
|
496
517
|
const text = messageText(msg);
|
|
518
|
+
this.applyAssistantReply(msg, text);
|
|
519
|
+
this.applyAssistantRefusal(text);
|
|
520
|
+
this.applyAssistantUsage(msg);
|
|
521
|
+
this.applyAssistantStop(msg);
|
|
522
|
+
}
|
|
523
|
+
applyAssistantReply(msg, text) {
|
|
497
524
|
this.finalMessageContentless = !messageContent(msg).some((c) => c.type === "toolCall" ||
|
|
498
525
|
(c.type === "text" && typeof c.text === "string" && Boolean(c.text.trim())));
|
|
499
526
|
if (!this.finalMessageContentless)
|
|
@@ -502,6 +529,8 @@ export class PiStreamParser {
|
|
|
502
529
|
this.finalText = text;
|
|
503
530
|
if (isNothingToDo(text))
|
|
504
531
|
this.declaredNothingToDo = true;
|
|
532
|
+
}
|
|
533
|
+
applyAssistantRefusal(text) {
|
|
505
534
|
// The refusal comes only from an anchored `TUMWATER_REFUSED: <reason>` line: a bare
|
|
506
535
|
// sentinel and a mid-sentence mention are not refusals, and a negating reason ("none",
|
|
507
536
|
// "(n/a …)") clears one — BUGS.md 2026-09-23, four runs ended ordinary replies with
|
|
@@ -521,6 +550,8 @@ export class PiStreamParser {
|
|
|
521
550
|
}
|
|
522
551
|
if (hasVerdictLine(text))
|
|
523
552
|
this.verdictText = text;
|
|
553
|
+
}
|
|
554
|
+
applyAssistantUsage(msg) {
|
|
524
555
|
this.turns += 1;
|
|
525
556
|
this.outputTokens += usageNumber(msg.usage?.output);
|
|
526
557
|
const promptTokens = usageNumber(msg.usage?.input) + usageNumber(msg.usage?.cacheRead) + usageNumber(msg.usage?.cacheWrite);
|
|
@@ -536,6 +567,8 @@ export class PiStreamParser {
|
|
|
536
567
|
}
|
|
537
568
|
this.peakContextTokens = Math.max(this.peakContextTokens, usageNumber(msg.usage?.totalTokens));
|
|
538
569
|
this.costUsd += usageNumber(msg.usage?.cost?.total);
|
|
570
|
+
}
|
|
571
|
+
applyAssistantStop(msg) {
|
|
539
572
|
this.stopReason = msg.stopReason;
|
|
540
573
|
if (msg.errorMessage)
|
|
541
574
|
this.errorMessage = msg.errorMessage;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** The run's watchdog clocks, split out of pi.ts's runPi (2026-10-04, organize): the run
|
|
2
|
-
* deadline and the quiet watchdog (quiet-kill plus the stalled-tool-call
|
|
2
|
+
* deadline and the quiet watchdog (quiet-kill plus the stalled-tool-call and
|
|
3
|
+
* unanswered-model-request warnings) — the
|
|
3
4
|
* timing policy of one pi run, which changes for different reasons than the spawn, stream,
|
|
4
|
-
* and result-distillation wiring that drives it (src/agent/run
|
|
5
|
+
* and result-distillation wiring that drives it (src/agent/agent-run.ts keeps those). The
|
|
5
6
|
* factory owns all the mutable state the two timers kept in runPi's closure (the progress
|
|
6
7
|
* window, the zero-byte grace, the warned-call set) and exposes the flags the run's close
|
|
7
|
-
* handler reads back; run
|
|
8
|
+
* handler reads back; agent-run.ts feeds the parser live through the deps accessors, so no
|
|
8
9
|
* notification path is needed when events land. */
|
|
9
10
|
import { bufferedCommandStallMs, callBuffersOutput } from "./command-shape.js";
|
|
10
11
|
/** The run-deadline clock: kills the process tree when it fires, after giving a
|
|
@@ -41,7 +42,9 @@ function startDeadlineWatch(state, deps) {
|
|
|
41
42
|
// discard (BUGS.md 2026-10-26).
|
|
42
43
|
const progressGraceMs = 250;
|
|
43
44
|
let extended = false;
|
|
44
|
-
let graceArmed = false; // one grace
|
|
45
|
+
let graceArmed = false; // one grace for the whole run, so a genuinely stalled run is never granted two
|
|
46
|
+
// (see the deadline comment below: a run reaches this branch at most once, so per-run and
|
|
47
|
+
// per-deadline scope grant the same single short re-check)
|
|
45
48
|
state.timeoutBudgetMs = deps.runTimeoutMs;
|
|
46
49
|
const fireDeadline = () => {
|
|
47
50
|
if (deps.extendOnProgress && !extended && deps.progressCount() > checkpointProgress) {
|
|
@@ -65,8 +68,13 @@ function startDeadlineWatch(state, deps) {
|
|
|
65
68
|
// count and a progressing run looks stalled (BUGS.md 2026-10-26). A run that has begun
|
|
66
69
|
// producing structured progress therefore gets ONE short grace to drain queued output and
|
|
67
70
|
// re-prove itself; only a run still flat after the grace — or one that never progressed —
|
|
68
|
-
// keeps the discard path. The grace is one
|
|
69
|
-
//
|
|
71
|
+
// keeps the discard path. The grace is one short beat, armed once for the whole run:
|
|
72
|
+
// graceArmed is a closure local of startDeadlineWatch, one per run, and the branch is
|
|
73
|
+
// reached at most once because a gate run's earlier base deadline is handled by its
|
|
74
|
+
// one-time extension (the block above) while an authoring run has a single deadline — so
|
|
75
|
+
// "once per run" and "once per deadline" grant the very same single re-check, and a
|
|
76
|
+
// progressing gate run is never left without one while a genuinely stalled run is never
|
|
77
|
+
// granted two.
|
|
70
78
|
if (!graceArmed && deps.progressCount() > 0) {
|
|
71
79
|
graceArmed = true;
|
|
72
80
|
state.timeout = setTimeout(fireDeadline, progressGraceMs);
|
|
@@ -78,9 +86,13 @@ function startDeadlineWatch(state, deps) {
|
|
|
78
86
|
};
|
|
79
87
|
state.timeout = setTimeout(fireDeadline, deps.runTimeoutMs);
|
|
80
88
|
}
|
|
89
|
+
/** Whole minutes read cleaner in the feed; sub-minute thresholds stay in seconds. */
|
|
90
|
+
function silenceLabel(ms) {
|
|
91
|
+
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`;
|
|
92
|
+
}
|
|
81
93
|
/** The quiet/stall clock: quiet-kills a run that stopped making progress and warns on
|
|
82
|
-
* stalled tool calls, on one interval against the wall clock
|
|
83
|
-
* 2026-09-28, 2026-09-29, 2026-10-06). */
|
|
94
|
+
* stalled tool calls and unanswered model requests, on one interval against the wall clock
|
|
95
|
+
* (BUGS.md 2026-09-23, 2026-09-28, 2026-09-29, 2026-10-06, 2026-10-29). */
|
|
84
96
|
function startQuietWatch(state, deps) {
|
|
85
97
|
// Quiet watchdog: a healthy run makes PROGRESS continuously even when slow — messages
|
|
86
98
|
// start and end, turns and tool calls complete. Prolonged lack of progress means a hung
|
|
@@ -147,21 +159,35 @@ function startQuietWatch(state, deps) {
|
|
|
147
159
|
const bufferedStallMs = bufferedCommandStallMs(deps.stallMs);
|
|
148
160
|
if (openMs >= bufferedStallMs) {
|
|
149
161
|
warnedStalledCalls.add(call.id);
|
|
150
|
-
deps.
|
|
162
|
+
deps.onStallWarning?.(`tool call stalled: ${call.label} — no exit for ${silenceLabel(openMs)}`);
|
|
151
163
|
}
|
|
152
164
|
continue;
|
|
153
165
|
}
|
|
154
166
|
const silentMs = Date.now() - call.lastActivityAt;
|
|
155
167
|
if (silentMs >= deps.stallMs) {
|
|
156
168
|
warnedStalledCalls.add(call.id);
|
|
157
|
-
|
|
158
|
-
const silent = silentMs < 60_000
|
|
159
|
-
? `${Math.round(silentMs / 1000)}s`
|
|
160
|
-
: `${Math.round(silentMs / 60_000)}m`;
|
|
161
|
-
deps.onToolCallStalled?.(`tool call stalled: ${call.label} — no output for ${silent}`);
|
|
169
|
+
deps.onStallWarning?.(`tool call stalled: ${call.label} — no output for ${silenceLabel(silentMs)}`);
|
|
162
170
|
}
|
|
163
171
|
}
|
|
164
172
|
};
|
|
173
|
+
// The unanswered-request warning, the model-side twin of the stall warning: a provider can
|
|
174
|
+
// accept a request and never answer it (BUGS.md 2026-10-29: deepinfra's DeepSeek-V4.1-Flash
|
|
175
|
+
// sent plan only SSE keepalives, which pi never surfaces). pi then waits out its own idle
|
|
176
|
+
// timeout and retries, and each retry is progress to the quiet watchdog, so nothing else
|
|
177
|
+
// names the hang for hours. Like the stall warning it only warns: a slow local prefill can
|
|
178
|
+
// legitimately run this long, and pi's own timeout still owns each attempt. One warning per
|
|
179
|
+
// unanswered stretch, keyed by its start, not one per retry.
|
|
180
|
+
let warnedReplySince;
|
|
181
|
+
const warnUnansweredReply = () => {
|
|
182
|
+
const pending = deps.pendingReply?.();
|
|
183
|
+
if (!pending || pending.since === warnedReplySince)
|
|
184
|
+
return;
|
|
185
|
+
const waitedMs = Date.now() - pending.since;
|
|
186
|
+
if (waitedMs < deps.stallMs)
|
|
187
|
+
return;
|
|
188
|
+
warnedReplySince = pending.since;
|
|
189
|
+
deps.onStallWarning?.(`model request unanswered: ${pending.target || "model"} — no reply for ${silenceLabel(waitedMs)}`);
|
|
190
|
+
};
|
|
165
191
|
const checkEveryMs = deps.quietMs > 0 ? deps.quietMs : deps.stallMs;
|
|
166
192
|
state.quietCheck =
|
|
167
193
|
checkEveryMs > 0
|
|
@@ -174,8 +200,10 @@ function startQuietWatch(state, deps) {
|
|
|
174
200
|
state.quietKilled = true;
|
|
175
201
|
deps.kill();
|
|
176
202
|
}
|
|
177
|
-
if (deps.stallMs > 0)
|
|
203
|
+
if (deps.stallMs > 0) {
|
|
178
204
|
warnStalledToolCalls();
|
|
205
|
+
warnUnansweredReply();
|
|
206
|
+
}
|
|
179
207
|
}, Math.min(Math.max(checkEveryMs / 2, 250), 30_000))
|
|
180
208
|
: undefined;
|
|
181
209
|
}
|
package/dist/src/pi/pi.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
/** The pi-side facade over the backend-neutral agent runner (src/agent/run
|
|
1
|
+
/** The pi-side facade over the backend-neutral agent runner (src/agent/agent-run.ts) and the
|
|
2
2
|
* pi adapter (src/pi/pi-backend.ts). `runPi` is a thin alias for `runAgent` so every existing
|
|
3
3
|
* call site, fake, and test keeps importing the historical name; the stderr-match constants and
|
|
4
4
|
* the run-kind/backend-failure types stay here for the same reason. The seam itself lives in
|
|
5
5
|
* src/agent/agent-backend.ts, and `hasResumableSession` now lives beside the ledger it reads
|
|
6
6
|
* (src/agent/session-ledger.ts) and is re-exported here so existing importers are unchanged. */
|
|
7
|
-
export { runAgent, runPi } from "../agent/run
|
|
7
|
+
export { runAgent, runPi } from "../agent/agent-run.js";
|
|
8
8
|
export { MODEL_FALLBACK_CLONE, TRANSIENT_PI_CRASH } from "./pi-backend.js";
|
|
9
9
|
export { hasResumableSession } from "../agent/session-ledger.js";
|
|
@@ -130,32 +130,22 @@ function isCandidate(msg, newerTokens, protectedTokens) {
|
|
|
130
130
|
return false;
|
|
131
131
|
return true;
|
|
132
132
|
}
|
|
133
|
-
/**
|
|
134
|
-
|
|
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
|
|
139
|
-
* `writeFullOutput`, which is only called for a bash result pi has not already persisted.
|
|
140
|
-
*/
|
|
141
|
-
export function shakePlan(messages, usage, writeFullOutput) {
|
|
142
|
-
const percent = percentOf(usage);
|
|
143
|
-
const limits = shakeLimits(usage.contextWindow);
|
|
144
|
-
if (percent === null || percent < limits.percent)
|
|
145
|
-
return EMPTY_PLAN;
|
|
146
|
-
// Tokens newer than each message, so a candidate is one the model has already moved past.
|
|
133
|
+
/** Tokens newer than each message, so a candidate is one the model has already moved past. */
|
|
134
|
+
function newerTokenCounts(messages) {
|
|
147
135
|
const newerTokens = new Array(messages.length).fill(0);
|
|
148
136
|
let running = 0;
|
|
149
137
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
150
138
|
newerTokens[i] = running;
|
|
151
139
|
running += estimateTokens(messages[i]?.text ?? "");
|
|
152
140
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
141
|
+
return newerTokens;
|
|
142
|
+
}
|
|
143
|
+
/** The messages the model has already moved past and can reclaim space from. */
|
|
144
|
+
function reclaimableCandidates(messages, newerTokens, protectedTokens) {
|
|
145
|
+
return messages.filter((msg, i) => isCandidate(msg, newerTokens[i] ?? 0, protectedTokens));
|
|
146
|
+
}
|
|
147
|
+
/** The edits that reclaim space from one candidate list, and how much they reclaim together. */
|
|
148
|
+
function shakeEdits(candidates, writeFullOutput) {
|
|
159
149
|
const edits = [];
|
|
160
150
|
let reclaimedTokens = 0;
|
|
161
151
|
for (const msg of candidates) {
|
|
@@ -171,6 +161,28 @@ export function shakePlan(messages, usage, writeFullOutput) {
|
|
|
171
161
|
edits.push({ targetId: msg.entryId, toolName: msg.toolName, replacement, reclaimedTokens: reclaimed });
|
|
172
162
|
reclaimedTokens += reclaimed;
|
|
173
163
|
}
|
|
164
|
+
return { edits, reclaimedTokens };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Plan a shake pass: the `context_edit` replacements that reclaim the most space from old
|
|
168
|
+
* bulky tool results. Empty unless the run is at or above the first pass's percent, something
|
|
169
|
+
* outside the newest protected slice is reclaimable, and the pass clears the reclaim floor
|
|
170
|
+
* together — all three from `shakeLimits` for the usage's window. Deterministic and
|
|
171
|
+
* filesystem-free except the injected
|
|
172
|
+
* `writeFullOutput`, which is only called for a bash result pi has not already persisted.
|
|
173
|
+
*/
|
|
174
|
+
export function shakePlan(messages, usage, writeFullOutput) {
|
|
175
|
+
const percent = percentOf(usage);
|
|
176
|
+
const limits = shakeLimits(usage.contextWindow);
|
|
177
|
+
if (percent === null || percent < limits.percent)
|
|
178
|
+
return EMPTY_PLAN;
|
|
179
|
+
const candidates = reclaimableCandidates(messages, newerTokenCounts(messages), limits.protectedTokens);
|
|
180
|
+
// The pointer is far shorter than the result it replaces, so the full text's tokens are a
|
|
181
|
+
// safe upper bound on the reclaim; bail before touching the writer when even that is short.
|
|
182
|
+
const upperBound = candidates.reduce((sum, msg) => sum + estimateTokens(msg.text), 0);
|
|
183
|
+
if (upperBound < limits.minReclaimTokens)
|
|
184
|
+
return EMPTY_PLAN;
|
|
185
|
+
const { edits, reclaimedTokens } = shakeEdits(candidates, writeFullOutput);
|
|
174
186
|
if (reclaimedTokens < limits.minReclaimTokens)
|
|
175
187
|
return EMPTY_PLAN;
|
|
176
188
|
return { edits, reclaimedTokens, elidedCount: edits.length };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled pi extension: refuse a `bash` call that kills processes by pattern, next to the write
|
|
3
|
+
* guard (src/pi-extension/write-guard.ts). Loaded on every pi run via `-e <this file>` from
|
|
4
|
+
* src/pi/pi-args.ts.
|
|
5
|
+
*
|
|
6
|
+
* The run prompt already forbids pattern kills (KILL_BY_PID_RULE, src/prompt/prompt.ts): "Never
|
|
7
|
+
* kill processes by name or pattern; stop only a process you started, by its PID (`kill $!`)".
|
|
8
|
+
* But that rule is words, and BUGS.md 2026-10-28 records one agent answering a timed-out test run
|
|
9
|
+
* with `ps aux | grep test-runner | xargs kill` and a `for p in $(ps …) kill` loop, which reached
|
|
10
|
+
* every process-table row the pattern matched — the team's own in-flight checks included — and
|
|
11
|
+
* took them down mid-run. The process table is shared, so none of those kills is provably the
|
|
12
|
+
* runner's own; `kill $!` and the run's exit sweep are the sanctioned way to stop what it started.
|
|
13
|
+
*
|
|
14
|
+
* This is a tool-layer net beside the prompt rule, not a replacement for it. The same `tool_call`
|
|
15
|
+
* event pi fires for `edit`/`write` fires for `bash`; blocking a call when a handler returns
|
|
16
|
+
* `{ block: true, reason }` shows the model the reason as the tool's error. The detection is a
|
|
17
|
+
* heuristic for the common by-pattern shapes — `pkill`/`killall` as a command word, and `ps` or
|
|
18
|
+
* `pgrep` output fed into `kill` via `xargs` or a `$(ps|pgrep)` substitution — and fails open like
|
|
19
|
+
* the write guard does: a missed shape under load still leaves the prompt rule and the run's sweep,
|
|
20
|
+
* and an unexpected failure here lets the call through rather than stopping every bash command of
|
|
21
|
+
* the run (a broken guard cannot block the team's own checks).
|
|
22
|
+
*/
|
|
23
|
+
/** The refusal shown as the tool's error, naming the safe ways to stop a run's own process. */
|
|
24
|
+
export const PATTERN_KILL_REFUSAL = "kill by pattern is refused: `pkill`/`killall` and `ps`/`pgrep` output fed into `kill` reach every " +
|
|
25
|
+
"process on the host the pattern matches, the team's own checks included. Stop only a process you " +
|
|
26
|
+
"started, by its PID: `kill $!` right after backgrounding it — the run's own processes are reaped " +
|
|
27
|
+
"when the run ends.";
|
|
28
|
+
/** A `pkill` or `killall` spelled as a command word, skipping a line that only mentions them
|
|
29
|
+
* (built after `echo`/`grep`/`man` etc.). Preceded by a command boundary so `echo pkill x` and a
|
|
30
|
+
* quoted mention pass while `; pkill x` and `| killall x` are caught. */
|
|
31
|
+
const DIRECT_PATTERN_KILL = /(?:^|[;&|(\n])\s*(?:pkill|killall)(?:\s|$)/;
|
|
32
|
+
/** `ps` or `pgrep` that reads the process table. */
|
|
33
|
+
const READ_PROCESS_TABLE = /\b(?:ps|pgrep)\b/;
|
|
34
|
+
/** That table piped into `kill` through `xargs` (the incident's `ps … | … | xargs -r kill`). */
|
|
35
|
+
const XARGS_KILL = /\bxargs\b[^\n;]*\bkill\b/;
|
|
36
|
+
/** A `$(ps …)` / `$(pgrep …)` substitution that feeds ids to a `kill` (`kill -9 $(pgrep -f npm)`,
|
|
37
|
+
* a `for p in $(ps …)` loop whose body kills). */
|
|
38
|
+
const TABLE_SUBSTITUTION = /\$\(\s*(?:ps|pgrep)\b/;
|
|
39
|
+
/** The refusal for one `bash` command, or null to let it run. Exported for tests. */
|
|
40
|
+
export function patternKillRefusal(command) {
|
|
41
|
+
if (typeof command !== "string" || command.trim() === "")
|
|
42
|
+
return null;
|
|
43
|
+
if (DIRECT_PATTERN_KILL.test(command))
|
|
44
|
+
return PATTERN_KILL_REFUSAL;
|
|
45
|
+
const feedsTable = READ_PROCESS_TABLE.test(command);
|
|
46
|
+
const feedsKill = XARGS_KILL.test(command) || (TABLE_SUBSTITUTION.test(command) && /\bkill\b/.test(command));
|
|
47
|
+
if (feedsTable && feedsKill)
|
|
48
|
+
return PATTERN_KILL_REFUSAL;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/** The refusal for one `tool_call` event, or undefined to let it run. Exported for tests. */
|
|
52
|
+
export function guardKillToolCall(event) {
|
|
53
|
+
if (event.toolName !== "bash")
|
|
54
|
+
return undefined;
|
|
55
|
+
const input = event.input;
|
|
56
|
+
if (typeof input !== "object" || input === null)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const reason = patternKillRefusal(input.command);
|
|
60
|
+
return reason === null ? undefined : { block: true, reason };
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The pi extension entry point. */
|
|
67
|
+
export default function killGuard(pi) {
|
|
68
|
+
pi.on("tool_call", guardKillToolCall);
|
|
69
|
+
}
|
|
@@ -1,26 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { captureCappedOutput, releaseChildHandles, signalTree, withoutLaunchServicesCheckIn } from "./process.js";
|
|
2
|
+
import { captureCappedOutput, KILL_GRACE_MS, releaseChildHandles, signalTree, withoutLaunchServicesCheckIn, } from "./process.js";
|
|
3
|
+
export { KILL_GRACE_MS };
|
|
3
4
|
import { errCode } from "../errno.js";
|
|
4
|
-
/** The detached process-group runner: runScriptGroup starts a command in its own process
|
|
5
|
-
* group — exactly like pi — and settles exactly once, bounding a timed-out tree at its
|
|
6
|
-
* deadline plus the SIGTERM → SIGKILL escalation grace. Split out of process.ts, whose other
|
|
7
|
-
* residents (the exec helper, the liveness and process-table probes, the LaunchServices
|
|
8
|
-
* preload) are generic child-process plumbing shared across the harness: this runner's two
|
|
9
|
-
* runtime consumers both sit in the build boundary — the build check (build/build-check.ts)
|
|
10
|
-
* and the lockfile install (build/dep-install.ts) — and its result carries the build check's
|
|
11
|
-
* run record, so it lives beside that boundary instead of inside the shared module. The
|
|
12
|
-
* signals it escalates with come from process.ts's signalTree; the children it starts carry
|
|
13
|
-
* withoutLaunchServicesCheckIn's preload, so its npm trees check in with launchservicesd
|
|
14
|
-
* neither on macOS nor anywhere else. */
|
|
15
|
-
/** SIGTERM → SIGKILL escalation window once a run's timeout has FIRED: the whole process
|
|
16
|
-
* group gets SIGTERM, and anything still alive this much later (a SIGTERM-trapping runner, a
|
|
17
|
-
* wedged worker) is SIGKILLed — and the run settles then, whether or not the tree ever closed
|
|
18
|
-
* its pipes, so a timed-out run is bounded at its deadline plus this grace. The default of
|
|
19
|
-
* runBuildCheck's killGraceMs parameter (build/build-check.ts), which tests shrink — pinning that
|
|
20
|
-
* the escalation is armed on timeout (never at spawn: a healthy run that merely outlasts the
|
|
21
|
-
* grace period must run to completion), that a surviving grandchild is taken down before the
|
|
22
|
-
* run settles, and that a tree the SIGTERM already took down does not wait out the grace. */
|
|
23
|
-
export const KILL_GRACE_MS = 10_000;
|
|
24
5
|
/** True while any process in the group led by `pid` still exists: a signal-0 probe of the
|
|
25
6
|
* group, where EPERM still means a member exists. armGroupDeadline probes with it for both
|
|
26
7
|
* detached-process-group runners (runScriptGroup and git-run's execGitBounded), whose
|
|
@@ -122,6 +103,13 @@ export function runScriptGroup(cmd, args, opts) {
|
|
|
122
103
|
env: withoutLaunchServicesCheckIn(process.env),
|
|
123
104
|
});
|
|
124
105
|
let settled = false;
|
|
106
|
+
let aborted = false;
|
|
107
|
+
// Abort-stop timers, the mirror of armGroupDeadline's — armed only while an abort fires
|
|
108
|
+
// before settle (never at spawn), SIGTERM first then SIGKILL after killGraceMs for a
|
|
109
|
+
// survivor, settling as soon as the whole group is gone — cleared on finish, the caller's
|
|
110
|
+
// single settle path.
|
|
111
|
+
let abortPoll;
|
|
112
|
+
let abortKillTimer;
|
|
125
113
|
// Captured output is capped at maxBuffer per stream (further chunks are dropped —
|
|
126
114
|
// classification reads the tail, so a chatty script can neither wedge the check nor balloon
|
|
127
115
|
// memory). The overflow is deliberately ignored here: truncated output still classifies.
|
|
@@ -134,11 +122,34 @@ export function runScriptGroup(cmd, args, opts) {
|
|
|
134
122
|
killGraceMs: opts.killGraceMs,
|
|
135
123
|
onTimedOut: () => finish({ signal: "SIGTERM" }),
|
|
136
124
|
});
|
|
125
|
+
function stopForAbort() {
|
|
126
|
+
if (settled)
|
|
127
|
+
return;
|
|
128
|
+
aborted = true;
|
|
129
|
+
// The harness's own stop, signalled the way a timeout stops the tree: SIGTERM the whole
|
|
130
|
+
// group (grandchild included), then SIGKILL anything still alive after the grace. The
|
|
131
|
+
// run settles when the group is gone, so the caller never sees a live tree behind it.
|
|
132
|
+
signalTree(child, "SIGTERM");
|
|
133
|
+
abortPoll = setInterval(() => {
|
|
134
|
+
if (!groupAlive(child.pid))
|
|
135
|
+
finish({ aborted: true });
|
|
136
|
+
}, GROUP_POLL_MS);
|
|
137
|
+
abortKillTimer = setTimeout(() => {
|
|
138
|
+
signalTree(child, "SIGKILL");
|
|
139
|
+
finish({ aborted: true });
|
|
140
|
+
}, opts.killGraceMs);
|
|
141
|
+
}
|
|
137
142
|
function finish(result) {
|
|
138
143
|
if (settled)
|
|
139
144
|
return;
|
|
140
145
|
settled = true;
|
|
141
146
|
deadline.dispose();
|
|
147
|
+
if (abortPoll)
|
|
148
|
+
clearInterval(abortPoll);
|
|
149
|
+
if (abortKillTimer)
|
|
150
|
+
clearTimeout(abortKillTimer);
|
|
151
|
+
if (opts.signal)
|
|
152
|
+
opts.signal.removeEventListener("abort", stopForAbort);
|
|
142
153
|
const timedOut = deadline.fired();
|
|
143
154
|
if (timedOut) {
|
|
144
155
|
// Nothing more is read once the deadline has fired: release the handles so a pipe
|
|
@@ -148,16 +159,30 @@ export function runScriptGroup(cmd, args, opts) {
|
|
|
148
159
|
const run = { spawnedAt, settledAt: Date.now(), timeoutMs: opts.timeoutMs };
|
|
149
160
|
if (timedOut)
|
|
150
161
|
run.deadlineLateMs = deadline.lateMs();
|
|
151
|
-
resolve({ ...result, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
|
|
162
|
+
resolve({ ...result, aborted, timedOut, stdout: captured.stdout, stderr: captured.stderr, run });
|
|
152
163
|
}
|
|
164
|
+
// An abort signal stops the tree mid-check — the harness's own stop, so the outcome says
|
|
165
|
+
// the harness aborted it, distinct from a timeout and from an external killed signal. The
|
|
166
|
+
// listener is removed as soon as the run settles. An already-aborted signal is deliberately
|
|
167
|
+
// NOT acted on at spawn: the swap happens mid-run (a user abort or a shutdown fires while
|
|
168
|
+
// the check is running), and a signal already aborted before the check started is caught by
|
|
169
|
+
// the caller's `aborted` check when the run returns — a fast failing check under a
|
|
170
|
+
// pre-aborted signal still reaches its own verdict first (the pre-existing shutdown-test
|
|
171
|
+
// behavior).
|
|
172
|
+
if (opts.signal)
|
|
173
|
+
opts.signal.addEventListener("abort", stopForAbort, { once: true });
|
|
153
174
|
child.on("error", (err) => {
|
|
154
|
-
// Before
|
|
155
|
-
// only be a teardown signal that could not be delivered
|
|
156
|
-
//
|
|
157
|
-
if (!deadline.fired())
|
|
175
|
+
// Before a stop this is the spawn failing (npm missing from PATH). After the deadline or
|
|
176
|
+
// an abort it can only be a teardown signal that could not be delivered — the stop's own
|
|
177
|
+
// group probe or grace timer settles the run.
|
|
178
|
+
if (!deadline.fired() && !aborted)
|
|
158
179
|
finish({ spawnError: err });
|
|
159
180
|
});
|
|
160
181
|
child.on("close", (code, signal) => {
|
|
182
|
+
// During an abort-stop the run settles on the whole group being gone (the probe or the
|
|
183
|
+
// grace timer), never on the leader's close alone. Otherwise the timeout rule stands.
|
|
184
|
+
if (aborted)
|
|
185
|
+
return;
|
|
161
186
|
// Before the deadline the leader's close settles the run. After it, settle only once the
|
|
162
187
|
// whole group is gone: something in the group may outlive the leader, and the poll
|
|
163
188
|
// settles the run when it goes, or the grace timer SIGKILLs it and settles then.
|
|
@@ -3,10 +3,10 @@ import { promisify } from "node:util";
|
|
|
3
3
|
/** Child-process plumbing: the harness's one execFile helper (execFileAsync), the pid-liveness
|
|
4
4
|
* probe every place that decides whether a recorded pid still belongs to a live process shares
|
|
5
5
|
* (the merge lock's stale-holder check in lock.ts, the orchestrator-alive status in
|
|
6
|
-
* team/orchestrator-info.ts), the group-signal helpers behind every teardown, the
|
|
7
|
-
* race helper the spawns and merge's shutdown wiring share, and the child
|
|
8
|
-
* that keeps the harness's Node processes out of LaunchServices on macOS. Reading the
|
|
9
|
-
* process table and the per-run TUMWATER_RUN marks and sweep built on it live in
|
|
6
|
+
* team/team-orchestrator-info.ts), the group-signal helpers behind every teardown, the
|
|
7
|
+
* register/abort race helper the spawns and merge's shutdown wiring share, and the child
|
|
8
|
+
* environment that keeps the harness's Node processes out of LaunchServices on macOS. Reading the
|
|
9
|
+
* host's process table and the per-run TUMWATER_RUN marks and sweep built on it live in
|
|
10
10
|
* process-table.ts, on top of the exec helper here. */
|
|
11
11
|
const execFileRaw = promisify(execFile);
|
|
12
12
|
/** The output ceiling every execFile the harness runs shares (git, tsc, the build check, the
|
|
@@ -24,7 +24,7 @@ export function execFileAsync(file, args, options = {}) {
|
|
|
24
24
|
* added after the abort event never fires, so `addEventListener` alone would leave the caller
|
|
25
25
|
* waiting on a shutdown that has come and gone. A missing signal (no shutdown wiring)
|
|
26
26
|
* registers nothing. Shared by spawnSupervised (supervisor.ts), runPi's child spawn
|
|
27
|
-
* (src/agent/
|
|
27
|
+
* (src/agent/agent-spawn.ts), sleepInterruptible (run-timing.ts), and abortOnShutdown
|
|
28
28
|
* (merge-pipeline.ts).
|
|
29
29
|
*
|
|
30
30
|
* Returns a disposer that detaches `fn`. A `{ once: true }` listener detaches itself when the
|
|
@@ -88,10 +88,18 @@ export async function identityAlive(id) {
|
|
|
88
88
|
const now = await pidIdentity(id.pid);
|
|
89
89
|
return now !== null && now.start === id.start;
|
|
90
90
|
}
|
|
91
|
+
/** SIGTERM → SIGKILL escalation window: how long a SIGTERM is given to take a process tree
|
|
92
|
+
* down before whatever survived is SIGKILLed. The one value behind every harness escalation
|
|
93
|
+
* — terminateChild's group kill here, the run-marker sweep's orphan kill, and the detached
|
|
94
|
+
* process-group runner (whose own `KILL_GRACE_MS` re-exports it, process-group.ts) — so a
|
|
95
|
+
* team-wide teardown, an end-of-run sweep, and a timed-out build check all grant the same
|
|
96
|
+
* grace and the literal cannot drift between them. 10 s is enough for a well-behaved tree to
|
|
97
|
+
* exit on SIGTERM; a SIGTERM-trapping straggler is SIGKILLed after it. */
|
|
98
|
+
export const KILL_GRACE_MS = 10_000;
|
|
91
99
|
/** Terminate a detached child and everything it started: SIGTERM to the process group now,
|
|
92
|
-
* escalating to SIGKILL after
|
|
93
|
-
* (its own process group leader — runPi does this for pi), so a negative PID reaches
|
|
94
|
-
* tool-call grandchild — which a single-PID kill leaves orphaned to launchd — while the
|
|
100
|
+
* escalating to SIGKILL after KILL_GRACE_MS if any of it is still alive. The child is spawned
|
|
101
|
+
* detached (its own process group leader — runPi does this for pi), so a negative PID reaches
|
|
102
|
+
* every tool-call grandchild — which a single-PID kill leaves orphaned to launchd — while the
|
|
95
103
|
* harness's own group is never in the blast radius. Shared by the run-timeout,
|
|
96
104
|
* quiet-watchdog, and harness-shutdown kills, and by the sweep every run gets once pi exits
|
|
97
105
|
* (runPi's 'exit' handler), when the group holds only what pi's tool calls backgrounded.
|
|
@@ -106,14 +114,14 @@ export async function identityAlive(id) {
|
|
|
106
114
|
* by it. */
|
|
107
115
|
export function terminateChild(child) {
|
|
108
116
|
if (signalTree(child, "SIGTERM"))
|
|
109
|
-
setTimeout(() => signalTree(child, "SIGKILL"),
|
|
117
|
+
setTimeout(() => signalTree(child, "SIGKILL"), KILL_GRACE_MS).unref();
|
|
110
118
|
}
|
|
111
119
|
/** Signal the child's whole process group, falling back to the child alone when the group is
|
|
112
120
|
* already gone or the platform has no negative-PID kill (Windows). Returns whether the signal
|
|
113
121
|
* reached a live process: false when nothing was left to receive it — ESRCH from a group that
|
|
114
122
|
* died with its leader, the normal case once the child has exited. Never throws: a process
|
|
115
123
|
* that died between the caller's decision and this call is the normal case. Shared by runPi's
|
|
116
|
-
* teardown (src/agent/run
|
|
124
|
+
* teardown (src/agent/agent-run.ts) and the detached process-group runner (runScriptGroup,
|
|
117
125
|
* process-group.ts) — the same "signal the tree, not just the direct child" guarantee in one
|
|
118
126
|
* home, this module. */
|
|
119
127
|
export function signalTree(child, signal) {
|
|
@@ -186,8 +194,8 @@ export function captureCappedOutput(child, maxBuffer, onOverflow) {
|
|
|
186
194
|
// ── Child environments ───────────────────────────────────────────────────────────────────
|
|
187
195
|
/** The child-environment policy every spawn that hands a child the harness's environment shares:
|
|
188
196
|
* start from `process.env` and apply `overrides` on top. The explicit spawn sites
|
|
189
|
-
* (supervisor.ts, self-reload.ts, notify.ts, git-run.ts, doctor-model-checks.ts) all want
|
|
190
|
-
* this, so the spread lives here once instead of in each spawn's options. */
|
|
197
|
+
* (supervisor.ts, self-reload.ts, event-notify.ts, git-run.ts, doctor-model-checks.ts) all want
|
|
198
|
+
* exactly this, so the spread lives here once instead of in each spawn's options. */
|
|
191
199
|
export function childEnv(overrides = {}) {
|
|
192
200
|
return { ...process.env, ...overrides };
|
|
193
201
|
}
|