From c51b07283e076010417ef3e024435e8e6c4ccb38 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Wed, 30 Sep 2026 07:04:08 -0700 Subject: [PATCH 1/3] fix(herdr): recover upstream sync and verify the published candidate --- .github/workflows/go-ci.yml | 9 + .github/workflows/herdr-sync.yml | 88 +++++-- docs/guides/active/herdr-sync.md | 15 ++ internal/agentactivity/activity.go | 2 +- internal/agentactivity/activity_test.go | 104 +++----- internal/agentactivity/detection_only_test.go | 1 + .../manifest/conformance_test.go | 50 ++-- .../agentactivity/manifest/engine_test.go | 44 +++- internal/agentactivity/manifest/evaluate.go | 13 +- internal/agentactivity/manifest/manifest.go | 8 +- internal/agentactivity/manifest/window.go | 4 +- internal/agentactivity/manifest_detect.go | 3 + .../agentactivity/manifests/fetch_test.go | 24 +- .../agentactivity/manifests/manifests_test.go | 1 - .../manifests/sidecar/codex.toml | 238 +++--------------- .../agentactivity/manifests/sidecar/kiro.toml | 29 --- .../testdata/kiro/live_working_footer.txt | 8 + .../testdata/kiro/tool_spinner_working.txt | 20 -- internal/agentcatalog/families/cline.toml | 1 + internal/agentcatalog/testdata/catalog.golden | 2 +- internal/agentcontrol/integration_test.go | 15 +- internal/agentcontrol/service_test.go | 6 +- internal/cli/agent_explain_file_test.go | 9 +- .../tools/herdrsync/authority_table_test.go | 122 +++++++++ internal/tools/herdrsync/extract.go | 170 +++++++++++-- internal/tools/herdrsync/herdrsync_test.go | 29 ++- scripts/test-herdr-staleness.sh | 88 +++++++ 27 files changed, 665 insertions(+), 438 deletions(-) create mode 100644 docs/guides/active/herdr-sync.md delete mode 100644 internal/agentactivity/manifests/sidecar/kiro.toml create mode 100644 internal/agentactivity/testdata/kiro/live_working_footer.txt delete mode 100644 internal/agentactivity/testdata/kiro/tool_spinner_working.txt create mode 100644 internal/tools/herdrsync/authority_table_test.go create mode 100755 scripts/test-herdr-staleness.sh diff --git a/.github/workflows/go-ci.yml b/.github/workflows/go-ci.yml index b5b92ca15..a2d8b2210 100644 --- a/.github/workflows/go-ci.yml +++ b/.github/workflows/go-ci.yml @@ -17,6 +17,9 @@ on: - "scripts/test-tmux-compat-prefix.sh" - "scripts/test-tmux-compatibility.sh" - "scripts/tmux-compat-lib.sh" + - ".github/workflows/herdr-sync.yml" + - "scripts/herdr-staleness.sh" + - "scripts/test-herdr-staleness.sh" # Bundled integration assets are shipped JavaScript that the Go suite # drives under node. A change to one of them changes what Sidecar # installs into a user's agent, and without this it triggered no CI run @@ -49,6 +52,9 @@ on: - "scripts/test-tmux-compat-prefix.sh" - "scripts/test-tmux-compatibility.sh" - "scripts/tmux-compat-lib.sh" + - ".github/workflows/herdr-sync.yml" + - "scripts/herdr-staleness.sh" + - "scripts/test-herdr-staleness.sh" - "internal/agentintegration/assets/**" - "internal/agentactivity/manifests/**" - "internal/agentintegration/upstream/**" @@ -85,6 +91,9 @@ jobs: with: node-version: "24" + - name: Herdr staleness workflow regression + run: ./scripts/test-herdr-staleness.sh + - name: Test run: go test ./... env: diff --git a/.github/workflows/herdr-sync.yml b/.github/workflows/herdr-sync.yml index da1b94293..61cc6057c 100644 --- a/.github/workflows/herdr-sync.yml +++ b/.github/workflows/herdr-sync.yml @@ -26,9 +26,10 @@ jobs: permissions: contents: write pull-requests: write + statuses: write outputs: changed: ${{ steps.diff.outputs.changed }} - ref: ${{ steps.diff.outputs.ref }} + ref: ${{ steps.push.outputs.ref || steps.diff.outputs.ref }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SYNC_BRANCH: bot/herdr-sync @@ -95,7 +96,7 @@ jobs: if [ -n "$files" ] || [ "$locks_moved" = true ]; then changed=true - ref="$SYNC_BRANCH" + ref="" else changed=false ref="$GITHUB_SHA" @@ -152,6 +153,7 @@ jobs: # current sync output. Re-running updates the existing review in place # rather than opening a second one. - name: Push the sync branch + id: push if: steps.diff.outputs.changed == 'true' run: | set -euo pipefail @@ -172,6 +174,26 @@ jobs: git commit -m "chore(herdr): sync detection manifests to $tag" \ -m "Vendored from herdr $ref by go run ./internal/tools/herdrsync." git push --force origin "$SYNC_BRANCH" + echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Publish checks on the proposed commit + if: steps.push.outputs.ref != '' + env: + SYNC_SHA: ${{ steps.push.outputs.ref }} + CHECKS_OUTCOME: ${{ steps.checks.outcome }} + LINT_OUTCOME: ${{ steps.lint.outcome }} + run: | + set -euo pipefail + state=failure + if [ "$CHECKS_OUTCOME" = success ] && [ "$LINT_OUTCOME" = success ]; then state=success; fi + gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$SYNC_SHA" \ + -f state="$state" -f context="Herdr sync / Go checks" \ + -f description="Build, tests, and lint for the proposed sync" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$SYNC_SHA" \ + -f state=pending -f context="Herdr sync / differential" \ + -f description="Waiting for both differential modes" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - name: Open or update the review if: steps.diff.outputs.changed == 'true' @@ -195,6 +217,8 @@ jobs: echo "> \`go build ./...\`, \`go test ./...\` and golangci-lint passed on this tree in the [run]($run_url) that produced it. A pull request opened with \`GITHUB_TOKEN\` does not trigger \`pull_request\` workflows, so Go CI posts no separate check here." fi echo + echo "The commit statuses on this pull request report Go checks and both differential modes. Wait for both to pass before merging." + echo cat internal/agentactivity/manifests/report.md echo echo "---" @@ -219,13 +243,15 @@ jobs: differential: name: Differential harness against the pinned Herdr release - # Runs on the sync branch when there is one, so the harness judges the - # manifests the review actually proposes; on the workflow's own commit - # otherwise. !cancelled() rather than success() because a sync that failed - # its Go suite still pushed a branch worth judging. + # Judge only the exact successfully published commit, or the workflow + # commit when nothing changed. An import/push failure publishes no ref; + # never fall back to a stale bot branch and report that as fresh evidence. needs: sync - if: ${{ !cancelled() }} + if: ${{ !cancelled() && needs.sync.outputs.ref != '' }} runs-on: ubuntu-latest + permissions: + contents: read + statuses: write env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HERDR_REPO: herdrdev/herdr @@ -233,7 +259,7 @@ jobs: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ needs.sync.outputs.ref || github.sha }} + ref: ${{ needs.sync.outputs.ref }} - name: Set up Go uses: actions/setup-go@v7 @@ -294,6 +320,20 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" done + - name: Publish differential status on the proposed commit + if: always() && needs.sync.outputs.changed == 'true' + env: + SYNC_SHA: ${{ needs.sync.outputs.ref }} + RESULT: ${{ job.status }} + run: | + set -euo pipefail + state=failure + if [ "$RESULT" = success ]; then state=success; fi + gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$SYNC_SHA" \ + -f state="$state" -f context="Herdr sync / differential" \ + -f description="Both differential modes: $RESULT" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + staleness: name: Staleness of the committed lock # Deliberately after sync and deliberately not conditional on it passing. @@ -330,18 +370,13 @@ jobs: run: | set -euo pipefail out=$(mktemp -d) - args=(-out "$out") - # The integration-asset half of the sync writes to a second root. - # Redirect that to a throwaway directory too when the tool has the - # flag, so this run cannot write into the checkout at all; asking the - # tool's own usage keeps this correct either side of that landing. - if go run ./internal/tools/herdrsync -h 2>&1 | grep -q -- '-integration-out'; then - args+=(-integration-out "$(mktemp -d)") - fi + args=(-out "$out" -integration-out "$(mktemp -d)") echo "HERDR_FRESH_LOCK=$out/upstream.lock.json" >> "$GITHUB_ENV" go run ./internal/tools/herdrsync "${args[@]}" > /dev/null - name: Compare against the committed lock + env: + BASE_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail stale=$(./scripts/herdr-staleness.sh \ @@ -355,7 +390,7 @@ jobs: exit 0 fi - open=$(gh pr list --head "$SYNC_BRANCH" --state open --json number --jq '.[0].number // empty') + review=$(gh pr list --head "$SYNC_BRANCH" --base "$BASE_BRANCH" --state open --json number,headRefOid --jq '.[0] // empty') { echo "### Stale vendored manifests" echo @@ -364,13 +399,22 @@ jobs: echo '```' } >> "$GITHUB_STEP_SUMMARY" - if [ -n "$open" ]; then - echo "$stale" - echo "sync pull request #$open is open and carries these bumps; not stale" - exit 0 + if [ -n "$review" ]; then + number=$(jq -r .number <<< "$review") + sha=$(jq -r .headRefOid <<< "$review") + proposed="$RUNNER_TEMP/proposed-herdr-lock.json" + gh api "repos/$GITHUB_REPOSITORY/contents/internal/agentactivity/manifests/upstream.lock.json?ref=$sha" \ + --jq .content | base64 --decode > "$proposed" + missing=$(./scripts/herdr-staleness.sh "$HERDR_FRESH_LOCK" "$proposed" "$MAX_AGE_DAYS") + if [ -z "$missing" ]; then + echo "sync pull request #$number actually carries the stale bumps; awaiting review" + exit 0 + fi + echo "Open sync pull request #$number does not carry these stale bumps:" >&2 + echo "$missing" >&2 fi echo "$stale" >&2 echo >&2 - echo "upstream has been ahead of the committed lock for more than $MAX_AGE_DAYS days with no open $SYNC_BRANCH pull request, so the sync is not landing; run scripts/sync-herdr.sh and read the report" >&2 + echo "upstream has been ahead of the committed lock for more than $MAX_AGE_DAYS days without a $SYNC_BRANCH pull request carrying the bumps, so the sync is not landing; run scripts/sync-herdr.sh and read the report" >&2 exit 1 diff --git a/docs/guides/active/herdr-sync.md b/docs/guides/active/herdr-sync.md new file mode 100644 index 000000000..5f88f3a3f --- /dev/null +++ b/docs/guides/active/herdr-sync.md @@ -0,0 +1,15 @@ +# Maintaining the Herdr sync + +The weekly `Herdr sync` GitHub Actions workflow imports detection manifests, alias and authority metadata, and integration source assets from Herdr's default branch. It pins a released Herdr binary separately for differential verification. Run it manually with `gh workflow run herdr-sync.yml`; reproduce the import with `scripts/sync-herdr.sh`. + +The workflow opens or updates `bot/herdr-sync`. Its Go checks and both differential modes are posted as commit statuses on the exact published commit. An import or push failure does not validate an older sync branch. A failed check leaves the proposed changes available for review with a failing status. Merge only when both statuses pass. + +Staleness compares current upstream versions with the committed lock. An open sync PR exempts a stale bump only when its own pinned lock actually contains that bump. An old PR does not hide newer missing rules. + +The importer accepts the legacy authority table and the current integration/notes table in Herdr's agent documentation. Unknown table schemas, agent names, or note meanings fail before vendoring. Native self-reporting is recorded separately from installable lifecycle integrations. Imported authority metadata never grants a Sidecar integration tier; ported assets require their own proof. + +A sync can require changes to overlays or the engine. Remove a disable when upstream removes its target. Retire regex rewrites when upstream removes the rule, and replace obsolete synthetic fixtures with current upstream shapes. The Codex no-match fallback is `unknown` (`codex_state_ambiguous`), including through Sidecar's live detection path; lack of working evidence cannot announce completion. Explicit matched rules still determine their declared state. + +Validation: `GOWORK=off SIDECAR_REQUIRE_NODE=1 go test ./...`, `GOWORK=off go build ./...`, and `GOWORK=off golangci-lint run ./...`. With the pinned binary available, run `HERDR_BIN=/path/to/herdr scripts/herdr-diff.sh` and the same command with `--merged`. The default comparison permits named Sidecar overlay differences; merged mode must agree on every compared fixture. + +Vendoring a new agent does not register it as a Sidecar provider or install its hooks. Letta's source assets and manifest are available for a later deliberate port; existing integration ports remain independently versioned. diff --git a/internal/agentactivity/activity.go b/internal/agentactivity/activity.go index 8d347941b..3c74ee3fc 100644 --- a/internal/agentactivity/activity.go +++ b/internal/agentactivity/activity.go @@ -274,7 +274,7 @@ func identifyProcessName(command string) string { // // None of them needs the versioned-binary prefix rule: `muse` is the only // entry in the extracted versioned_binary_prefixes table. - case name == "cline": + case oneOf(name, "cline", ".cline"): return "cline" case oneOf(name, "devin", "devin cli", "devin-cli"): return "devin" diff --git a/internal/agentactivity/activity_test.go b/internal/agentactivity/activity_test.go index 2b516554f..3b2da057a 100644 --- a/internal/agentactivity/activity_test.go +++ b/internal/agentactivity/activity_test.go @@ -377,37 +377,15 @@ func TestRealPhase2ProviderFixtures(t *testing.T) { // upstream's; TestWeakBlockersKeepTheAttentionFlag is what proves the // one thing that changed. {"claude", "legacy_permission_wait.txt", "legacy_no_prompt_blocker", StateBlocked, false}, - // Grok's six fixtures. Every verdict is unchanged. Per fixture, - // old → new and why: - // - // idle.txt grok.title.idle → osc_title_idle - // interrupted.txt grok.title.idle → osc_title_idle - // Upstream rule better: the same "grok" / " - grok" form - // with the same braille exclusion, stated as a rule with a `not` - // gate rather than as a hand-written resolver step. - // working.txt grok.screen.working → sidecar.working_footer - // Sidecar behaviour preserved via overlay, and the one row where the - // overlay reports a verdict upstream would have reached anyway - // (`osc_title_working`, from the braille frame in the title). The - // footer rule has to outrank `osc_title_idle` at 1100 to do its job, - // which puts it above `osc_title_working` at 1000 as a side effect; - // the alternative is a rule that cannot fire when the title lags. - // overlay.txt grok.overlay.retain → sidecar.overlay_retain - // Sidecar-only behaviour preserved via overlay, narrower gate. - // stale_working_scrollback.txt - // grok.footer.idle → sidecar.idle_footer - // Sidecar-only behaviour preserved via overlay. Upstream trusts the - // title, which still carries a braille frame here. - // background_subagent.txt - // grok.screen.background-running - // → sidecar.background_subagent_working - // Sidecar-only behaviour preserved via overlay. + // Upstream handles Grok's title, spinner and background status. The + // overlay still handles a retained overlay and an idle footer beneath + // a stale working title; evidence identifies the rule that actually won. {"grok", "idle.txt", "osc_title_idle", StateIdle, false}, - {"grok", "working.txt", "sidecar.working_footer", StateWorking, false}, + {"grok", "working.txt", "spinner_status_working", StateWorking, false}, {"grok", "interrupted.txt", "osc_title_idle", StateIdle, false}, {"grok", "overlay.txt", "sidecar.overlay_retain", StateUnknown, true}, {"grok", "stale_working_scrollback.txt", "sidecar.idle_footer", StateIdle, false}, - {"grok", "background_subagent.txt", "sidecar.background_subagent_working", StateWorking, false}, + {"grok", "background_subagent.txt", "background_status_working", StateWorking, false}, // The permission prompt, captured live from Grok Build 1.0.13. Upstream // does describe it — three of its blockers match, the title rule wins — // which is what the capture settled and what retired the overlay rule @@ -787,6 +765,18 @@ func TestKnownLiveFallbackIdleNeverCreatesUnseenDone(t *testing.T) { for _, prior := range []State{StateWorking, StateBlocked} { t.Run(ob.Agent+"/"+string(prior), func(t *testing.T) { result := Detect(ob) + if ob.Agent == "codex" { + if result.State != StateUnknown || result.Evidence != "codex_state_ambiguous" || result.FallbackIdle { + t.Fatalf("ambiguous Codex result = %+v", result) + } + tracker := Tracker{State: prior, Evidence: "codex.prior"} + tracker.Apply(result, time.Unix(400, 0)) + tracker.Apply(result, time.Unix(400, 0).Add(IdleDebounce)) + if tracker.DisplayState() == "done" { + t.Fatalf("ambiguous Codex announced done: %+v", tracker) + } + return + } if result.State != StateIdle || !result.FallbackIdle { t.Fatalf("fallback result = %+v", result) } @@ -855,7 +845,7 @@ func TestGrokBackgroundSubagentBeatsIdleTitleAndFooter(t *testing.T) { "│ ❯ │\n" + "Shift+Tab:mode │ Ctrl+x:shortcuts\n", }) - if got.State != StateWorking || got.Evidence != "sidecar.background_subagent_working" { + if got.State != StateWorking || got.Evidence != "background_status_working" { t.Fatalf("background subagent got %+v", got) } } @@ -1152,18 +1142,14 @@ func TestMuseIsClassifiedByItsBundledOnlyManifest(t *testing.T) { } } -// A Codex turn is live for as long as the interrupt hint is on screen, and the -// composer is drawn the whole time. Before the Phase 2 review these two facts -// met in the wrong place: `sidecar.composer_idle` saw the `›` at priority 100, -// upstream's `screen_working_fallback` reads only the bottom three non-empty -// lines and so could not see the status line one tool-output row higher, and a -// pane in the middle of an apply_patch reported an explicit visible idle — which -// Tracker turns into a completed turn. +// A live Codex timer must outrank the composer that remains on screen during +// tool calls. Upstream recognizes the status above the current prompt; an idle +// composer must never make that active turn look completed to Tracker. func TestCodexRunningTurnBeatsTheComposerIdleRule(t *testing.T) { live := readObservationFixture(t, "codex", "tool_running_composer.txt") got := DetectCodex(live) - if got.State != StateWorking || got.Evidence != "sidecar.working_chrome" || !got.VisibleWorking { - t.Fatalf("running turn got %+v, want working/sidecar.working_chrome", got) + if got.State != StateWorking || got.Evidence != "screen_working_fallback" || !got.VisibleWorking { + t.Fatalf("running turn got %+v, want working/screen_working_fallback", got) } // The tracker consequence, stated rather than implied: this screen arriving @@ -1184,28 +1170,23 @@ func TestCodexRunningTurnBeatsTheComposerIdleRule(t *testing.T) { t.Fatalf("finished turn got %+v, want idle/sidecar.composer_idle", got) } - // The new working rule must not displace the background-terminal rule one - // priority step above it, whose fixture also carries an interrupt hint. - if got := DetectCodex(readObservationFixture(t, "codex", "background_terminal.txt")); got.Evidence != "sidecar.background_terminal_working" { - t.Fatalf("background terminal got %+v, want sidecar.background_terminal_working", got) + // Upstream now recognizes the background-terminal fixture too, with the + // same working state and its own evidence. + if got := DetectCodex(readObservationFixture(t, "codex", "background_terminal.txt")); got.Evidence != "screen_working_fallback" { + t.Fatalf("background terminal got %+v, want screen_working_fallback", got) } - // Nor upstream's own rule, on a screen upstream can see. An overlay rule - // that reports a verdict Herdr reaches without it is a rule to delete, and - // scripts/herdr-diff.sh fails the run when one does. + // A local rule must not displace upstream's evidence on its working fixture. + // The differential harness checks retained overlays for redundant behavior. if got := DetectCodex(readObservationFixture(t, "codex", "working.txt")); got.Evidence == "sidecar.working_chrome" { t.Fatalf("overlay displaced upstream's rule on a screen upstream can see: %+v", got) } } -// The two negatives the spot-check of `sidecar.working_chrome` asked for. The -// rule reads four times as far up the pane as upstream's `screen_working_fallback`, -// so both of the things upstream's narrow window protects it from — an -// interrupted turn's stale status line, and a copy of the status line quoted in -// prose — are inside this rule's region and have to be refused by the gate -// instead. +// Interrupted or quoted status lines must not count as live work, whether the +// evaluator reaches them through an upstream rule or a local working rule. func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) { - // Upstream's own `not` gate, carried onto this rule. The status line is - // still on screen with the banner beneath it and the composer beneath that. + // The stale status line remains above the interruption banner and composer; + // no working or explicit-idle rule should claim a live turn from it. interrupted := DetectCodex(Observation{ Agent: "codex", CurrentCommand: "codex", PaneTitle: "tasks", PaneHeight: 24, Screen: "• Working (45s • esc to interrupt) · editing files\n" + @@ -1217,13 +1198,10 @@ func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) if interrupted.Evidence == "sidecar.working_chrome" || interrupted.State == StateWorking { t.Fatalf("an interrupted turn read as working: %+v", interrupted) } - // It lands on the low-evidence fallback rather than on the composer idle, - // because the stale status line still carries the interrupt hint that is - // `sidecar.composer_idle`'s own `not` gate. That is the recorded choice — - // see manifests/sidecar/codex.toml — and what makes it acceptable is that a - // fallback idle establishes the lane without announcing anything. - if interrupted.State != StateIdle || interrupted.Evidence != "codex.known-live-fallback" || !interrupted.FallbackIdle { - t.Fatalf("interrupted turn got %+v, want idle/codex.known-live-fallback", interrupted) + // A stale interrupt hint refuses composer idle; absent a live rule, Codex + // stays unknown and cannot announce a completion. + if interrupted.State != StateUnknown || interrupted.Evidence != "codex_state_ambiguous" || interrupted.FallbackIdle { + t.Fatalf("interrupted turn got %+v, want unknown/codex_state_ambiguous", interrupted) } if interrupted.VisibleIdle { t.Fatalf("interrupted turn announced a visible idle: %+v", interrupted) @@ -1247,10 +1225,9 @@ func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) t.Fatalf("an indented quotation read as working: %+v", quoted) } - // The positive is unchanged, and it is the reason the anchor allows a glyph - // and a run of spaces rather than nothing at all. - if got := DetectCodex(readObservationFixture(t, "codex", "tool_running_composer.txt")); got.Evidence != "sidecar.working_chrome" { - t.Fatalf("live tool call got %+v, want sidecar.working_chrome", got) + // A real running tool still supplies positive working evidence upstream. + if got := DetectCodex(readObservationFixture(t, "codex", "tool_running_composer.txt")); got.Evidence != "screen_working_fallback" { + t.Fatalf("live tool call got %+v, want screen_working_fallback", got) } } @@ -1430,7 +1407,6 @@ func TestRewrittenAlphabeticRulesMatchTheScreensTheyTarget(t *testing.T) { // scripts/herdr-diff.sh use for an id with no provider, and a rewrite that // only worked through one of the two would be half dead. for _, tt := range []struct{ agent, fixture, rule string }{ - {"kiro", "tool_spinner_working.txt", "tool_spinner_working"}, {"qodercli", "spinner_working.txt", "spinner_working"}, } { ob := readObservationFixture(t, tt.agent, tt.fixture) diff --git a/internal/agentactivity/detection_only_test.go b/internal/agentactivity/detection_only_test.go index c7ef84c43..de294ce42 100644 --- a/internal/agentactivity/detection_only_test.go +++ b/internal/agentactivity/detection_only_test.go @@ -24,6 +24,7 @@ var familiesWithNoScreenManifest = map[string]string{ } var unregisteredManifests = map[string]string{ + "letta": "Vendored for review; process identification and integration support have not been ported to Sidecar.", "gemini": "Decision 4: Antigravity replaced it and `agy` is already a full family. " + "The manifest stays vendored because the sync mirrors the whole catalog, so registering it later is one alias line.", } diff --git a/internal/agentactivity/manifest/conformance_test.go b/internal/agentactivity/manifest/conformance_test.go index cce5f6dee..1406d4b42 100644 --- a/internal/agentactivity/manifest/conformance_test.go +++ b/internal/agentactivity/manifest/conformance_test.go @@ -3,10 +3,12 @@ package manifest_test // Conformance suite: Herdr's own manifest tests, ported. // // Every case below carries the name of the Rust test it came from -// (~/code/herdr/src/detect/manifest/tests.rs at e2b85c7) so a reviewer can map +// (originally ~/code/herdr/src/detect/manifest/tests.rs at e2b85c7) so a reviewer can map // the two files one to one. Herdr's tests are the executable specification of // the grammar; if one of these fails, the Go engine has diverged from the // engine the differential harness checks against, not merely from an opinion. +// Codex no-match cases follow Herdr v0.9.3 (331775c3): a static title no +// longer establishes idle, and no matching rule returns unknown. // // Nine of Herdr's 45 inline tests are NOT ported, all for the same reason: they // exercise Herdr's *loader* (its remote-manifest cache under the state @@ -62,15 +64,11 @@ import ( // engine reads a manifest the way Herdr's engine reads it. Running them against // the merged manifest would conflate that question with a second one — whether // Sidecar's overlay changes the verdict — and a deliberate overlay would then -// fail a test whose subject is the engine. Two of them would today: -// `sidecar.composer_idle` and the `osc_title_idle` disable in -// manifests/sidecar/codex.toml are exactly such a deliberate change, and -// TestTheCodexOverlayReplacesTitleIdleWithComposerIdle in the agentactivity -// package is what pins *that*. +// fail a test whose subject is the engine. Sidecar's composer-idle overlay is +// such a deliberate difference; agentactivity tests cover that behavior. // -// AllowIncompatibleRegex matches how production loads the vendored tree: four -// upstream rules use `\p{Alphabetic}`, which RE2 cannot compile, and each has an -// overlay carrying the rewrite. None of the four agents is exercised here. +// AllowIncompatibleRegex matches how production loads the vendored tree: +// upstream rules using `\p{Alphabetic}` need Sidecar's RE2 overlay rewrites. func bundled(t *testing.T, agent string) *manifest.Compiled { t.Helper() data, err := manifests.UpstreamBytes(agent + ".toml") @@ -106,7 +104,7 @@ func compileSource(t *testing.T, source string) *manifest.Compiled { // oscExplain is Herdr's osc_explain helper: a screen plus the two OSC strings. func oscExplain(t *testing.T, agent, screen, title, progress string) (manifest.Verdict, *manifest.Explain) { t.Helper() - return bundled(t, agent).Explain(manifest.Input{Screen: screen, Title: title, Progress: progress}) + return bundled(t, agent).Explain(manifest.Input{Agent: agent, Screen: screen, Title: title, Progress: progress}) } func matchedID(v manifest.Verdict) string { @@ -120,7 +118,7 @@ func matchedID(v manifest.Verdict) string { // Herdr: known_agent_no_match_defaults_to_idle_fallback. func TestKnownAgentNoMatchDefaultsToIdleFallback(t *testing.T) { - verdict := bundled(t, "codex").Evaluate(manifest.Input{Screen: "ordinary prompt text"}) + verdict := bundled(t, "pi").Evaluate(manifest.Input{Screen: "ordinary prompt text"}) if verdict.State != manifest.StateIdle { t.Fatalf("state = %q, want idle", verdict.State) } @@ -534,10 +532,10 @@ func TestCodexOSCTitleActionRequiredIsBlocked(t *testing.T) { } } -// Herdr: codex_osc_title_plain_is_idle. -func TestCodexOSCTitlePlainIsIdle(t *testing.T) { +// A static Codex title is no longer idle evidence in Herdr v0.9.3. +func TestCodexOSCTitlePlainIsAmbiguous(t *testing.T) { verdict, _ := oscExplain(t, "codex", "", "llm-proxy", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" || !verdict.VisibleIdle { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.VisibleIdle || verdict.FallbackReason != manifest.CodexStateAmbiguous { t.Fatalf("verdict = %+v rule = %q", verdict, matchedID(verdict)) } } @@ -565,15 +563,15 @@ func TestCodexTrustDirectoryRequiresLiveTopRegion(t *testing.T) { "Do you trust the contents of this\n" + "directory? Working with untrusted contents comes with higher risk.\n" verdict, _ = oscExplain(t, "codex", transcript, "project", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) == "trust_directory" || verdict.VisibleBlocker { + if verdict.State != manifest.StateUnknown || matchedID(verdict) == "trust_directory" || verdict.VisibleBlocker { t.Fatalf("transcribed trust text: verdict = %+v rule = %q", verdict, matchedID(verdict)) } } -// Herdr: codex_background_terminal_screen_does_not_override_osc_idle. -func TestCodexBackgroundTerminalScreenDoesNotOverrideOSCIdle(t *testing.T) { +// A background terminal line alone does not establish the foreground turn. +func TestCodexBackgroundTerminalWithoutTurnEvidenceIsAmbiguous(t *testing.T) { verdict, _ := oscExplain(t, "codex", "background terminal running · /ps to view · /stop to close\n", "llm-proxy", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" || !verdict.VisibleIdle { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.VisibleIdle || verdict.FallbackReason != manifest.CodexStateAmbiguous { t.Fatalf("verdict = %+v rule = %q", verdict, matchedID(verdict)) } } @@ -638,7 +636,7 @@ func TestCodexWeakBlockerIgnoresFinishedResponseAboveCurrentPrompt(t *testing.T) "─ Worked for 4m 59s ─\n\n" + "› Ask Codex to do anything\n" verdict, _ := oscExplain(t, "codex", screen, "project", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.FallbackReason != manifest.CodexStateAmbiguous { t.Fatalf("verdict = %+v rule = %q", verdict, matchedID(verdict)) } } @@ -649,7 +647,7 @@ func TestCodexWeakBlockerIgnoresWrappedCurrentPromptText(t *testing.T) { " [y/N] / esc and whether the docs should include it\n\n" + " gpt-5.6-sol default · /work\n" verdict, _ := oscExplain(t, "codex", screen, "project", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.FallbackReason != manifest.CodexStateAmbiguous { t.Fatalf("verdict = %+v rule = %q", verdict, matchedID(verdict)) } } @@ -684,8 +682,8 @@ func TestCodexScreenWorkingFallbackIgnoresStaleAndPromptText(t *testing.T) { } for i, screen := range screens { verdict, _ := oscExplain(t, "codex", screen, "project", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" || - !verdict.VisibleIdle || verdict.VisibleWorking { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.FallbackReason != manifest.CodexStateAmbiguous || + verdict.VisibleIdle || verdict.VisibleWorking { t.Fatalf("screen %d: verdict = %+v rule = %q", i, verdict, matchedID(verdict)) } } @@ -697,8 +695,8 @@ func TestCodexScreenWorkingFallbackIgnoresInterruptedShortTerminal(t *testing.T) "■ Conversation interrupted\n" + "›\n" verdict, _ := oscExplain(t, "codex", screen, "project", "") - if verdict.State != manifest.StateIdle || matchedID(verdict) != "osc_title_idle" || - !verdict.VisibleIdle || verdict.VisibleWorking { + if verdict.State != manifest.StateUnknown || matchedID(verdict) != "" || verdict.FallbackReason != manifest.CodexStateAmbiguous || + verdict.VisibleIdle || verdict.VisibleWorking { t.Fatalf("verdict = %+v rule = %q", verdict, matchedID(verdict)) } } @@ -834,8 +832,8 @@ func TestEveryVendoredManifestCompiles(t *testing.T) { if err != nil { t.Fatal(err) } - if len(agents) != 21 { - t.Fatalf("vendored %d manifests, expected Herdr's 21", len(agents)) + if len(agents) == 0 { + t.Fatal("no vendored manifests; compilation would test nothing") } for _, agent := range agents { compiled, source, err := manifests.Load(agent) diff --git a/internal/agentactivity/manifest/engine_test.go b/internal/agentactivity/manifest/engine_test.go index 649f0cc4e..d50a447ea 100644 --- a/internal/agentactivity/manifest/engine_test.go +++ b/internal/agentactivity/manifest/engine_test.go @@ -513,7 +513,7 @@ contains = ["token"] func TestExplainOnNoMatchRecordsTheFallbackReason(t *testing.T) { compiled := compileSource(t, ` -id = "codex" +id = "pi" [[rules]] id = "never" @@ -532,6 +532,48 @@ contains = ["absent"] } } +// Herdr v0.9.3's Codex fallback preserves uncertainty without changing other +// agents, and follows the requested family when a local override has another id. +func TestNoMatchUsesTheRequestedAgentFallback(t *testing.T) { + for _, tt := range []struct { + name, manifestID, agent string + state manifest.State + reason string + }{ + {"codex manifest", "codex", "", manifest.StateUnknown, manifest.CodexStateAmbiguous}, + {"codex requested", "pi", "codex", manifest.StateUnknown, manifest.CodexStateAmbiguous}, + {"pi manifest", "pi", "", manifest.StateIdle, manifest.DefaultKnownAgentIdleFallback}, + {"pi requested", "codex", "pi", manifest.StateIdle, manifest.DefaultKnownAgentIdleFallback}, + } { + t.Run(tt.name, func(t *testing.T) { + compiled := compileSource(t, `id = "`+tt.manifestID+`" +version = "2026.09.23.1" +[[rules]] +id = "active" +state = "working" +visible_working = true +contains = ["active-marker"] +`) + input := manifest.Input{Agent: tt.agent, Screen: "unmatched-marker"} + verdict, explain := compiled.Explain(input) + if verdict.State != tt.state || verdict.FallbackReason != tt.reason || verdict.MatchedRule != nil || verdict.VisibleIdle || verdict.VisibleWorking || verdict.VisibleBlocker || verdict.SkipStateUpdate { + t.Fatalf("no-match verdict = %+v", verdict) + } + if plain := compiled.Evaluate(input); plain != verdict { + t.Fatalf("Evaluate = %+v, Explain = %+v", plain, verdict) + } + if explain.State != tt.state || explain.FallbackReason != tt.reason || explain.ManifestVersion != "2026.09.23.1" || len(explain.EvaluatedRules) != 1 { + t.Fatalf("fallback lost context: %+v", explain) + } + input.Screen = "active-marker" + matched := compiled.Evaluate(input) + if matched.State != manifest.StateWorking || matched.MatchedRule == nil || !matched.VisibleWorking || matched.FallbackReason != "" { + t.Fatalf("a matched rule must retain its verdict: %+v", matched) + } + }) + } +} + // --- overlays -------------------------------------------------------------- const overlayUpstream = ` diff --git a/internal/agentactivity/manifest/evaluate.go b/internal/agentactivity/manifest/evaluate.go index b377f8fc0..d6a5d7082 100644 --- a/internal/agentactivity/manifest/evaluate.go +++ b/internal/agentactivity/manifest/evaluate.go @@ -23,7 +23,7 @@ type Verdict struct { // SkippedUpdateReason is Herdr's "matched_rule:" when the matched rule // carries skip_state_update, and empty otherwise. SkippedUpdateReason string - // FallbackReason is DefaultKnownAgentIdleFallback when no rule matched. + // FallbackReason identifies the agent-specific outcome when no rule matched. FallbackReason string } @@ -190,6 +190,17 @@ func (c *Compiled) evaluate(in Input, explain bool) (Verdict, *Explain) { if winner == nil { verdict := Verdict{State: StateIdle, FallbackReason: DefaultKnownAgentIdleFallback} + // Herdr's fallback_explain at 331775c3 does not assume Codex is idle + // without a matching rule. Use the requested agent even when a local + // override declares another id; direct manifest callers can omit it. + agent := in.Agent + if agent == "" { + agent = c.Manifest.ID + } + if agent == "codex" { + verdict.State = StateUnknown + verdict.FallbackReason = CodexStateAmbiguous + } return verdict, c.explainRecord(in.Agent, verdict, evaluated, explain) } diff --git a/internal/agentactivity/manifest/manifest.go b/internal/agentactivity/manifest/manifest.go index d9c230e98..ad7783488 100644 --- a/internal/agentactivity/manifest/manifest.go +++ b/internal/agentactivity/manifest/manifest.go @@ -33,10 +33,14 @@ import ( const EngineVersion = 3 // DefaultKnownAgentIdleFallback is the reason Herdr records when a known agent -// matches no rule. Kept here so the evaluator and the explain record agree on -// the exact string Herdr emits. +// other than Codex matches no rule. Kept here so the evaluator and explain +// record agree on the exact string Herdr emits. const DefaultKnownAgentIdleFallback = "default_known_agent_idle_fallback" +// CodexStateAmbiguous is Herdr's no-match reason for Codex: a static title or +// absence of a working marker does not establish that a Codex turn is idle. +const CodexStateAmbiguous = "codex_state_ambiguous" + // DefaultRegion is the region a rule uses when it declares none. const DefaultRegion = "whole_recent" diff --git a/internal/agentactivity/manifest/window.go b/internal/agentactivity/manifest/window.go index 8335c56a3..61e56f264 100644 --- a/internal/agentactivity/manifest/window.go +++ b/internal/agentactivity/manifest/window.go @@ -28,8 +28,8 @@ type Input struct { // for every vendored file and can differ under a local override, which may // declare one agent's id while carrying another's alias. Reporting the id // there would answer a question nobody asked, about a file rather than - // about the pane. Empty on a path with no requested agent; nothing about - // evaluation depends on it. + // about the pane. The no-match fallback uses the requested agent; when it + // is empty, direct manifest evaluation uses the loaded manifest's id. Agent string // Screen is the raw capture. It may carry SGR escapes and scrollback; the // engine strips and bounds it itself, so callers pass what tmux gave them. diff --git a/internal/agentactivity/manifest_detect.go b/internal/agentactivity/manifest_detect.go index 525a96dfe..c502fd020 100644 --- a/internal/agentactivity/manifest_detect.go +++ b/internal/agentactivity/manifest_detect.go @@ -251,6 +251,9 @@ func manifestInput(ob Observation) (*manifest.Compiled, manifest.Input, Result, // itself carries is upstream's. func manifestVerdict(agent string, verdict manifest.Verdict) Result { if verdict.MatchedRule == nil { + if verdict.State != manifest.StateIdle { + return Result{State: State(verdict.State), Evidence: verdict.FallbackReason} + } return Result{ State: StateIdle, Evidence: agent + ".known-live-fallback", diff --git a/internal/agentactivity/manifests/fetch_test.go b/internal/agentactivity/manifests/fetch_test.go index 243ad7ca4..55ac10bbe 100644 --- a/internal/agentactivity/manifests/fetch_test.go +++ b/internal/agentactivity/manifests/fetch_test.go @@ -322,7 +322,7 @@ func TestTheSidecarOverlayMergesOntoAFetchedManifest(t *testing.T) { } // codexWithRenamedRule is a served codex manifest that the Sidecar overlay -// cannot merge onto: codex's overlay disables upstream's `osc_title_idle`, and a +// cannot merge onto: codex's overlay replaces upstream's `weak_blocker`, and a // served file with no such rule is exactly what a rename upstream looks like. const codexWithRenamedRule = ` id = "codex" @@ -330,8 +330,8 @@ version = "9999.01.01.1" min_engine_version = 1 [[rules]] -id = "renamed_idle" -state = "idle" +id = "renamed_weak_blocker" +state = "blocked" priority = 100 region = "whole_recent" contains = ["fetched codex marker"] @@ -341,15 +341,9 @@ contains = ["fetched codex marker"] // maintainer has to be told about: upstream renamed a rule the overlay replaces, // so the merge refuses. // -// This test used to pin the opposite answer -- cache the file, drop the overlay, -// run the fetched file alone -- and that was wrong. codex's overlay carries six -// rules, cursor's four, claude's five, including the `osc_title_idle` disable, -// the `weak_blocker` replacement and the `\p{Alphabetic}` RE2 rewrites. Running -// a newer upstream file with all of that stripped out is a detection regression -// bought with a version bump, and it would have arrived silently on the day -// upstream renamed one rule id. Known-good detection wins: the file is not -// cached, the status file names the agent and the merge error, and re-cutting -// the overlay is what makes the next check take it. +// Sidecar overlays carry local rules and RE2 rewrites. Running a newer file +// without them would silently lose detection. Keep the known-good manifest, +// report the merge error, and require the overlay to be reconciled first. func TestAFetchedManifestTheOverlayCannotMergeOntoIsNotCached(t *testing.T) { remoteState(t) server := newCatalogServer(t, map[string]string{ @@ -368,6 +362,9 @@ func TestAFetchedManifestTheOverlayCannotMergeOntoIsNotCached(t *testing.T) { if !strings.Contains(status.LastError, "overlay no longer fits") { t.Fatalf("codex error does not say the overlay stopped fitting: %q", status.LastError) } + if !strings.Contains(status.LastError, "weak_blocker") { + t.Fatalf("codex error does not identify the missing replacement target: %q", status.LastError) + } if _, err := os.Stat(RemoteCachePath("codex")); err == nil { t.Fatal("a manifest the overlay cannot merge onto was written to the cache") } @@ -412,6 +409,9 @@ func TestACachedManifestTheOverlayCannotMergeOntoFallsBackToVendored(t *testing. !strings.Contains(source.Diagnostic, RemoteCachePath("codex")) { t.Fatalf("diagnostic does not name the cached file and the reason: %q", source.Diagnostic) } + if !strings.Contains(source.Diagnostic, "weak_blocker") { + t.Fatalf("fallback does not identify the missing replacement target: %q", source.Diagnostic) + } if compiled.Evaluate(manifest.Input{Screen: "fetched codex marker\n"}).MatchedRule != nil { t.Fatal("the abandoned cached manifest still classified a screen") } diff --git a/internal/agentactivity/manifests/manifests_test.go b/internal/agentactivity/manifests/manifests_test.go index 10e7bf537..c1672f991 100644 --- a/internal/agentactivity/manifests/manifests_test.go +++ b/internal/agentactivity/manifests/manifests_test.go @@ -116,7 +116,6 @@ func TestVendoredManifestsMatchLock(t *testing.T) { var knownRegexIncompatibilities = map[string]string{ "antigravity.toml spinner_working line_regex": `\p{Alphabetic}`, "cursor.toml spinner_working line_regex": `\p{Alphabetic}`, - "kiro.toml tool_spinner_working line_regex": `\p{Alphabetic}`, "qodercli.toml spinner_working line_regex": `\p{Alphabetic}`, } diff --git a/internal/agentactivity/manifests/sidecar/codex.toml b/internal/agentactivity/manifests/sidecar/codex.toml index a435a80d9..eab29f0be 100644 --- a/internal/agentactivity/manifests/sidecar/codex.toml +++ b/internal/agentactivity/manifests/sidecar/codex.toml @@ -1,43 +1,13 @@ -# Sidecar overlay for codex. -# -# Five rules and one disable. In order down the file: a main turn parked on a -# background terminal is working; explicit idle needs the composer rather than a -# non-empty tmux pane title; a turn whose status line has been pushed off the -# bottom three lines by tool output is still working; the approval prompt whose -# option line empties both of upstream's prompt-marker-relative regions is -# blocked; and upstream's `weak_blocker` is replaced by id so that the blocked -# lane it puts a pane on still raises Sidecar's attention flag. -# -# --- The background terminal --- -# -# Herdr's codex.toml has no background-terminal rule at all. Upstream treats a -# background terminal as something the user started and the agent is not waiting -# on, so a pane in that state falls through to `osc_title_idle` and reads idle. -# That is right for the case Herdr models, and wrong for the one Sidecar -# captured (testdata/codex/background_terminal.txt): Codex itself renders -# "Waiting for background terminal (… esc to interrupt)" while the *turn* is -# parked on the command, and calling that idle makes the tracker announce a -# completed turn while the release script is still running. -# -# The gate is the pre-manifest rule's, unchanged, and it is three conjunctions -# deep on purpose. It needs the waiting line at the head of a line, a *running* -# count of at least one, and both of Codex's live hints (`/ps to view`, -# `/stop to close`) in either order. A finished wait ("Waited for background -# terminal") has no running count; a long-lived helper someone left open has no -# waiting line. Only the live shape matches, which is what -# TestCodexBackgroundTerminalRequiresCurrentRunningChrome pins. -# -# Priority 500 is upstream's own working tier (`screen_working_fallback`), so -# every blocker still outranks it — `live_strong_blocker` 900, `trust_directory` -# 950, `weak_blocker` 600, `osc_title_blocked` 1100 — and it beats the idle rule -# whose verdict it exists to displace: upstream's `osc_title_idle` (100) on -# Herdr's side, `sidecar.composer_idle` at the same priority here. -# -# This is a candidate for an upstream pull request: the fixture is real and the -# rule is not Sidecar-specific. Delete it the day Herdr carries an equivalent. +# Sidecar overlay for Codex. Local rules preserve captured behavior; upstream +# lifecycle authority and the no-match fallback remain separate. id = "codex" +# A turn waiting on a running background terminal is working. Require the +# waiting line, a positive running count, and both terminal-control hints; +# a finished wait or a helper running on its own must not match. Upstream's +# screen_working_fallback also recognizes the captured background-terminal +# fixture. At equal priority, the upstream rule remains the reported evidence. [[rules]] id = "sidecar.background_terminal_working" state = "working" @@ -46,57 +16,10 @@ region = "bottom_non_empty_lines(12)" visible_working = true regex = ['(?ims)^\s*[•◦]?\s*Waiting for background terminal\b.*[1-9][0-9]* background terminals? running\b.*(?:/ps to view.*?/stop to close|/stop to close.*?/ps to view)'] -# --- Codex idle must be the composer, not the pane title --- -# -# Upstream reaches idle through `osc_title_idle`, whose whole matcher is -# `regex = ['\S']` on the title: any non-empty title with no spinner frame and -# no "Action Required" in it is an explicit, visible idle. That is sound on -# Herdr's input, where `osc_title` is a decoded OSC 0/2 payload and genuinely -# empty until the program writes one. It is not sound on Sidecar's input. tmux -# seeds `#{pane_title}` with the host name, so a Sidecar pane's title is -# effectively never empty — which turns "no rule matched" into "explicitly -# idle", visible flag and all, on every unmatched Codex screen. The tracker -# turns a visible idle out of working into a completed turn, so any screen -# upstream has no rule for would announce a completion that never happened. -# -# The two rules below put that back the way it was before the cutover: explicit -# idle needs the composer on screen, and everything else falls through to -# `codex.known-live-fallback`, which is low-evidence and cannot announce a -# completion. `sidecar.composer_idle` is the deleted `codex.screen.idle` at -# upstream's own idle priority, plus the `not` gate the paragraph below is -# about. -# -# Delete both the day Sidecar can hand the engine a real OSC title rather than -# tmux's `#{pane_title}` — at that point upstream's rule is correct here too. - -[[rules]] -id = "osc_title_idle" -disable = true - -# The `not` gate is not decoration, and it is the half of this rule the first -# cut got wrong. Codex's composer stays on screen *during* a turn: while a tool -# runs, the pane reads -# -# • Working (45s • esc to interrupt) · editing files -# └ apply_patch /Users/…/file.go -# -# › ⏎ send ⌃J newline -# gpt-5.6-sol low · ~/code/tasks -# -# which puts the status line four non-empty lines from the bottom. Upstream's -# `screen_working_fallback` reads `bottom_non_empty_lines(3)` and so cannot see -# it; without the gate this rule saw the `›` at 100, nothing outranked it, and a -# pane with a plain or lagging title read as a *visible* idle in the middle of a -# tool call — which the tracker turns into a completed turn. That is the exact -# false "done" the whole plan opened with, reintroduced by a rule meant to -# prevent it. -# -# The gate is the interrupt hint, over this rule's own region, because that hint -# is on screen for exactly as long as the turn is live: Codex prints it beside -# the elapsed timer and clears it when the turn ends. `sidecar.working_chrome` -# below is the positive half — the gate alone would only demote the screen to -# the low-evidence fallback, and a turn that is running should read as working -# rather than as unknown. +# Explicit idle requires the composer with no interrupt hint in this region. +# Codex draws its composer during active tool calls too, so the negative gate +# prevents a visible idle while the turn is still running. Herdr no longer has +# osc_title_idle: a plain title supplies no completion evidence. [[rules]] id = "sidecar.composer_idle" state = "idle" @@ -106,69 +29,12 @@ visible_idle = true line_regex = ['^\s*›(?:\s|$)'] not = [{ regex = ['(?i)esc to interrupt'] }] -# The status line a running turn paints, read deep enough to still be visible -# when tool output has pushed it up the pane. -# -# This is the deleted `codex.screen.working` (`Working \(.*esc to interrupt\)` -# over the last twelve lines) restored, with upstream's own anchoring and -# upstream's own interrupted gate. Upstream carries the same signal as -# `screen_working_fallback` and carries it better — its pattern spells out the -# trailing " · " clause and it excludes an interrupted turn — but it -# reads only the bottom three non-empty lines, which is the bottom of an *idle* -# pane. One tool call's worth of output moves the line out of that window, and -# this rule is what still sees it there. Everything else about the rule is -# upstream's, and deliberately so: it exists to extend upstream's reach, not to -# disagree with it. -# -# Two narrowings the spot-check of the first cut asked for, both of them -# upstream's own text: -# -# 1. `^[•◦]\s+Working \(` rather than `^\s*[•◦]?\s*Working \(`. The deleted Go -# rule matched the phrase unanchored, but the chrome Codex actually paints -# is a status glyph in column 0 — `background_terminal.txt` and -# `tool_running_composer.txt` both show it there — so an indented line -# quoting the phrase inside prose is not this rule's screen. Without the -# anchor a turn that pastes its own status line into the transcript reads -# as live work for as long as the paste stays in the bottom twelve lines. -# 2. `not = [{ contains = ["■ Conversation interrupted"] }]`, copied verbatim -# from `screen_working_fallback`. An interrupted turn leaves the status -# line on screen with the banner beneath it; upstream refuses to call that -# working and so must this rule, which reads four times as far up the pane -# and therefore sees the pair more often, not less. -# -# What the interrupted screen then resolves to is worth stating, because it is -# not the composer idle and that was a deliberate choice. On -# -# • Working (45s • esc to interrupt) · editing files -# └ apply_patch … -# -# ■ Conversation interrupted -# -# › ⏎ send ⌃J newline -# gpt-5.6-sol low · ~/code/tasks -# -# this rule is gated off by the banner and `sidecar.composer_idle` is gated off -# by the interrupt hint the stale status line still carries, so nothing matches -# and the pane reads `codex.known-live-fallback` — a low-evidence idle that -# establishes the lane without announcing a completion. The alternative -# considered was to teach `sidecar.composer_idle` to ignore its own interrupt -# gate when the banner is present, which would make this screen an *explicit* -# visible idle. That is rejected: the same edit would also fire on an -# interrupted banner still sitting in the scrollback above a turn the user has -# since restarted, and an explicit visible idle mid-turn is exactly the false -# "done" this whole overlay exists to prevent. A quiet idle on an interrupted -# pane costs one unannounced completion; the other direction costs a completion -# announced while a tool call is running. -# -# Priority 490 is deliberately one step *below* upstream's 500 rather than above -# it. Both rules report the same state with the same visible flag, so on a -# screen upstream can see there is nothing to gain by displacing it and two -# things to lose: the evidence string stops naming upstream's rule, and -# scripts/herdr-diff.sh correctly reports a `sidecar.` rule that reaches the -# verdict Herdr reaches without it as REDUNDANT. Below 500 this rule fires only -# where upstream's cannot, which is the only place it is needed. -# -# Delete it the day upstream's rule reads more than three lines. +# This narrow Working/timer match reads the last twelve non-empty lines and +# refuses quoted or interrupted status. Upstream's broader working rule now +# reads before_current_prompt_marker and wins on the captured running-tool +# screen. Keep upstream's priority higher so its evidence remains visible. +# When an interrupted screen matches neither a working rule nor composer idle, +# the result is codex_state_ambiguous, never a completion. [[rules]] id = "sidecar.working_chrome" state = "working" @@ -178,40 +44,14 @@ visible_working = true line_regex = ['^[•◦]\s+Working \(.*esc to interrupt\)'] not = [{ contains = ["■ Conversation interrupted"] }] -# The approval prompt Codex paints with its own composer glyph on the option -# line, which is the one blocker shape upstream cannot see. -# -# Upstream has two blockers below the title: `live_strong_blocker` reads -# `after_last_prompt_marker` and `weak_blocker` reads -# `whole_recent_without_current_prompt_marker`. Both are defined relative to the -# Codex prompt marker, and this screen puts a `›` on the *option* line: -# -# Would you like to run the following command? -# › 1. Yes, proceed (y) -# 2. No, and tell Codex what to do differently (esc) -# -# So the last prompt marker is the option itself — nothing is below it, and the -# without-marker region is empty. Both regions are blank and neither blocker can -# fire. Before the cutover `codex.screen.blocked` matched this alternation -# anywhere in the last eighteen lines and Sidecar read it as blocked; -# testdata/codex/approval_prompt.txt is the fixture, and -# testdata/codex/blocked.txt is the same screen with "Action Required" in the -# title, which is the only reason upstream catches that one. -# -# Priority 700 is between `live_strong_blocker` (900) and `weak_blocker` (600): -# a stronger, more specific blocker still wins, and this still outranks every -# working and idle rule. The alternation is the deleted rule's, verbatim. -# -# It is deliberately left un-narrowed, and the Phase 2 review looked at this. An -# *answered* prompt still inside the bottom twelve non-empty lines keeps -# reporting blocked over the turn that follows it, because the question stays on -# screen after the answer and there is nothing in the alternation to say the -# answer arrived. Upstream solves that structurally, with prompt-marker-relative -# regions — which is exactly what this rule exists because it cannot use. A `not` -# gate would have to name whatever Codex paints beneath an answered prompt, and -# no capture of that screen exists to write one from. The behaviour is the -# pre-manifest rule's, unchanged, and it errs towards an attention badge on a -# pane that no longer needs one rather than towards a missed prompt. +# Codex uses its composer glyph on an approval option. That can empty both +# prompt-relative blocker regions, so this rule reads the recent question +# directly. Its priority sits below live_strong_blocker and above weak_blocker. +# +# An answered question still in these twelve lines can continue to report +# blocked. No captured post-answer shape justifies narrowing that gate yet; +# retain attention rather than miss an unanswered approval. The captured +# approval_prompt.txt and blocked.txt screens protect this behavior. [[rules]] id = "sidecar.approval_blocker" state = "blocked" @@ -220,30 +60,10 @@ region = "bottom_non_empty_lines(12)" visible_blocker = true regex = ['(?i)(Action Required|Would you like to run|Press enter to confirm|Allow command)'] -# --- The weak blocker keeps Sidecar's attention nag --- -# -# Upstream's rule, verbatim, with `visible_blocker = true` added. -# -# The engine takes the visible flags from the matched rule rather than from the -# state, which is Herdr's semantics and is what the cutover adopted. Upstream -# declares `weak_blocker` blocked with no `visible_blocker`, so a pane matching -# it lands on the blocked lane carrying no attention flag and the surfaces that -# key an alert on `Tracker.VisibleBlocker` stay quiet. That is a deliberate -# upstream choice — the rule is weak and high-recall, meant to classify rather -# than to interrupt — and it is a behaviour change here: before the cutover -# every blocked verdict Sidecar minted nagged. What this rule is the last resort -# for is `[y/n]`, `yes (y)` and a "do you want to" / "would you like to" -# question beside a yes option, all of which are questions waiting on the user, -# and a blocked lane nobody is told about is a pane sitting on a question. -# -# Nothing else about the rule is changed: same priority, same region, same -# `any` list. `scripts/herdr-diff.sh` therefore still requires this rule to -# AGREE with Herdr on state and rule id — the harness does not compare the -# visible flags — which is what keeps the copy honest against a re-sync. -# -# Delete this overlay if a captured screen shows the nag is wrong: a shape this -# rule reaches that is not a question waiting on the user. -# testdata/codex/weak_blocker.txt is the fixture it is kept for. +# Upstream's weak blocker, with visible_blocker=true for Sidecar's attention +# badge. State, region, priority and matching stay aligned with upstream; the +# differential harness verifies them. Remove this replacement if the captured +# weak_blocker.txt question no longer merits attention or upstream adds the flag. [[rules]] id = "weak_blocker" state = "blocked" diff --git a/internal/agentactivity/manifests/sidecar/kiro.toml b/internal/agentactivity/manifests/sidecar/kiro.toml deleted file mode 100644 index 55945772b..000000000 --- a/internal/agentactivity/manifests/sidecar/kiro.toml +++ /dev/null @@ -1,29 +0,0 @@ -# Sidecar overlay for kiro. -# -# Sole purpose: carry an RE2 spelling of the one upstream rule Go's regexp -# cannot compile. Rust's regex crate supports Unicode binary properties by -# name, so upstream writes \p{Alphabetic}; RE2 supports only general -# categories and scripts, so it rejects the pattern outright and the engine -# would evaluate the rule as a permanent no-match. -# -# `Alphabetic` is `L + Nl + Other_Alphabetic`. RE2 has no Other_Alphabetic, -# whose members are combining marks and a handful of symbols, so -# [\p{L}\p{Nl}] is the closest sound rewrite. It differs from upstream only -# for a word that *starts* with a combining mark, which no agent renders as -# the first character of a status word. See -# docs/reference/herdr-detection-parity.md ("Regex compatibility"). -# -# The rule id matches upstream's, so this replaces that rule in place rather -# than adding one. Delete this file the day upstream stops using the property -# or RE2 grows it. - -id = "kiro" - -[[rules]] -id = "tool_spinner_working" -state = "working" -priority = 90 -region = "whole_recent" -visible_working = true -contains = ["esc to cancel"] -line_regex = ['^\s*(◔|◑|◕|●)\s+[\p{L}\p{Nl}]'] diff --git a/internal/agentactivity/testdata/kiro/live_working_footer.txt b/internal/agentactivity/testdata/kiro/live_working_footer.txt new file mode 100644 index 000000000..93bc15a8e --- /dev/null +++ b/internal/agentactivity/testdata/kiro/live_working_footer.txt @@ -0,0 +1,8 @@ +source: synthetic, from Herdr kiro.toml 2026.09.19.1 live_working_footer literals. +why: current Kiro working footer remains classified by the upstream rule. +pane_current_command: kiro +pane_title: unavailable +state: working +screen: +Kiro is working +Type to steer · ctrl+s to queue diff --git a/internal/agentactivity/testdata/kiro/tool_spinner_working.txt b/internal/agentactivity/testdata/kiro/tool_spinner_working.txt deleted file mode 100644 index 7b4659978..000000000 --- a/internal/agentactivity/testdata/kiro/tool_spinner_working.txt +++ /dev/null @@ -1,20 +0,0 @@ -source: synthetic. There is no Kiro on the harvest machine and no capture of - one; Kiro became a Sidecar provider in Phase 4, as one of the ten - detection-only families, and was not one when this was written. This - screen is built to the shape upstream's `tool_spinner_working` rule - describes — one of the four quarter-circle spinner frames, a space, a - word, and the "esc to cancel" hint the same rule requires — and to - nothing else. -why: manifests/sidecar/kiro.toml rewrites that rule's `\p{Alphabetic}` as - `[\p{L}\p{Nl}]` because RE2 cannot compile the property, and until this - fixture the rewrite had no coverage of any kind: no fixture, no census - row, no differential-harness row. A dead pattern there would have stayed - dead silently. This fixture is what proves the rewritten rule still - matches the screen the original targets, and the harness proves Herdr's - own engine reaches the same verdict on the original. -pane_current_command: kiro -pane_title: unavailable -state: working -screen: -● Reading internal/agentactivity/activity.go -esc to cancel diff --git a/internal/agentcatalog/families/cline.toml b/internal/agentcatalog/families/cline.toml index 8bf367c40..94d80910f 100644 --- a/internal/agentcatalog/families/cline.toml +++ b/internal/agentcatalog/families/cline.toml @@ -14,6 +14,7 @@ # Resume is `--id`, not `--resume`. Herdr's agent_resume.rs has no Cline entry at # all, so this is from Cline's own reference rather than from upstream. id = "cline" +aliases = [".cline"] order = 110 name = "Cline" short = "Cline" diff --git a/internal/agentcatalog/testdata/catalog.golden b/internal/agentcatalog/testdata/catalog.golden index 0aa244e59..34e944b32 100644 --- a/internal/agentcatalog/testdata/catalog.golden +++ b/internal/agentcatalog/testdata/catalog.golden @@ -124,7 +124,7 @@ short Cline command cline skipPerms - aliases + aliases .cline adapter cline resumeKinds id launch cline diff --git a/internal/agentcontrol/integration_test.go b/internal/agentcontrol/integration_test.go index 869505142..4b36415dd 100644 --- a/internal/agentcontrol/integration_test.go +++ b/internal/agentcontrol/integration_test.go @@ -23,17 +23,10 @@ func TestIsolatedTmuxFakeProviderSteelThread(t *testing.T) { t.Cleanup(func() { _ = exec.Command("tmux", "kill-session", "-t", session).Run() }) terminal := NewLocalTerminal() target := Target{Host: "local", Project: "fixture", Session: session, Namespace: tmuxenv.Namespace()} - detect := func(s Snapshot, _ *agentactivity.Tracker) AgentState { - status := StatusUnknown - latest := -1 - for marker, candidate := range map[string]Status{"FAKE_IDLE": StatusIdle, "FAKE_WORKING": StatusWorking, "FAKE_DONE": StatusDone, "FAKE_BLOCKED": StatusBlocked} { - if at := strings.LastIndex(s.Screen, marker); at > latest { - latest = at - status = candidate - } - } - return AgentState{Kind: "fake", Status: status, Freshness: "current", Evidence: "fake.screen", CapturedAt: s.CapturedAt} - } + // Do not identify the provider until its first marker appears. Claiming + // Kind=fake while the launch line is only echoed makes Start interpret the + // next shell snapshot as a provider that exited, bypassing StartGrace. + detect := fakeProviderDetect // This fake provider runs as `sh`, so its pane keeps reporting an // interactive shell for the agent's whole life — the one shape shellReady // cannot distinguish from a pane where nothing started. With the default diff --git a/internal/agentcontrol/service_test.go b/internal/agentcontrol/service_test.go index b466dc200..63a50292d 100644 --- a/internal/agentcontrol/service_test.go +++ b/internal/agentcontrol/service_test.go @@ -84,7 +84,7 @@ func TestStartPinsTargetAndReturnsOnlyAtPositiveReady(t *testing.T) { } } -func TestGetReportsKnownLiveFallbackAsInferredIdle(t *testing.T) { +func TestGetReportsUnclassifiedCodexAsUnknown(t *testing.T) { snapshot := pinnedSnapshot("stable composer without an explicit prompt marker") snapshot.CurrentCommand = "node" snapshot.ProcessIdentity = "codex" @@ -94,8 +94,8 @@ func TestGetReportsKnownLiveFallbackAsInferredIdle(t *testing.T) { if err != nil { t.Fatal(err) } - if got.Agent.Kind != "codex" || got.Agent.Status != StatusIdle || !got.Agent.InteractiveReady || got.Agent.Evidence != "codex.known-live-fallback" { - t.Fatalf("Get() = %+v, want positively identified inferred idle Codex", got) + if got.Agent.Kind != "codex" || got.Agent.Status != StatusUnknown || got.Agent.InteractiveReady || got.Agent.Evidence != "codex_state_ambiguous" { + t.Fatalf("Get() = %+v, want identified but unclassified Codex without interactive readiness", got) } } diff --git a/internal/cli/agent_explain_file_test.go b/internal/cli/agent_explain_file_test.go index b8eeab32f..5743708f9 100644 --- a/internal/cli/agent_explain_file_test.go +++ b/internal/cli/agent_explain_file_test.go @@ -143,12 +143,9 @@ func TestExplainFileTextOutputFollowsHerdrsLayout(t *testing.T) { if code != 0 { t.Fatalf("exit %d (stderr: %s)", code, errOut) } - // The rule is "none" and the state idle from the fallback: the Sidecar - // overlay disables upstream's `osc_title_idle`, whose whole matcher is `\S` - // on the title, because tmux seeds `#{pane_title}` and would make every - // unmatched Codex screen an explicit idle. `quiet` carries no composer, so - // nothing matches. See manifests/sidecar/codex.toml. - for _, want := range []string{"agent: codex", "state: idle", "manifest: bundled codex ", "rule: none", "fallback_reason: default_known_agent_idle_fallback", "evaluated_rules:"} { + // A plain tmux title and unmatched screen do not prove Codex is idle. + // The public explanation must preserve the engine's ambiguous fallback. + for _, want := range []string{"agent: codex", "state: unknown", "manifest: bundled codex ", "rule: none", "fallback_reason: codex_state_ambiguous", "evaluated_rules:"} { if !strings.Contains(out, want) { t.Fatalf("output is missing %q:\n%s", want, out) } diff --git a/internal/tools/herdrsync/authority_table_test.go b/internal/tools/herdrsync/authority_table_test.go new file mode 100644 index 000000000..584f47c48 --- /dev/null +++ b/internal/tools/herdrsync/authority_table_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "strings" + "testing" + + "github.com/marcus/sidecar/internal/agentactivity/manifests" +) + +// The September 2026 docs put less-tested agents in the table, allow empty +// notes, and move Muse to the separate native-support list. +const integrationAgentsMDX = ` +### Supported by Herdr + +| Agent | Integration | Notes | +| --- | --- | --- | +| Claude Code | ` + "`claude`" + ` | | +| Codex | ` + "`codex`" + ` | | +| GitHub Copilot CLI | ` + "`copilot`" + ` | | +| Cursor Agent CLI | ` + "`cursor`" + ` | | +| OpenCode | ` + "`opencode`" + ` | also reports state | +| Pi | ` + "`pi`" + ` | also reports state | +| OMP | ` + "`omp`" + ` | state requires the integration | +| Droid | ` + "`droid`" + ` | | +| Devin CLI | ` + "`devin`" + ` | | +| Kimi Code CLI | ` + "`kimi`" + ` | also reports state | +| Kilo Code CLI | ` + "`kilo`" + ` | also reports state | +| Hermes Agent | ` + "`hermes`" + ` | | +| Qoder CLI | ` + "`qodercli`" + ` | | +| Qwen Code | ` + "`qwen`" + ` | | +| Letta Code | ` + "`letta`" + ` | CLI install only | +| MastraCode | ` + "`mastracode`" + ` | state requires the integration | +| Grok CLI | ` + "`grok`" + ` | | +| Antigravity CLI | ` + "`antigravity-cli`" + ` | | +| Amp | none | state only | +| Kiro CLI | none | state only | +| Maki | none | state only | +| Gemini CLI | none | state only, less tested | +| Cline | none | state only, less tested | + +### Supported by the agent + +These agents report their own state to Herdr. There is nothing to install. + +- [Crush](https://github.com/charmbracelet/crush) +- Command Code +- Muse +- [Prime Agent](https://github.com/PrimeIntellect-ai/prime-agent) + +## How status works + +Further prose. +` + +func TestExtractAuthorityFromIntegrationTable(t *testing.T) { + src := stubAssets() + src.files[authoritySource] = integrationAgentsMDX + authority, err := stubAuthority(src) + if err != nil { + t.Fatal(err) + } + if len(authority.Agents) != 24 { + t.Fatalf("got %d detector agents, want 24", len(authority.Agents)) + } + for _, id := range []string{"pi", "omp", "kimi", "kilo", "opencode", "mastracode"} { + if got := authority.Agents[id].LifecycleAuthority; got != manifests.AuthorityHooks { + t.Errorf("%s authority = %q, want hooks", id, got) + } + } + for _, id := range []string{"claude", "codex", "copilot", "cursor", "droid", "devin", "hermes", "qodercli", "qwen", "letta", "grok", "agy"} { + if got := authority.Agents[id].LifecycleAuthority; got != manifests.AuthoritySessionIdentity { + t.Errorf("%s authority = %q, want session identity", id, got) + } + } + for _, id := range []string{"amp", "kiro", "maki", "gemini", "cline", "muse"} { + if got := authority.Agents[id].LifecycleAuthority; got != manifests.AuthorityNone { + t.Errorf("%s authority = %q, want no Herdr integration", id, got) + } + } + if got := authority.Agents["muse"]; got.StateAuthority != "agent self-report" || got.IntegrationRole != "native state reporting" { + t.Errorf("Muse's native state source was lost: %+v", got) + } + if got := authority.Agents["claude"].IntegrationVersion; got != 9 { + t.Errorf("Claude asset version = %d, want 9", got) + } +} + +func TestExtractAuthorityAcceptsLettaInLegacyTable(t *testing.T) { + doc := strings.Replace(stubAgentsMDX, "| Amp |", "| Letta Code | screen manifest | session |\n| Amp |", 1) + agents, err := parseAuthorityTable(doc) + if err != nil { + t.Fatal(err) + } + if got := agents["letta"].LifecycleAuthority; got != manifests.AuthoritySessionIdentity { + t.Fatalf("Letta authority = %q, want session identity", got) + } +} + +func TestAuthorityTableRefusesUnrecognizedChanges(t *testing.T) { + tests := map[string]string{ + "unknown header": strings.Replace(integrationAgentsMDX, "| Integration | Notes |", "| Support | Notes |", 1), + "extra column": strings.Replace(integrationAgentsMDX, "| Claude Code | `claude` | |", "| Claude Code | `claude` | | extra |", 1), + "unknown agent": strings.Replace(integrationAgentsMDX, "| Claude Code |", "| Mystery Agent |", 1), + "wrong integration identity": strings.Replace(integrationAgentsMDX, "`claude`", "`codex`", 1), + "unknown notes": strings.Replace(integrationAgentsMDX, "also reports state", "might report state", 1), + "contradictory notes": strings.Replace(integrationAgentsMDX, "| Amp | none | state only |", "| Amp | none | also reports state |", 1), + "duplicate agent": strings.Replace(integrationAgentsMDX, "| Codex | `codex` | |", "| Claude Code | `claude` | |", 1), + "missing native section": strings.Replace(integrationAgentsMDX, "### Supported by the agent", "### Native agents", 1), + "unknown native agent": strings.Replace(integrationAgentsMDX, "- Muse", "- Mystery Agent", 1), + "duplicated native provider": strings.Replace(integrationAgentsMDX, "- Muse", "- Pi", 1), + "unknown legacy authority": strings.Replace(stubAgentsMDX, "screen manifest", "new source", 1), + "unknown legacy role": strings.Replace(stubAgentsMDX, "| session |", "| optional hooks |", 1), + "missing legacy appendix": strings.Replace(stubAgentsMDX, "Detected but less thoroughly tested:", "Other agents:", 1), + } + for name, doc := range tests { + t.Run(name, func(t *testing.T) { + if _, err := parseAuthorityTable(doc); err == nil { + t.Fatal("accepted a changed upstream contract") + } + }) + } +} diff --git a/internal/tools/herdrsync/extract.go b/internal/tools/herdrsync/extract.go index 2ff90d153..0199ff03f 100644 --- a/internal/tools/herdrsync/extract.go +++ b/internal/tools/herdrsync/extract.go @@ -182,6 +182,7 @@ var authorityDisplayNames = map[string]string{ "Hermes Agent": "hermes", "Qoder CLI": "qodercli", "Qwen Code": "qwen", + "Letta Code": "letta", "Droid": "droid", "OpenCode": "opencode", "Kilo Code CLI": "kilo", @@ -204,7 +205,7 @@ var authorityDisplayNames = map[string]string{ var assetDirAgent = map[string]string{"antigravity_cli": "agy"} var ( - authorityRowRE = regexp.MustCompile(`(?m)^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$`) + authorityRowRE = regexp.MustCompile(`^\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$`) // "Detected but less thoroughly tested: Gemini CLI and Cline." lessTestedRE = regexp.MustCompile(`Detected but less thoroughly tested:\s*([^.]+)\.`) integrationVersion = regexp.MustCompile(`HERDR_INTEGRATION_VERSION=(\d+)`) @@ -252,32 +253,79 @@ func extractAuthority(src source, ref string, assets []integrationAssetDir) (*ma }, nil } +// parseAuthorityTable supports the original authority table and its replacement +// integration table. Recognize the header first: treating an integration name as +// authority prose would silently downgrade every provider to "none". func parseAuthorityTable(content string) (map[string]manifests.AuthorityAgent, error) { agents := map[string]manifests.AuthorityAgent{} - for _, row := range authorityRowRE.FindAllStringSubmatch(content, -1) { - name := strings.TrimSpace(row[1]) - stateAuthority := strings.TrimSpace(row[2]) - role := strings.TrimSpace(row[3]) - if name == "Agent" || strings.HasPrefix(name, "---") { + format := "" + inTable := false + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "|") { + inTable = false + continue + } + row := authorityRowRE.FindStringSubmatch(line) + if row == nil { + return nil, fmt.Errorf("malformed agents.mdx table row %q; expected three columns", line) + } + name, second, third := strings.TrimSpace(row[1]), strings.TrimSpace(row[2]), strings.TrimSpace(row[3]) + if name == "Agent" { + if format != "" { + return nil, fmt.Errorf("multiple agents.mdx agent tables; the doc shape changed") + } + switch { + case second == "State authority" && third == "Integration role": + format = "authority" + case second == "Integration" && third == "Notes": + format = "integration" + default: + return nil, fmt.Errorf("unknown agents.mdx table columns %q and %q", second, third) + } + inTable = true + continue + } + if !inTable { + return nil, fmt.Errorf("agents.mdx table row %q has no recognized header", name) + } + if name == "---" && second == "---" && third == "---" { continue } id, ok := authorityDisplayNames[name] if !ok { return nil, fmt.Errorf("agents.mdx lists %q, which has no agent id mapping; add it to authorityDisplayNames", name) } + if _, exists := agents[id]; exists { + return nil, fmt.Errorf("agents.mdx lists %q more than once", name) + } + stateAuthority, role := second, third + if format == "integration" { + var err error + stateAuthority, role, err = integrationAuthority(id, second, third) + if err != nil { + return nil, fmt.Errorf("agents.mdx %s: %w", name, err) + } + } else if !knownLegacyAuthority(stateAuthority, role) { + return nil, fmt.Errorf("agents.mdx %s has unknown authority %q or integration role %q", name, stateAuthority, role) + } agents[id] = manifests.AuthorityAgent{ - DisplayName: name, - LifecycleAuthority: lifecycleAuthority(stateAuthority, role), - StateAuthority: stateAuthority, - IntegrationRole: role, + DisplayName: name, LifecycleAuthority: lifecycleAuthority(stateAuthority, role), + StateAuthority: stateAuthority, IntegrationRole: role, } } if len(agents) == 0 { return nil, fmt.Errorf("no rows found in the agents.mdx authority table; the table shape changed") } + if format == "integration" { + if err := addNativeAuthority(content, agents); err != nil { + return nil, err + } + return agents, nil + } - // The prose line after the table names the agents Herdr detects but does - // not list as a table row. They have a screen manifest and no integration. + // The legacy table keeps its less-tested agents in prose. In the new + // format they are ordinary table rows, so this line is no longer required. match := lessTestedRE.FindStringSubmatch(content) if match == nil { return nil, fmt.Errorf("could not find the \"Detected but less thoroughly tested\" line; the doc shape changed") @@ -287,17 +335,103 @@ func parseAuthorityTable(content string) (map[string]manifests.AuthorityAgent, e if !ok { return nil, fmt.Errorf("agents.mdx names %q as less thoroughly tested, which has no agent id mapping", name) } - if _, exists := agents[id]; exists { + if _, exists := agents[id]; !exists { + agents[id] = manifests.AuthorityAgent{ + DisplayName: name, LifecycleAuthority: manifests.AuthorityNone, + StateAuthority: "screen manifest", IntegrationRole: "none", + } + } + } + return agents, nil +} + +func knownLegacyAuthority(state, role string) bool { + switch state { + case "screen manifest": + return role == "session" || role == "none" + case "lifecycle hooks when installed; otherwise screen manifest", "lifecycle plugin when installed; otherwise screen manifest", "lifecycle hooks when installed": + return role == "state and session" + default: + return false + } +} + +// Current Herdr docs say every installed integration supplies session identity; +// only these exact notes also promise lifecycle reporting. Unknown combinations +// fail for review rather than accidentally granting authority from loose prose. +func integrationAuthority(id, integration, notes string) (string, string, error) { + if integration == "none" { + if notes == "state only" || notes == "state only, less tested" { + return "screen manifest", "none", nil + } + return "", "", fmt.Errorf("unknown notes %q for no integration", notes) + } + command := id + if id == "agy" { + command = "antigravity-cli" + } + if integration != "`"+command+"`" { + return "", "", fmt.Errorf("unknown integration %q for %q", integration, id) + } + switch notes { + case "", "CLI install only": + return "screen manifest", "session", nil + case "also reports state": + if id == "opencode" || id == "kilo" { + return "lifecycle plugin when installed; otherwise screen manifest", "state and session", nil + } + return "lifecycle hooks when installed; otherwise screen manifest", "state and session", nil + case "state requires the integration": + return "lifecycle hooks when installed", "state and session", nil + default: + return "", "", fmt.Errorf("unknown integration notes %q", notes) + } +} + +// The native-support list is distinct from Herdr's installable integrations. +// Retain known detector identities such as Muse, but do not invent detector IDs +// for native-only agents. "none" means no Herdr integration, not no native state. +func addNativeAuthority(content string, agents map[string]manifests.AuthorityAgent) error { + _, section, ok := strings.Cut(content, "### Supported by the agent\n") + if !ok { + return fmt.Errorf("could not find the agents.mdx native-support section; the doc shape changed") + } + section, _, _ = strings.Cut(section, "\n## ") + count := 0 + for _, line := range strings.Split(section, "\n") { + if !strings.HasPrefix(line, "- ") { continue } + name := strings.TrimSpace(strings.TrimPrefix(line, "- ")) + if strings.HasPrefix(name, "[") { + label, _, found := strings.Cut(strings.TrimPrefix(name, "["), "](") + if !found || !strings.HasSuffix(name, ")") { + return fmt.Errorf("malformed native agent entry %q", name) + } + name = label + } + count++ + id, known := authorityDisplayNames[name] + if !known { + switch name { + case "Crush", "Command Code", "Prime Agent": + continue // Native-only providers have no upstream detector ID. + default: + return fmt.Errorf("unknown native agent %q in agents.mdx", name) + } + } + if _, exists := agents[id]; exists { + return fmt.Errorf("agents.mdx lists %q as both Herdr-supported and native", name) + } agents[id] = manifests.AuthorityAgent{ - DisplayName: name, - LifecycleAuthority: manifests.AuthorityNone, - StateAuthority: "screen manifest", - IntegrationRole: "none", + DisplayName: name, LifecycleAuthority: manifests.AuthorityNone, + StateAuthority: "agent self-report", IntegrationRole: "native state reporting", } } - return agents, nil + if count == 0 { + return fmt.Errorf("no entries in the agents.mdx native-support list; the doc shape changed") + } + return nil } // lifecycleAuthority derives the three-valued authority from the two prose diff --git a/internal/tools/herdrsync/herdrsync_test.go b/internal/tools/herdrsync/herdrsync_test.go index 52d39eb22..b81f51b0c 100644 --- a/internal/tools/herdrsync/herdrsync_test.go +++ b/internal/tools/herdrsync/herdrsync_test.go @@ -171,11 +171,29 @@ func TestSyncPinsTheAttributionFiles(t *testing.T) { // TestSyncReproducesTheCommittedSourceChoices is the plan's "published-versus- // bundled choice recorded in the lock matches what the tool decides" check. func TestSyncReproducesTheCommittedSourceChoices(t *testing.T) { - _, fresh, _ := syncIntoTemp(t) committed, err := manifests.LoadLock() if err != nil { t.Fatalf("LoadLock: %v", err) } + if committed.Herdr.Commit == "" { + t.Fatal("the committed lock has no source commit to replay") + } + source := herdrSource(t) + if _, err := newDirSource(source, committed.Herdr.Commit); err != nil { + t.Skipf("local Herdr checkout cannot replay committed pin %s: %v", committed.Herdr.Commit, err) + } + // Historical fixture tests remain pinned to their original source. This + // round trip instead follows the moving committed lock, reading all source + // and distribution bytes at that exact commit without fetching upstream. + report, err := sync(options{ + ref: committed.Herdr.Commit, releaseTag: committed.Herdr.PinnedReleaseTag, + catalogURL: defaultCatalogURL, sourceDir: source, offline: true, + out: t.TempDir(), integrationOut: t.TempDir(), + }) + if err != nil { + t.Fatalf("replay committed source pin: %v", err) + } + fresh := report.Lock if len(fresh.Agents) != len(committed.Agents) { t.Fatalf("fresh sync produced %d agents, the committed lock has %d", len(fresh.Agents), len(committed.Agents)) @@ -197,14 +215,7 @@ func TestSyncReproducesTheCommittedSourceChoices(t *testing.T) { t.Errorf("%s: fresh sync version %s, committed %s", agent.ID, agent.Version, want.Version) } } - // The two documented exceptions, asserted by name so a change upstream is - // a review conversation rather than a silent flip. - if grok, ok := fresh.Agent("grok"); !ok || grok.Source != manifests.SourceBundled { - t.Errorf("grok source = %+v, want bundled (published 2026.07.16.1 is older than bundled 2026.07.16.2)", grok) - } - if muse, ok := fresh.Agent("muse"); !ok || muse.Source != manifests.SourceBundled || muse.PublishedVersion != "" { - t.Errorf("muse source = %+v, want bundled only (it is not in the published catalog)", muse) - } + } // TestDirSourceReadsBytesFromTheRequestedRef is the guard for the bug where the diff --git a/scripts/test-herdr-staleness.sh b/scripts/test-herdr-staleness.sh new file mode 100755 index 000000000..a8a846f9d --- /dev/null +++ b/scripts/test-herdr-staleness.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Exercise the real workflow decision with local locks and a stubbed GitHub API. +set -euo pipefail + +repo=$(cd "$(dirname "$0")/.." && pwd) +test_dir=$(mktemp -d "${TMPDIR:-/tmp}/sidecar-herdr-staleness.XXXXXX") +trap 'rm -rf "$test_dir"' EXIT +mkdir -p "$test_dir/bin" "$test_dir/scripts" "$test_dir/internal/agentactivity/manifests" +cp "$repo/scripts/herdr-staleness.sh" "$test_dir/scripts/" + +# Run the actual Actions shell block so a regression to "any open PR is enough" +# fails this test. No copy of the workflow's decision logic lives in the test. +awk ' + /- name: Compare against the committed lock/ { found = 1; next } + found && /^ run: \|$/ { body = 1; next } + body && /^ / { print substr($0, 11); next } + body && /^$/ { print; next } + body { exit } +' "$repo/.github/workflows/herdr-sync.yml" > "$test_dir/compare.sh" +[ -s "$test_dir/compare.sh" ] || { echo "missing workflow comparison step" >&2; exit 1; } + +cat > "$test_dir/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +if [ "$1 $2" = 'pr list' ]; then + [[ " $* " == *' --base main '* ]] || exit 91 + if [ "$TEST_REVIEW" != absent ]; then + printf '{"number":42,"headRefOid":"abc123"}\n' + fi +elif [ "$1" = api ]; then + [[ "$2" == *'?ref=abc123' ]] || exit 92 + base64 < "$TEST_PROPOSAL" +else + echo "unexpected gh request: $*" >&2 + exit 93 +fi +GH +chmod +x "$test_dir/bin/gh" + +cat > "$test_dir/fresh.json" <<'JSON' +{"agents":[ + {"id":"codex","version":"2026.09.23.1","updated_at":"2000-01-01T00:00:00Z"}, + {"id":"letta","version":"2026.09.15.1","updated_at":"2000-01-01T00:00:00Z"} +]} +JSON +cat > "$test_dir/old.json" <<'JSON' +{"agents":[{"id":"codex","version":"2026.8.9.1","updated_at":"2000-01-01T00:00:00Z"}]} +JSON +cat > "$test_dir/partial.json" <<'JSON' +{"agents":[{"id":"codex","version":"2026.09.23.1","updated_at":"2000-01-01T00:00:00Z"}]} +JSON + +check_case() { + local name=$1 committed=$2 review=$3 proposal=$4 expected=$5 + cp "$test_dir/$committed.json" "$test_dir/internal/agentactivity/manifests/upstream.lock.json" + local status=0 + ( + cd "$test_dir" + export PATH="$test_dir/bin:$PATH" + export HERDR_FRESH_LOCK="$test_dir/fresh.json" + export GITHUB_STEP_SUMMARY="$test_dir/summary" RUNNER_TEMP="$test_dir" + export GITHUB_REPOSITORY=example/sidecar SYNC_BRANCH=bot/herdr-sync BASE_BRANCH=main MAX_AGE_DAYS=14 + export TEST_REVIEW="$review" TEST_PROPOSAL="$test_dir/$proposal.json" + bash "$test_dir/compare.sh" + ) > "$test_dir/output" 2>&1 || status=$? + if [ "$status" -ne "$expected" ]; then + cat "$test_dir/output" >&2 + echo "$name: got exit $status, expected $expected" >&2 + exit 1 + fi + echo "PASS $name" +} + +check_case 'current committed lock needs no review' fresh absent old 0 +check_case 'stale committed lock with no review fails' old absent old 1 +check_case 'open stale review does not hide drift' old present old 1 +check_case 'review missing a new agent still fails' old present partial 1 +check_case 'review containing the actual bumps passes' old present fresh 0 + +# A recent bump is not overdue; invalid inputs are execution failures, not a +# clean bill of health. These exercise the helper independently of GitHub. +jq '.agents |= map(.updated_at = "2999-01-01T00:00:00Z")' "$test_dir/fresh.json" > "$test_dir/recent.json" +[ -z "$("$repo/scripts/herdr-staleness.sh" "$test_dir/recent.json" "$test_dir/old.json" 14)" ] +if "$repo/scripts/herdr-staleness.sh" "$test_dir/fresh.json" "$test_dir/old.json" invalid >/dev/null 2>&1; then + echo 'invalid age was accepted' >&2 + exit 1 +fi +echo 'PASS recent bump and invalid age' From d498bb607ff581e214971952ed4fcc41a9cb1aac Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Wed, 30 Sep 2026 07:13:30 -0700 Subject: [PATCH 2/3] refactor(herdr): retire working overlays covered by upstream --- internal/agentactivity/activity_test.go | 20 +++-- .../manifests/sidecar/codex.toml | 28 ------- .../agentactivity/manifests/sidecar/grok.toml | 75 ++----------------- 3 files changed, 15 insertions(+), 108 deletions(-) diff --git a/internal/agentactivity/activity_test.go b/internal/agentactivity/activity_test.go index 3b2da057a..ed9a61100 100644 --- a/internal/agentactivity/activity_test.go +++ b/internal/agentactivity/activity_test.go @@ -1175,16 +1175,14 @@ func TestCodexRunningTurnBeatsTheComposerIdleRule(t *testing.T) { if got := DetectCodex(readObservationFixture(t, "codex", "background_terminal.txt")); got.Evidence != "screen_working_fallback" { t.Fatalf("background terminal got %+v, want screen_working_fallback", got) } - // A local rule must not displace upstream's evidence on its working fixture. - // The differential harness checks retained overlays for redundant behavior. - if got := DetectCodex(readObservationFixture(t, "codex", "working.txt")); got.Evidence == "sidecar.working_chrome" { - t.Fatalf("overlay displaced upstream's rule on a screen upstream can see: %+v", got) + // Working title evidence remains valid alongside screen-based activity. + if got := DetectCodex(readObservationFixture(t, "codex", "working.txt")); got.State != StateWorking || !got.VisibleWorking { + t.Fatalf("captured working screen lost visible working evidence: %+v", got) } } -// Interrupted or quoted status lines must not count as live work, whether the -// evaluator reaches them through an upstream rule or a local working rule. -func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) { +// Interrupted or quoted status lines must not count as live work. +func TestCodexWorkingStatusRefusesInterruptedAndQuotedStatusLines(t *testing.T) { // The stale status line remains above the interruption banner and composer; // no working or explicit-idle rule should claim a live turn from it. interrupted := DetectCodex(Observation{ @@ -1195,7 +1193,7 @@ func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) "› ⏎ send ⌃J newline\n" + " gpt-5.6-sol low · ~/code/tasks\n", }) - if interrupted.Evidence == "sidecar.working_chrome" || interrupted.State == StateWorking { + if interrupted.State == StateWorking || interrupted.VisibleWorking { t.Fatalf("an interrupted turn read as working: %+v", interrupted) } // A stale interrupt hint refuses composer idle; absent a live rule, Codex @@ -1206,9 +1204,9 @@ func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) if interrupted.VisibleIdle { t.Fatalf("interrupted turn announced a visible idle: %+v", interrupted) } - tracker := Tracker{State: StateWorking, Evidence: "sidecar.working_chrome"} + tracker := Tracker{State: StateWorking, Evidence: "screen_working_fallback"} if tracker.Apply(interrupted, time.Unix(300, 0)) && tracker.DisplayState() == "done" { - t.Fatalf("a fallback idle announced done: %+v", tracker) + t.Fatalf("ambiguous interrupted evidence announced done: %+v", tracker) } // The anchor. Codex paints the status glyph in column 0; an indented copy of @@ -1221,7 +1219,7 @@ func TestCodexWorkingChromeRefusesInterruptedAndQuotedStatusLines(t *testing.T) "› \n" + " gpt-5.6-sol low · ~/code/tasks\n", }) - if quoted.Evidence == "sidecar.working_chrome" || quoted.State == StateWorking { + if quoted.State == StateWorking || quoted.VisibleWorking { t.Fatalf("an indented quotation read as working: %+v", quoted) } diff --git a/internal/agentactivity/manifests/sidecar/codex.toml b/internal/agentactivity/manifests/sidecar/codex.toml index eab29f0be..0e92bdcbb 100644 --- a/internal/agentactivity/manifests/sidecar/codex.toml +++ b/internal/agentactivity/manifests/sidecar/codex.toml @@ -3,19 +3,6 @@ id = "codex" -# A turn waiting on a running background terminal is working. Require the -# waiting line, a positive running count, and both terminal-control hints; -# a finished wait or a helper running on its own must not match. Upstream's -# screen_working_fallback also recognizes the captured background-terminal -# fixture. At equal priority, the upstream rule remains the reported evidence. -[[rules]] -id = "sidecar.background_terminal_working" -state = "working" -priority = 500 -region = "bottom_non_empty_lines(12)" -visible_working = true -regex = ['(?ims)^\s*[•◦]?\s*Waiting for background terminal\b.*[1-9][0-9]* background terminals? running\b.*(?:/ps to view.*?/stop to close|/stop to close.*?/ps to view)'] - # Explicit idle requires the composer with no interrupt hint in this region. # Codex draws its composer during active tool calls too, so the negative gate # prevents a visible idle while the turn is still running. Herdr no longer has @@ -29,21 +16,6 @@ visible_idle = true line_regex = ['^\s*›(?:\s|$)'] not = [{ regex = ['(?i)esc to interrupt'] }] -# This narrow Working/timer match reads the last twelve non-empty lines and -# refuses quoted or interrupted status. Upstream's broader working rule now -# reads before_current_prompt_marker and wins on the captured running-tool -# screen. Keep upstream's priority higher so its evidence remains visible. -# When an interrupted screen matches neither a working rule nor composer idle, -# the result is codex_state_ambiguous, never a completion. -[[rules]] -id = "sidecar.working_chrome" -state = "working" -priority = 490 -region = "bottom_non_empty_lines(12)" -visible_working = true -line_regex = ['^[•◦]\s+Working \(.*esc to interrupt\)'] -not = [{ contains = ["■ Conversation interrupted"] }] - # Codex uses its composer glyph on an approval option. That can empty both # prompt-relative blocker regions, so this rule reads the recent question # directly. Its priority sits below live_strong_blocker and above weak_blocker. diff --git a/internal/agentactivity/manifests/sidecar/grok.toml b/internal/agentactivity/manifests/sidecar/grok.toml index 9ada37ce6..d9debf4fc 100644 --- a/internal/agentactivity/manifests/sidecar/grok.toml +++ b/internal/agentactivity/manifests/sidecar/grok.toml @@ -1,77 +1,14 @@ # Sidecar overlay for grok. # -# Five rules. Four of them are about the same thing: Grok's OSC title and its -# footer can disagree, and upstream settles that disagreement in favour of the -# title. A fifth rule, `sidecar.allow_prompt_blocked`, is deleted rather than -# carried: a live Grok Build 1.0.13 capture showed it was written for a screen no -# Grok release paints, and that upstream's own blockers reach both of the real -# permission prompts. `permission_hints_blocked` (1190) is the one upstream -# blocker that misses 1.0.13 — it wants `ctrl+o:yolo` where the footer writes -# `Ctrl+o:always-approve` — and the three that hit make that harmless. See the -# reference document's "What the Grok capture measured". -# -# Upstream's grok.toml says so in its own header comment — the title is the -# primary signal and the screen rules exist for older Grok Build releases — and -# it states it as priorities: `osc_title_idle` is 1100 and `osc_title_working` is -# 1000, where `spinner_status_working` is 200 and `prompt_hints_idle` is 100. On -# Herdr's input that is right, because Herdr reads a decoded OSC 0 payload the -# moment the program writes it. -# -# Sidecar reads tmux's `#{pane_title}`, which is the same string one repaint -# later, and Sidecar observed both directions of the lag on live Grok 1.0: -# a braille frame left in the title after the turn ended (false working), and -# the pane's own footer is what said so. The two footer rules below put the -# footer above the title in both directions, which is the rule the deleted Go -# probe encoded by evaluating footer chrome before any title decision. -# -# The other two carry screens upstream has no rule for at all: a parked main -# turn whose subagents are still running, and the startup/resume overlay. -# -# The priority ladder, upstream's numbers included: -# -# 1300 osc_title_blocked (upstream) -# 1200 option_dialog_blocked (upstream) -# 1190 permission_hints_blocked (upstream) -# 1185 question_dialog_hints_blocked (upstream) -# 1180 permission_scope_selector (upstream) -# 1170 background_work_chip_working (upstream) -# 1160 sidecar.background_subagent_working parked turn, live subagents -# 1150 osc_progress_working (upstream; always empty here) -# 1140 sidecar.overlay_retain startup/resume overlay -# 1120 sidecar.working_footer Esc:cancel beats a cleared title -# 1100 osc_title_idle (upstream) -# 1050 sidecar.idle_footer idle footer beats a stale title -# 1000 osc_title_working (upstream) -# ... upstream's screen rules, unchanged -# -# Every upstream blocker still outranks all five, which is the invariant that -# matters: nothing here can hide a permission prompt upstream can describe. +# Three rules cover the startup/resume overlay and disagreements between the +# visible footer and tmux's pane title. Herdr now recognizes the parked turn's +# background-subagent status directly through background_status_working, which +# outranks an idle title. Local working and idle footer rules still protect the +# captured cases where the title has cleared early or retained a stale spinner. +# Every upstream permission blocker outranks these local rules. id = "grok" -# A main turn parked on background subagents restores an idle-*looking* title -# and an idle-looking footer while a status row above the prompt still says the -# work is live: -# -# ○ 1 subagent still running · send a message to interrupt -# -# Upstream has `background_work_chip_working` for Grok's animated chip on the -# pinned top row, and nothing for this row. Without it the pane reads idle from -# its title and the tracker announces a completed turn while subagents are still -# working — which is the exact false "done" this whole plan opened with. -# testdata/grok/background_subagent.txt is the fixture and -# TestGrokBackgroundSubagentBeatsIdleTitleAndFooter pins it. -# -# Priority 1160 puts it one step below upstream's own chip rule, in the same -# tier and for the same reason: background work outranks an idle title. -[[rules]] -id = "sidecar.background_subagent_working" -state = "working" -priority = 1160 -region = "bottom_non_empty_lines(14)" -visible_working = true -regex = ['(?i)[1-9][0-9]*\s+subagents?\s+still\s+running|send a message to interrupt'] - # Grok's startup / resume overlay, which upstream has no rule for. It replaces # the whole pane while the title still reads "grok", so upstream alone reads it # as an explicit visible idle — and a visible idle out of working is a completed From 38fff842961254ba0f3a426be957fa582414adb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:17:57 +0000 Subject: [PATCH 3/3] chore(herdr): sync detection manifests to v0.9.3 Vendored from herdr master by go run ./internal/tools/herdrsync. --- .../manifests/aliases.upstream.json | 6 + .../manifests/authority.upstream.json | 24 +- internal/agentactivity/manifests/report.md | 879 ++++++++++++++++-- .../manifests/upstream.lock.json | 139 +-- .../agentactivity/manifests/upstream/NOTICE | 2 +- .../manifests/upstream/claude.toml | 28 +- .../manifests/upstream/cline.toml | 41 +- .../manifests/upstream/codex.toml | 39 +- .../manifests/upstream/grok.toml | 39 +- .../manifests/upstream/index.toml | 8 + .../manifests/upstream/kiro.toml | 110 ++- .../manifests/upstream/letta.toml | 95 ++ .../agentactivity/manifests/upstream/pi.toml | 12 +- internal/agentintegration/upstream.lock.json | 118 ++- internal/agentintegration/upstream/NOTICE | 2 +- .../upstream/claude/herdr-agent-state.ps1 | 2 +- .../upstream/claude/herdr-agent-state.sh | 2 +- .../upstream/codex/herdr-agent-state.ps1 | 14 +- .../upstream/codex/herdr-agent-state.sh | 27 +- .../upstream/grok/herdr-agent-state.ps1 | 20 +- .../upstream/grok/herdr-agent-state.sh | 20 +- .../upstream/herdr-agent-state.test.ts | 68 +- .../upstream/letta/herdr-agent-session.ps1 | 51 + .../upstream/letta/herdr-agent-session.sh | 56 ++ .../upstream/omp/herdr-agent-state.ts | 8 +- .../upstream/opencode/herdr-agent-state.js | 37 +- .../opencode/herdr-agent-state.test.ts | 76 +- .../upstream/opencode/herdr-tui-session.js | 615 ++++++++++-- .../opencode/herdr-tui-session.test.ts | 542 ++++++++++- .../agentintegration/upstream/opencode/tui.js | 5 + .../upstream/pi/herdr-agent-state.ts | 8 +- 31 files changed, 2712 insertions(+), 381 deletions(-) create mode 100644 internal/agentactivity/manifests/upstream/letta.toml create mode 100644 internal/agentintegration/upstream/letta/herdr-agent-session.ps1 create mode 100644 internal/agentintegration/upstream/letta/herdr-agent-session.sh create mode 100644 internal/agentintegration/upstream/opencode/tui.js diff --git a/internal/agentactivity/manifests/aliases.upstream.json b/internal/agentactivity/manifests/aliases.upstream.json index 8683e1629..cc1b1adeb 100644 --- a/internal/agentactivity/manifests/aliases.upstream.json +++ b/internal/agentactivity/manifests/aliases.upstream.json @@ -17,6 +17,7 @@ "claude-code" ], "cline": [ + ".cline", "cline" ], "codex": [ @@ -64,6 +65,11 @@ "kiro", "kiro-cli" ], + "letta": [ + "letta", + "letta code", + "letta-code" + ], "maki": [ "maki" ], diff --git a/internal/agentactivity/manifests/authority.upstream.json b/internal/agentactivity/manifests/authority.upstream.json index d3a9b0b14..422781ef4 100644 --- a/internal/agentactivity/manifests/authority.upstream.json +++ b/internal/agentactivity/manifests/authority.upstream.json @@ -25,7 +25,7 @@ "lifecycle_authority": "session_identity", "state_authority": "screen manifest", "integration_role": "session", - "integration_version": 9, + "integration_version": 10, "integration_asset_dir": "claude" }, "cline": { @@ -39,7 +39,7 @@ "lifecycle_authority": "session_identity", "state_authority": "screen manifest", "integration_role": "session", - "integration_version": 8, + "integration_version": 9, "integration_asset_dir": "codex" }, "copilot": { @@ -85,7 +85,7 @@ "lifecycle_authority": "session_identity", "state_authority": "screen manifest", "integration_role": "session", - "integration_version": 1, + "integration_version": 2, "integration_asset_dir": "grok" }, "hermes": { @@ -118,6 +118,14 @@ "state_authority": "screen manifest", "integration_role": "none" }, + "letta": { + "display_name": "Letta Code", + "lifecycle_authority": "session_identity", + "state_authority": "screen manifest", + "integration_role": "session", + "integration_version": 1, + "integration_asset_dir": "letta" + }, "maki": { "display_name": "Maki", "lifecycle_authority": "none", @@ -135,15 +143,15 @@ "muse": { "display_name": "Muse", "lifecycle_authority": "none", - "state_authority": "screen manifest", - "integration_role": "none" + "state_authority": "agent self-report", + "integration_role": "native state reporting" }, "omp": { "display_name": "OMP", "lifecycle_authority": "hooks", "state_authority": "lifecycle hooks when installed", "integration_role": "state and session", - "integration_version": 9, + "integration_version": 10, "integration_asset_dir": "omp" }, "opencode": { @@ -151,7 +159,7 @@ "lifecycle_authority": "hooks", "state_authority": "lifecycle plugin when installed; otherwise screen manifest", "integration_role": "state and session", - "integration_version": 10, + "integration_version": 13, "integration_asset_dir": "opencode" }, "pi": { @@ -159,7 +167,7 @@ "lifecycle_authority": "hooks", "state_authority": "lifecycle hooks when installed; otherwise screen manifest", "integration_role": "state and session", - "integration_version": 8, + "integration_version": 9, "integration_asset_dir": "pi" }, "qodercli": { diff --git a/internal/agentactivity/manifests/report.md b/internal/agentactivity/manifests/report.md index f6fca391f..1b38ff6e3 100644 --- a/internal/agentactivity/manifests/report.md +++ b/internal/agentactivity/manifests/report.md @@ -1,25 +1,41 @@ # Herdr detection sync report -Generated 2026-09-02T14:03:03Z by `go run ./internal/tools/herdrsync`. +Generated 2026-09-30T14:15:06Z by `go run ./internal/tools/herdrsync`. | Field | Value | | --- | --- | | Herdr repository | https://github.com/herdrdev/herdr | | Ref vendored | `master` | -| Commit | `d08e44686d8b19bd9555cc99ec9068d9fde05f16` | -| Pinned release for the differential harness | `preview-2026-08-31-b1ff4582e968` | +| Commit | `331775c3e51e8cca4d122468180738101bd9e6b0` | +| Pinned release for the differential harness | `v0.9.3` | | Catalog | https://herdr.dev/agent-detection/index.toml | -| Catalog ETag | `W/"d78b183cb570f3f343ba80777bbf579d"` | +| Catalog ETag | `W/"e73307bc29c98eec95cac5cf610317c7"` | | Sidecar manifest engine version | 3 | -| Manifests vendored | 21 | +| Manifests vendored | 22 | ## Version changes -No manifest version changed since the previous lock. +| Agent | Before | After | Change | +| --- | --- | --- | --- | +| `claude` | 2026.08.29.1 | 2026.09.11.1 | bumped | +| `cline` | 2026.06.10.1 | 2026.09.11.1 | bumped | +| `codex` | 2026.08.28.1 | 2026.09.23.1 | bumped | +| `grok` | 2026.07.16.2 | 2026.09.18.2 | bumped | +| `kiro` | 2026.08.01.1 | 2026.09.19.1 | bumped | +| `letta` | — | 2026.08.24.1 | added | +| `pi` | 2026.06.10.1 | 2026.09.14.1 | bumped | ## File changes -0 file(s) changed, 21 unchanged. +7 file(s) changed, 15 unchanged. + +- `upstream/claude.toml` (2026.09.11.1, 6726 bytes, 16 rules) +- `upstream/cline.toml` (2026.09.11.1, 1527 bytes, 6 rules) +- `upstream/codex.toml` (2026.09.23.1, 3309 bytes, 8 rules) +- `upstream/grok.toml` (2026.09.18.2, 5401 bytes, 15 rules) +- `upstream/kiro.toml` (2026.09.19.1, 2861 bytes, 8 rules) +- `upstream/letta.toml` (2026.08.24.1, 2328 bytes, 10 rules) +- `upstream/pi.toml` (2026.09.14.1, 456 bytes, 2 rules) ## Published versus bundled @@ -29,42 +45,41 @@ Each row is the copy a Herdr client would load, and why. | --- | --- | --- | --- | --- | | `agy` | published | 2026.06.24.1 | 2026.06.24.1 | published and bundled are both 2026.06.24.1; a Herdr client prefers the remote copy | | `amp` | published | 2026.07.09.1 | 2026.07.09.1 | published and bundled are both 2026.07.09.1; a Herdr client prefers the remote copy | -| `claude` | published | 2026.08.29.1 | 2026.08.29.1 | published and bundled are both 2026.08.29.1; a Herdr client prefers the remote copy | -| `cline` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | -| `codex` | published | 2026.08.28.1 | 2026.08.28.1 | published and bundled are both 2026.08.28.1; a Herdr client prefers the remote copy | +| `claude` | published | 2026.09.11.1 | 2026.09.11.1 | published and bundled are both 2026.09.11.1; a Herdr client prefers the remote copy | +| `cline` | published | 2026.09.11.1 | 2026.09.11.1 | published and bundled are both 2026.09.11.1; a Herdr client prefers the remote copy | +| `codex` | published | 2026.09.23.1 | 2026.09.23.1 | published and bundled are both 2026.09.23.1; a Herdr client prefers the remote copy | | `copilot` | published | 2026.08.29.1 | 2026.08.29.1 | published and bundled are both 2026.08.29.1; a Herdr client prefers the remote copy | | `cursor` | published | 2026.08.03.1 | 2026.08.03.1 | published and bundled are both 2026.08.03.1; a Herdr client prefers the remote copy | | `devin` | published | 2026.06.15.1 | 2026.06.15.1 | published and bundled are both 2026.06.15.1; a Herdr client prefers the remote copy | | `droid` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | | `gemini` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | -| `grok` | bundled | 2026.07.16.2 | 2026.07.16.1 | bundled 2026.07.16.2 is newer than published 2026.07.16.1; a Herdr client ignores the older remote copy | +| `grok` | bundled | 2026.09.18.2 | 2026.09.18.1 | bundled 2026.09.18.2 is newer than published 2026.09.18.1; a Herdr client ignores the older remote copy | | `hermes` | published | 2026.07.24.1 | 2026.07.24.1 | published and bundled are both 2026.07.24.1; a Herdr client prefers the remote copy | | `kilo` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | | `kimi` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | -| `kiro` | published | 2026.08.01.1 | 2026.08.01.1 | published and bundled are both 2026.08.01.1; a Herdr client prefers the remote copy | +| `kiro` | published | 2026.09.19.1 | 2026.09.19.1 | published and bundled are both 2026.09.19.1; a Herdr client prefers the remote copy | +| `letta` | published | 2026.08.24.1 | 2026.08.24.1 | published and bundled are both 2026.08.24.1; a Herdr client prefers the remote copy | | `maki` | published | 2026.07.09.2 | 2026.07.09.2 | published and bundled are both 2026.07.09.2; a Herdr client prefers the remote copy | -| `muse` | bundled | 2026.08.26.1 | — | bundled only; the published catalog does not list this agent | +| `muse` | published | 2026.08.26.1 | 2026.08.26.1 | published and bundled are both 2026.08.26.1; a Herdr client prefers the remote copy | | `opencode` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | -| `pi` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | +| `pi` | published | 2026.09.14.1 | 2026.09.14.1 | published and bundled are both 2026.09.14.1; a Herdr client prefers the remote copy | | `qodercli` | published | 2026.06.10.1 | 2026.06.10.1 | published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy | | `qwen` | published | 2026.08.14.1 | 2026.08.14.1 | published and bundled are both 2026.08.14.1; a Herdr client prefers the remote copy | ## Regex compatibility -4 pattern(s) that Rust's `regex` crate accepts cannot compile under Go's RE2. The vendored files keep them verbatim; an overlay carries the rewrite. See `docs/reference/herdr-detection-parity.md`. +3 pattern(s) that Rust's `regex` crate accepts cannot compile under Go's RE2. The vendored files keep them verbatim; an overlay carries the rewrite. See `docs/reference/herdr-detection-parity.md`. - `upstream/antigravity.toml` rule `spinner_working` line_regex: `^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b` - error parsing regexp: invalid character class range: `\p{Alphabetic}` - `upstream/cursor.toml` rule `spinner_working` line_regex: `^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b` - error parsing regexp: invalid character class range: `\p{Alphabetic}` -- `upstream/kiro.toml` rule `tool_spinner_working` line_regex: `^\s*(◔|◑|◕|●)\s+\p{Alphabetic}` - - error parsing regexp: invalid character class range: `\p{Alphabetic}` - `upstream/qodercli.toml` rule `spinner_working` line_regex: `^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}` - error parsing regexp: invalid character class range: `\p{Alphabetic}` ## Alias table -23 agents in Herdr's `lookup_agent`; generic runtimes: `bash`, `bun`, `cmd`, `fish`, `node`, `powershell`, `pwsh`, `sh`, `tmux`, `zsh` (plus python, or python[....] where every dot-separated segment after the prefix is a non-empty run of ASCII digits (is_python_runtime)). +24 agents in Herdr's `lookup_agent`; generic runtimes: `bash`, `bun`, `cmd`, `fish`, `node`, `powershell`, `pwsh`, `sh`, `tmux`, `zsh` (plus python, or python[....] where every dot-separated segment after the prefix is a non-empty run of ASCII digits (is_python_runtime)). Every Herdr alias for a family Sidecar already claims appears literally in `internal/agentactivity/activity.go`. @@ -76,29 +91,30 @@ Herdr's published authority is a *target*. Sidecar tiers are earned by traces an | Agent | Herdr authority | Sidecar tier | Below target | | --- | --- | --- | --- | -| `agy` | session_identity | screen-fallback | | +| `agy` | session_identity | session-identity | | | `amp` | none | screen-fallback | | | `claude` | session_identity | session-identity | | | `cline` | none | — | | | `codex` | session_identity | session-identity | | -| `copilot` | session_identity | screen-fallback | | -| `cursor` | session_identity | screen-fallback | | -| `devin` | session_identity | — | | -| `droid` | session_identity | — | | +| `copilot` | session_identity | session-identity | | +| `cursor` | session_identity | session-identity | | +| `devin` | session_identity | screen-fallback | | +| `droid` | session_identity | screen-fallback | | | `gemini` | none | — | | -| `grok` | session_identity | screen-fallback | | -| `hermes` | session_identity | — | | -| `kilo` | hooks | — | yes | -| `kimi` | hooks | — | yes | +| `grok` | session_identity | session-identity | | +| `hermes` | session_identity | session-identity | | +| `kilo` | hooks | advisory | yes | +| `kimi` | hooks | advisory | yes | | `kiro` | none | — | | +| `letta` | session_identity | — | | | `maki` | none | — | | -| `mastracode` | hooks | — | yes | +| `mastracode` | hooks | advisory | yes | | `muse` | none | screen-fallback | | -| `omp` | hooks | — | yes | +| `omp` | hooks | advisory | yes | | `opencode` | hooks | full | | -| `pi` | hooks | session-identity | yes | -| `qodercli` | session_identity | — | | -| `qwen` | session_identity | — | | +| `pi` | hooks | advisory | yes | +| `qodercli` | session_identity | screen-fallback | | +| `qwen` | session_identity | session-identity | | ## Integration assets @@ -106,23 +122,28 @@ Vendored verbatim from `src/integration/assets` into `internal/agentintegration/ | Agent | Asset directory | Version | Previous | Change | Sidecar port | | --- | --- | --- | --- | --- | --- | -| `agy` | `antigravity_cli` | 3 | 3 | unchanged | not ported | -| `claude` | `claude` | 9 | 9 | unchanged | `claude` from version 9 | -| `codex` | `codex` | 8 | 8 | unchanged | `codex` from version 8 | -| `copilot` | `copilot` | 3 | 3 | unchanged | not ported | -| `cursor` | `cursor` | 1 | 1 | unchanged | not ported | -| `devin` | `devin` | 2 | 2 | unchanged | not ported | -| `droid` | `droid` | 3 | 3 | unchanged | not ported | -| `grok` | `grok` | 1 | 1 | unchanged | not ported | -| `hermes` | `hermes` | 5 | 5 | unchanged | not ported | -| `kilo` | `kilo` | 4 | 4 | unchanged | not ported | -| `kimi` | `kimi` | 7 | 7 | unchanged | not ported | -| `mastracode` | `mastracode` | 2 | 2 | unchanged | not ported | -| `omp` | `omp` | 9 | 9 | unchanged | not ported | -| `opencode` | `opencode` | 10 | 10 | unchanged | `opencode` from version 10 | -| `pi` | `pi` | 8 | 8 | unchanged | not ported | -| `qodercli` | `qodercli` | 3 | 3 | unchanged | not ported | -| `qwen` | `qwen` | 1 | 1 | unchanged | not ported | +| `agy` | `antigravity_cli` | 3 | 3 | unchanged | `antigravity` from version 3 | +| `claude` | `claude` | 10 | 9 | **bumped** | `claude` from version 9 | +| `codex` | `codex` | 9 | 8 | **bumped** | `codex` from version 8 | +| `copilot` | `copilot` | 3 | 3 | unchanged | `copilot` from version 3 | +| `cursor` | `cursor` | 1 | 1 | unchanged | `cursor` from version 1 | +| `devin` | `devin` | 2 | 2 | unchanged | `devin` from version 2 | +| `droid` | `droid` | 3 | 3 | unchanged | `droid` from version 3 | +| `grok` | `grok` | 2 | 1 | **bumped** | `grok` from version 1 | +| `hermes` | `hermes` | 5 | 5 | unchanged | `hermes` from version 5 | +| `kilo` | `kilo` | 4 | 4 | unchanged | `kilo` from version 4 | +| `kimi` | `kimi` | 7 | 7 | unchanged | `kimi` from version 7 | +| `letta` | `letta` | 1 | — | added | not ported | +| `mastracode` | `mastracode` | 2 | 2 | unchanged | `mastracode` from version 2 | +| `omp` | `omp` | 10 | 9 | **bumped** | `omp` from version 9 | +| `opencode` | `opencode` | 13 | 10 | **bumped** | `opencode` from version 10 | +| `pi` | `pi` | 9 | 8 | **bumped** | `pi` from version 8 | +| `qodercli` | `qodercli` | 3 | 3 | unchanged | `qodercli` from version 3 | +| `qwen` | `qwen` | 1 | 1 | unchanged | `qwen` from version 1 | + +### Bumps for providers Sidecar has not ported + +- `letta` now ships an integration at version 1. ### Upstream changes since each Sidecar port @@ -130,19 +151,751 @@ Vendored verbatim from `src/integration/assets` into `internal/agentintegration/ #### `opencode` — ported from herdr `opencode` version 10 +Compared against `4a3b04f5`; upstream is now at version 13. + +`upstream/opencode/herdr-agent-state.js` (diff): + +```diff +--- src/integration/assets/opencode/herdr-agent-state.js @ 4a3b04f5 ++++ src/integration/assets/opencode/herdr-agent-state.js @ 331775c3 +@@ + // managed by herdr; reinstalling or updating the integration overwrites this file. + // add custom hooks/plugins beside this file instead of editing it. + // HERDR_INTEGRATION_ID=opencode +-// HERDR_INTEGRATION_VERSION=10 ++// HERDR_INTEGRATION_VERSION=13 + + import net from "node:net"; + +@@ + let reportedRootSessionID; + + // Track child sessions so their events cannot replace the pane's root session. +-// Their user prompts still project state without attaching the child session id. +-const childSessions = new Set(); ++// User prompts carry the root id to preserve its identity and cross-talk guard. ++const childSessions = new Map(); + const CHILD_EVENT_STATES = new Map([ + ["permission.asked", "blocked"], + ["question.asked", "blocked"], +@@ + return request("pane.report_agent", params); + } + ++function ownsLocalLifecycle() { ++ const args = process.argv.slice(2); ++ const separator = args.indexOf("--"); ++ if (separator !== -1) args.splice(separator); ++ if (args.some((arg) => arg === "--attach" || arg.startsWith("--attach="))) return false; ++ while (args[0] === "--print-logs" || args[0] === "--log-level" || args[0]?.startsWith("--log-level=")) { ++ args.splice(0, args[0] === "--log-level" ? 2 : 1); ++ } ++ // These local clients have no TUI plugin. Shared servers and the TUI worker ++ // cannot identify their attached panes; their lifecycle belongs to each TUI. ++ return args[0] === "run" || ++ (!["serve", "web", "attach"].includes(args[0]) && args.includes("--mini")); ++} ++ + export const HerdrAgentStatePlugin = async () => { + if ( ++ !ownsLocalLifecycle() || + process.env.HERDR_ENV !== "1" || + !process.env.HERDR_SOCKET_PATH || + !process.env.HERDR_PANE_ID +@@ + + const info = properties.info; + if (info?.id && info.parentID) { +- childSessions.add(info.id); ++ childSessions.set(info.id, info.parentID); + } + if (sessionID && childSessions.has(sessionID)) { + const state = CHILD_EVENT_STATES.get(type); + if (state) { +- await reportState(state); ++ let rootSessionID = sessionID; ++ while (childSessions.has(rootSessionID)) { ++ rootSessionID = childSessions.get(rootSessionID); ++ } ++ await reportState(state, rootSessionID); + } + return; + } +@@ + }, + }; + }; ++ ++// V1 local run/Mini retain their server hooks. V1/V2 full TUIs own both ++// selection and lifecycle, including when attached to a shared remote server. ++export default { ++ id: "herdr.opencode", ++ server: HerdrAgentStatePlugin, ++ setup() {}, ++}; +``` + +`upstream/opencode/herdr-agent-state.test.ts` (diff): + +```diff +--- src/integration/assets/opencode/herdr-agent-state.test.ts @ 4a3b04f5 ++++ src/integration/assets/opencode/herdr-agent-state.test.ts @ 331775c3 +-import { beforeEach, expect, mock, test } from "bun:test"; ++import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + ++const originalArgv = process.argv; ++afterEach(() => { process.argv = originalArgv; }); ++ + const requests: unknown[] = []; + const clients: FakeClient[] = []; + const requestWaiters: Array<() => void> = []; +@@ + clients.length = 0; + requestWaiters.length = 0; + autoAcknowledge = true; ++ process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = "test.sock"; + process.env.HERDR_PANE_ID = "test:p1"; +@@ + event: { + type: "session.created", + properties: { +- sessionID: "child-session", + info: { id: "child-session", parentID: "root-session" }, + }, + }, +@@ + "working", + ]); + expect(requests.map(requestSessionID)).toEqual([ +- undefined, +- undefined, +- undefined, +- undefined, +- undefined, ++ "root-session", ++ "root-session", ++ "root-session", ++ "root-session", ++ "root-session", + ]); + }); + ++test("routes nested child prompts to their own root, not the last active root", async () => { ++ const plugin = await loadPlugin(); ++ for (const info of [ ++ { id: "child-session", parentID: "root-session" }, ++ { id: "nested-session", parentID: "child-session" }, ++ ]) { ++ await plugin.event({ event: { type: "session.created", properties: { info } } }); ++ } ++ await plugin["chat.message"]({ sessionID: "other-root" }); ++ await plugin.event({ ++ event: { type: "permission.asked", properties: { sessionID: "nested-session" } }, ++ }); ++ await plugin.event({ ++ event: { type: "permission.replied", properties: { sessionID: "nested-session" } }, ++ }); ++ await plugin.event({ ++ event: { type: "session.idle", properties: { sessionID: "nested-session" } }, ++ }); ++ await plugin["chat.message"]({ sessionID: "nested-session" }); ++ ++ expect(requests.map(requestState)).toEqual(["working", "blocked", "working"]); ++ expect(requests.map(requestSessionID)).toEqual([ ++ "other-root", ++ "root-session", ++ "root-session", ++ ]); ++}); ++ ++test("only local run and Mini own server lifecycle, never shared servers or TUI workers", async () => { ++ for (const args of [ ++ ["run"], ["run", "--session", "existing"], ["--mini"], ["--mini", "--session", "existing"], ++ ["--print-logs", "--log-level", "DEBUG", "run"], ["run", "--", "--attach"], ++ ]) { ++ process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; ++ expect((await loadPlugin()).event).toBeFunction(); ++ } ++ for (const args of [ ++ [], ["--session", "existing"], ["serve"], ["web"], ["attach", "http://localhost:4096"], ++ ["run", "--attach", "http://localhost:4096"], ["--mini", "--attach=http://localhost:4096"], ++ ["serve", "--", "--mini"], ++ ]) { ++ process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; ++ expect(await loadPlugin()).toEqual({}); ++ } ++ process.argv = ["bun", "/$bunfs/root/src/cli/tui/worker.js"]; ++ expect(await loadPlugin()).toEqual({}); ++ expect(requests).toHaveLength(0); ++}); ++ + function requestMethod(request: unknown): unknown { + return isRecord(request) ? request.method : undefined; + } + ++test("dual server entrypoint keeps V1 hooks and never reports from the V2 shared server", async () => { ++ const module = await import(`./herdr-agent-state.js?test=${++importCounter}`); ++ expect(module.default.server).toBe(module.HerdrAgentStatePlugin); ++ expect(await module.default.setup({})).toBeUndefined(); ++ expect(requests).toHaveLength(0); ++ const hooks = await module.default.server(); ++ await hooks["chat.message"]({ sessionID: "v1-root" }); ++ expect(requests.map(requestState)).toEqual(["working"]); ++}); ++ + function requestState(request: unknown): unknown { + return requestParam(request, "state"); + } +``` + +`upstream/opencode/herdr-tui-session.js` (diff): + +```diff +--- src/integration/assets/opencode/herdr-tui-session.js @ 4a3b04f5 ++++ src/integration/assets/opencode/herdr-tui-session.js @ 331775c3 + // installed by herdr + // managed by herdr; reinstalling or updating the integration overwrites this file. + // HERDR_INTEGRATION_ID=opencode-tui +-// HERDR_INTEGRATION_VERSION=10 ++// HERDR_INTEGRATION_VERSION=13 + + import net from "node:net"; + +@@ + const ROUTE_POLL_INTERVAL_MS = 100; + const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000]; + +-function requestOnce(sessionID) { ++function requestOnce(sessionID, state, seq, isCurrent = () => true) { + const paneId = process.env.HERDR_PANE_ID; + const socketPath = process.env.HERDR_SOCKET_PATH; + if (!paneId || !socketPath) { +- return Promise.resolve(); ++ return Promise.resolve(true); + } + + const socketEndpoint = +@@ + id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0")}`, +- method: "pane.report_agent_session", ++ method: state === undefined ? "pane.report_agent_session" : "pane.report_agent", + params: { + pane_id: paneId, + source: SOURCE, + agent: AGENT, + agent_session_id: sessionID, +- session_start_source: "select", ++ ...(state === undefined ? { session_start_source: "select" } : { state, seq }), + }, + }; + + return new Promise((resolve) => { ++ let settled = false; ++ let timer; ++ const settle = (delivered) => { ++ if (settled) return; ++ settled = true; ++ clearTimeout(timer); ++ client.destroy(); ++ resolve(delivered); ++ }; + const client = net.createConnection(socketEndpoint, () => { ++ if (!isCurrent()) { ++ settle(false); ++ return; ++ } + client.write(`${JSON.stringify(request)}\n`); + }); +- const finish = () => { +- client.destroy(); +- resolve(); +- }; + +- client.setTimeout(500, finish); +- client.on("data", finish); +- client.on("error", finish); +- client.on("end", finish); +- client.on("close", resolve); ++ // A plain timer, not socket.setTimeout, so a connection that never finishes ++ // connecting still settles and cannot block later reports behind the queue. ++ timer = setTimeout(() => settle(false), 500); ++ timer.unref?.(); ++ client.on("data", () => settle(true)); ++ client.on("error", () => settle(false)); ++ client.on("end", () => settle(false)); ++ client.on("close", () => settle(false)); + }); + } + + export default { + id: "herdr.opencode.session-selection", +- tui: async (api) => { +- if ( +- process.env.HERDR_ENV !== "1" || +- !process.env.HERDR_SOCKET_PATH || +- !process.env.HERDR_PANE_ID +- ) { ++ // Keep this plain object dependency-free: V1 and V2 expose different SDK ++ // packages, but both loaders accept their own lifecycle entry on this object. ++ setup, ++ tui, ++}; ++ ++async function tui(api) { ++ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; ++ ++ let disposed = false; ++ let context; ++ let sequence = Date.now() * 1000; ++ let chain = Promise.resolve(); ++ ++ function routeID() { ++ const route = api.route.current; ++ return route?.name === "session" ? route.params?.sessionID : undefined; ++ } ++ ++ function current(ctx) { ++ return !disposed && context === ctx && routeID() === ctx.route; ++ } ++ ++ async function read(ctx, request) { ++ const result = await request({ ++ signal: AbortSignal.any([ctx.controller.signal, AbortSignal.timeout(5_000)]), ++ throwOnError: true, ++ }); ++ if (!current(ctx) || result?.data === undefined) throw new Error("session data unavailable"); ++ return result.data; ++ } ++ ++ // The first selected session owns this pane's subtree. Browsing descendants ++ // keeps that boundary; directly attaching to a child does not claim siblings. +... diff truncated at 120 of 666 lines; run `git diff` between the two commits named above in a Herdr checkout for the rest. +``` + +`upstream/opencode/herdr-tui-session.test.ts` (diff): + +```diff +--- src/integration/assets/opencode/herdr-tui-session.test.ts @ 4a3b04f5 ++++ src/integration/assets/opencode/herdr-tui-session.test.ts @ 331775c3 +@@ + const requests: unknown[] = []; + const activeDisposers: Array<() => void> = []; + const requestWaiters: Array<() => void> = []; ++const stateWaiters: Array<() => void> = []; + let importCounter = 0; ++let holdConnections = false; ++let failConnections = false; ++const connections: Array<() => void> = []; + + mock.module("node:net", () => ({ + default: { + createConnection(_path: string, onConnect: () => void) { + const handlers = new Map void>(); + const client = { ++ destroyed: false, + write(input: string) { +- requests.push(JSON.parse(input.trim())); ++ if (client.destroyed) return; ++ const request = JSON.parse(input.trim()); ++ requests.push(request); ++ if (isRecord(request) && isRecord(request.params) && request.params.state !== undefined) { ++ stateWaiters.shift()?.(); ++ } + requestWaiters.shift()?.(); + queueMicrotask(() => client.emit("data")); + }, +@@ + on(event: string, handler: () => void) { + handlers.set(event, handler); + }, +- destroy() {}, ++ destroy() { ++ client.destroyed = true; ++ }, + emit(event: string) { + handlers.get(event)?.(); + }, + }; +- queueMicrotask(onConnect); ++ if (holdConnections) connections.push(onConnect); ++ else if (failConnections) queueMicrotask(() => client.emit("error")); ++ else queueMicrotask(onConnect); + return client; + }, + }, +@@ + beforeEach(() => { + requests.length = 0; + requestWaiters.length = 0; ++ stateWaiters.length = 0; ++ holdConnections = false; ++ failConnections = false; ++ connections.length = 0; + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = "test.sock"; + process.env.HERDR_PANE_ID = "test:p1"; +@@ + + function fakeApi() { + const sessions = new Map(); ++ const statuses: Record = {}; ++ const permissions: Array<{ id: string; sessionID: string; tool?: { messageID: string; callID: string } }> = []; ++ const questions: typeof permissions = []; ++ const messages = new Map }>(); ++ const listeners = new Map void>>(); ++ const calls: string[] = []; + let current: { name: string; params?: { sessionID: string } } = { name: "home" }; + let dispose: (() => void) | undefined; + activeDisposers.push(() => dispose?.()); + + return { ++ statuses, permissions, questions, messages, listeners, calls, ++ emit(type: string, properties: object) { ++ for (const receive of listeners.get(type) ?? []) receive({ type, properties }); ++ }, + api: { ++ client: { ++ session: { ++ async get({ sessionID }: { sessionID: string }) { ++ calls.push(`get:${sessionID}`); ++ const data = sessions.get(sessionID); ++ if (!data) throw new Error("session not found"); ++ return { data }; ++ }, ++ async status() { calls.push("status"); return { data: { ...statuses } }; }, ++ async message({ messageID }: { sessionID: string; messageID: string }) { ++ calls.push(`message:${messageID}`); ++ const data = messages.get(messageID); ++ if (!data) throw new Error("message unavailable"); ++ return { data }; ++ }, ++ }, ++ permission: { async list() { return { data: [...permissions] }; } }, ++ question: { async list() { return { data: [...questions] }; } }, ++ }, ++ event: { ++ on(type: string, receive: (event: object) => void) { ++ if (!listeners.has(type)) listeners.set(type, new Set()); ++ listeners.get(type)!.add(receive); ++ return () => listeners.get(type)!.delete(receive); ++ }, ++ }, + route: { + get current() { + return current; +@@ + select(sessionID: string) { + current = { name: "session", params: { sessionID } }; + }, ++ home() { current = { name: "home" }; }, + dispose() { + dispose?.(); + }, +@@ + return new Promise((resolve) => requestWaiters.push(resolve)); + } + +... diff truncated at 120 of 626 lines; run `git diff` between the two commits named above in a Herdr checkout for the rest. +``` + +`upstream/opencode/tui.js` (current upstream file): + +```diff +upstream had no src/integration/assets/opencode/tui.js at 4a3b04f5; this file is new since the port. +``` + +#### `codex` — ported from herdr `codex` version 8 + +Compared against `4a3b04f5`; upstream is now at version 9. + +`upstream/codex/herdr-agent-state.ps1` (diff): + +```diff +--- src/integration/assets/codex/herdr-agent-state.ps1 @ 4a3b04f5 ++++ src/integration/assets/codex/herdr-agent-state.ps1 @ 331775c3 +@@ + # managed by herdr; reinstalling or updating the integration overwrites this file. + # add custom hooks beside this file instead of editing it. + # HERDR_INTEGRATION_ID=codex +-# HERDR_INTEGRATION_VERSION=8 ++# HERDR_INTEGRATION_VERSION=9 + + param([string]$Action = "") + +-if ($Action -ne "session") { exit 0 } ++if ($Action -notin @("session", "working", "idle")) { exit 0 } + if ($env:HERDR_ENV -ne "1") { exit 0 } + if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +@@ + exit 0 + } + +-if ($payload.hook_event_name -and $payload.hook_event_name -ne "SessionStart") { exit 0 } ++$expectedEvents = @{ session = @("SessionStart"); working = @("UserPromptSubmit"); idle = @("Stop", "Interrupt") }[$Action] ++if ($payload.hook_event_name -and $payload.hook_event_name -notin $expectedEvents) { exit 0 } + + $sessionId = $payload.session_id + if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } +-if ([string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 } ++if ($Action -eq "session" -and [string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 } + if (-not [string]::IsNullOrWhiteSpace($env:CODEX_THREAD_ID) -and $env:CODEX_THREAD_ID -ne $sessionId) { exit 0 } + +-$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() ++$seq = [DateTimeOffset]::UtcNow.Ticks + $herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } + try { + $args = @( + "pane", +- "report-agent-session", ++ $(if ($Action -eq "session") { "report-agent-session" } else { "report-agent" }), + $env:HERDR_PANE_ID, + "--source", + "herdr:codex", +@@ + "--agent-session-id", + "$sessionId" + ) ++ if ($Action -ne "session") { $args += @("--state", $Action) } + if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) { + $args += @("--session-start-source", "$($payload.source)") + } +``` + +`upstream/codex/herdr-agent-state.sh` (diff): + +```diff +--- src/integration/assets/codex/herdr-agent-state.sh @ 4a3b04f5 ++++ src/integration/assets/codex/herdr-agent-state.sh @ 331775c3 +@@ + # managed by herdr; reinstalling or updating the integration overwrites this file. + # add custom hooks beside this file instead of editing it. + # HERDR_INTEGRATION_ID=codex +-# HERDR_INTEGRATION_VERSION=8 ++# HERDR_INTEGRATION_VERSION=9 + + set -eu + +@@ + cat >"$hook_input_file" 2>/dev/null || true + + case "$action" in +- session) ;; ++ session|working|idle) ;; + *) exit 0 ;; + esac + +@@ + hook_input = {} + + hook_event_name = str(hook_input.get("hook_event_name") or "") +-if hook_event_name and hook_event_name != "SessionStart": ++expected_events = {"session": ("SessionStart",), "working": ("UserPromptSubmit",), "idle": ("Stop", "Interrupt")}[action] ++if hook_event_name and hook_event_name not in expected_events: + raise SystemExit(0) + + request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" + report_seq = time.time_ns() + session_id = hook_input.get("session_id") + agent_session_id = session_id if isinstance(session_id, str) and session_id else None +-transcript_path = hook_input.get("transcript_path") +-if not isinstance(transcript_path, str) or not transcript_path.strip(): +- raise SystemExit(0) ++if action == "session": ++ transcript_path = hook_input.get("transcript_path") ++ if not isinstance(transcript_path, str) or not transcript_path.strip(): ++ raise SystemExit(0) + inherited_session_id = os.environ.get("CODEX_THREAD_ID") + if inherited_session_id and inherited_session_id != agent_session_id: + raise SystemExit(0) +-session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None ++session_start_source = hook_input.get("source") if action == "session" else None + if not isinstance(session_start_source, str) or not session_start_source: + session_start_source = None + if agent_session_id: +@@ + "seq": report_seq, + "agent_session_id": agent_session_id, + } +- if session_start_source: +- params["session_start_source"] = session_start_source ++ if action == "session": ++ if session_start_source: ++ params["session_start_source"] = session_start_source ++ method = "pane.report_agent_session" ++ else: ++ params["state"] = action ++ method = "pane.report_agent" + request = { + "id": request_id, +- "method": "pane.report_agent_session", ++ "method": method, + "params": params, + } + else: +``` + +#### `claude` — ported from herdr `claude` version 9 + Compared against `4a3b04f5`; upstream is now at version 10. -No upstream change: all 4 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. +`upstream/claude/herdr-agent-state.ps1` (diff): + +```diff +--- src/integration/assets/claude/herdr-agent-state.ps1 @ 4a3b04f5 ++++ src/integration/assets/claude/herdr-agent-state.ps1 @ 331775c3 +@@ + # managed by herdr; reinstalling or updating the integration overwrites this file. + # add custom hooks beside this file instead of editing it. + # HERDR_INTEGRATION_ID=claude +-# HERDR_INTEGRATION_VERSION=9 ++# HERDR_INTEGRATION_VERSION=10 + + param([string]$Action = "") + +``` + +`upstream/claude/herdr-agent-state.sh` (diff): + +```diff +--- src/integration/assets/claude/herdr-agent-state.sh @ 4a3b04f5 ++++ src/integration/assets/claude/herdr-agent-state.sh @ 331775c3 +@@ + # managed by herdr; reinstalling or updating the integration overwrites this file. + # add custom hooks beside this file instead of editing it. + # HERDR_INTEGRATION_ID=claude +-# HERDR_INTEGRATION_VERSION=9 ++# HERDR_INTEGRATION_VERSION=10 + + set -eu + +``` + +#### `pi` — ported from herdr `pi` version 8 + +Compared against `d08e4468`; upstream is now at version 9. + +`upstream/pi/herdr-agent-state.ts` (diff): + +```diff +--- src/integration/assets/pi/herdr-agent-state.ts @ d08e4468 ++++ src/integration/assets/pi/herdr-agent-state.ts @ 331775c3 +@@ + // managed by herdr; reinstalling or updating the integration overwrites this file. + // add custom hooks/plugins beside this file instead of editing it. + // HERDR_INTEGRATION_ID=pi +-// HERDR_INTEGRATION_VERSION=8 ++// HERDR_INTEGRATION_VERSION=9 + // @ts-nocheck + + import net from "node:net"; ++import path from "node:path"; + + const HERDR_ENV = process.env.HERDR_ENV; + const socketPath = process.env.HERDR_SOCKET_PATH; +@@ + try { + const file = ctx?.sessionManager?.getSessionFile?.(); + currentAgentSessionPath = +- typeof file === "string" && file.startsWith("/") ? file : undefined; ++ typeof file === "string" && ++ (path.posix.isAbsolute(file) || path.win32.isAbsolute(file)) ++ ? file ++ : undefined; + } catch { + currentAgentSessionPath = undefined; + } +``` + +#### `kilo` — ported from herdr `kilo` version 4 + +Compared against `d08e4468`; upstream is now at version 4. + +No upstream change: all 1 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `kimi` — ported from herdr `kimi` version 7 + +Compared against `d08e4468`; upstream is now at version 7. -#### `codex` — ported from herdr `codex` version 8 +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `omp` — ported from herdr `omp` version 9 -Compared against `4a3b04f5`; upstream is now at version 8. +Compared against `d08e4468`; upstream is now at version 10. + +`upstream/omp/herdr-agent-state.ts` (diff): + +```diff +--- src/integration/assets/omp/herdr-agent-state.ts @ d08e4468 ++++ src/integration/assets/omp/herdr-agent-state.ts @ 331775c3 +@@ + // managed by herdr; reinstalling or updating the integration overwrites this file. +... truncated at 4 of 26 lines; run `git diff` between the two commits named above in a Herdr checkout for the rest. +``` + +#### `antigravity` — ported from herdr `agy` version 3 + +Compared against `d08e4468`; upstream is now at version 3. No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. -#### `claude` — ported from herdr `claude` version 9 +#### `copilot` — ported from herdr `copilot` version 3 -Compared against `4a3b04f5`; upstream is now at version 9. +Compared against `d08e4468`; upstream is now at version 3. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `cursor` — ported from herdr `cursor` version 1 + +Compared against `d08e4468`; upstream is now at version 1. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `grok` — ported from herdr `grok` version 1 + +Compared against `d08e4468`; upstream is now at version 2. + +`upstream/grok/herdr-agent-state.ps1` (diff) is not shown: this section is capped at 600 lines to keep the report inside GitHub's pull request body limit. Re-run `go run ./internal/tools/herdrsync` locally to read it. + +`upstream/grok/herdr-agent-state.sh` (diff) is not shown: this section is capped at 600 lines to keep the report inside GitHub's pull request body limit. Re-run `go run ./internal/tools/herdrsync` locally to read it. + +#### `devin` — ported from herdr `devin` version 2 + +Compared against `d08e4468`; upstream is now at version 2. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `droid` — ported from herdr `droid` version 3 + +Compared against `d08e4468`; upstream is now at version 3. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `qodercli` — ported from herdr `qodercli` version 3 + +Compared against `d08e4468`; upstream is now at version 3. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `qwen` — ported from herdr `qwen` version 1 + +Compared against `d08e4468`; upstream is now at version 1. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `mastracode` — ported from herdr `mastracode` version 2 + +Compared against `d08e4468`; upstream is now at version 2. + +No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. + +#### `hermes` — ported from herdr `hermes` version 5 + +Compared against `d08e4468`; upstream is now at version 5. No upstream change: all 2 compared file(s) are byte-identical to the copy this port was written against. Nothing to re-port. @@ -150,7 +903,18 @@ No upstream change: all 2 compared file(s) are byte-identical to the copy this p Every fixture in `internal/agentactivity/testdata` with a `screen:` block, classified against the manifests this sync replaced and against the ones it wrote. A verdict is the state, the matched rule id, and the fallback reason: the same triple `scripts/herdr-diff.sh` compares. The Sidecar overlays are applied to **both** sides, because a sync never touches them and applying them to one side would report every overlay rule as a flip. Sidecar's process gate is not applied: it reads the pane's process name and never the manifest, so its answer is the same on both sides and it cannot create or hide a flip. -**No fixture changed verdict.** 61 fixture(s) reach the same state, the same matched rule and the same fallback reason under the new manifests as under the old ones. That is the expected result and it is what the review gate is for. +**8 of 61 fixture(s) changed verdict.** Each row is a screen Sidecar now reads differently. Read the manifest diff above for the rule that moved, and decide per row whether the new verdict is the better one. + +| Agent | Fixture | Before | After | +| --- | --- | --- | --- | +| `codex` | `background_terminal.txt` | idle via `osc_title_idle` | working via `screen_working_fallback` | +| `codex` | `completed.txt` | idle via `osc_title_idle` | idle via `sidecar.composer_idle` | +| `codex` | `interrupted.txt` | idle via `osc_title_idle` | idle via `sidecar.composer_idle` | +| `codex` | `startup_idle.txt` | idle via `osc_title_idle` | idle via `sidecar.composer_idle` | +| `codex` | `tool_running_composer.txt` | idle via `osc_title_idle` | working via `screen_working_fallback` | +| `grok` | `background_subagent.txt` | idle via `osc_title_idle` | working via `background_status_working` | +| `grok` | `working.txt` | working via `sidecar.working_footer` | working via `spinner_status_working` | +| `kiro` | `live_working_footer.txt` | working via `kiro_working_marker` | working via `live_working_footer` | ## Overlay rules @@ -169,21 +933,16 @@ A rule carrying an **upstream** id is not in that bucket and is never a deletion | `claude` | `sidecar.background_agents_waiting` | addition | **changes nothing: deletion candidate**; without it working via `sidecar.background_agents_footer_working` | | `claude` | `sidecar.background_agents_footer_working` | addition | changes 1 fixture(s): `background_agents_footer.txt` | | `claude` | `legacy_no_prompt_blocker` | replaces upstream | changes 1 fixture(s): `legacy_permission_wait.txt` | -| `codex` | `sidecar.background_terminal_working` | addition | changes 1 fixture(s): `background_terminal.txt` | -| `codex` | `osc_title_idle` | disables upstream | changes 3 fixture(s): `completed.txt`, `interrupted.txt`, `startup_idle.txt` | | `codex` | `sidecar.composer_idle` | addition | changes 3 fixture(s): `completed.txt`, `interrupted.txt`, `startup_idle.txt` | -| `codex` | `sidecar.working_chrome` | addition | changes 1 fixture(s): `tool_running_composer.txt` | | `codex` | `sidecar.approval_blocker` | addition | changes 1 fixture(s): `approval_prompt.txt` | | `codex` | `weak_blocker` | replaces upstream | changes 1 fixture(s): `weak_blocker.txt` | | `cursor` | `spinner_working` | replaces upstream | changes 1 fixture(s): `working_spinner.txt` | | `cursor` | `sidecar.decision_blocked` | addition | changes 1 fixture(s): `blocked_decision.txt` | | `cursor` | `sidecar.finished_background_tasks_idle` | addition | changes 1 fixture(s): `false_positive_finished_background.txt` | | `cursor` | `sidecar.background_suffix_working` | addition | changes 1 fixture(s): `working_background.txt` | -| `grok` | `sidecar.background_subagent_working` | addition | changes 1 fixture(s): `background_subagent.txt` | | `grok` | `sidecar.overlay_retain` | addition | changes 1 fixture(s): `overlay.txt` | | `grok` | `sidecar.working_footer` | addition | changes nothing, and the overlay declares it `harness-exempt`: no fixture holds the screen it is for, and its case is a Go test | | `grok` | `sidecar.idle_footer` | addition | changes 1 fixture(s): `stale_working_scrollback.txt` | -| `kiro` | `tool_spinner_working` | replaces upstream | changes 1 fixture(s): `tool_spinner_working.txt` | | `muse` | `sidecar.thinking_working` | addition | changes 1 fixture(s): `thinking.txt` | | `qodercli` | `spinner_working` | replaces upstream | changes 1 fixture(s): `spinner_working.txt` | diff --git a/internal/agentactivity/manifests/upstream.lock.json b/internal/agentactivity/manifests/upstream.lock.json index 1145539cb..4f36aac66 100644 --- a/internal/agentactivity/manifests/upstream.lock.json +++ b/internal/agentactivity/manifests/upstream.lock.json @@ -1,21 +1,21 @@ { "schema_version": 1, - "generated_at": "2026-09-02T14:03:03Z", + "generated_at": "2026-09-30T14:15:06Z", "engine_version": 3, "herdr": { "repository": "https://github.com/herdrdev/herdr", "ref": "master", - "commit": "d08e44686d8b19bd9555cc99ec9068d9fde05f16", - "pinned_release_tag": "preview-2026-08-31-b1ff4582e968" + "commit": "331775c3e51e8cca4d122468180738101bd9e6b0", + "pinned_release_tag": "v0.9.3" }, "catalog": { "url": "https://herdr.dev/agent-detection/index.toml", - "etag": "W/\"d78b183cb570f3f343ba80777bbf579d\"", + "etag": "W/\"e73307bc29c98eec95cac5cf610317c7\"", "fetched": true, "source": "https://herdr.dev/agent-detection/index.toml", "schema_version": 1, "path": "upstream/index.toml", - "sha256": "22daa84a074bee7caf0c8316ecd8d237df7abae910351c02eb1577e68c9fc77f" + "sha256": "c6b1e5f23b8db083710f83d91389a852aad1d420d651c833b59361d57a65fbc1" }, "agents": [ { @@ -64,16 +64,16 @@ { "id": "claude", "path": "upstream/claude.toml", - "sha256": "4863ad6d3486749189e53ddd1bf1f77a9b080c4a45f42c3417f0dd23222ac124", - "bytes": 6325, - "version": "2026.08.29.1", + "sha256": "038d0aa23fee3f9b39cb3c9ca117d0f95b0b3a5873cf0f38284ccbac279c9664", + "bytes": 6726, + "version": "2026.09.11.1", "min_engine_version": 2, - "updated_at": "2026-08-29T00:00:00Z", - "rule_count": 17, + "updated_at": "2026-09-11T00:00:00Z", + "rule_count": 16, "source": "published", - "source_reason": "published and bundled are both 2026.08.29.1; a Herdr client prefers the remote copy", - "bundled_version": "2026.08.29.1", - "published_version": "2026.08.29.1", + "source_reason": "published and bundled are both 2026.09.11.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.09.11.1", + "published_version": "2026.09.11.1", "aliases": [ "claude-code" ] @@ -81,31 +81,31 @@ { "id": "cline", "path": "upstream/cline.toml", - "sha256": "577e2f1bd115a6ed3b5c38c661af175386fcdba269f7cd7447bf16d7c72521f0", - "bytes": 644, - "version": "2026.06.10.1", + "sha256": "75fe33ec735c59638da8d62e16bddd257d9959e692edb83b1f11b7f28057866a", + "bytes": 1527, + "version": "2026.09.11.1", "min_engine_version": 1, - "updated_at": "2026-06-10T00:00:00Z", - "rule_count": 2, + "updated_at": "2026-09-11T00:00:00Z", + "rule_count": 6, "source": "published", - "source_reason": "published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy", - "bundled_version": "2026.06.10.1", - "published_version": "2026.06.10.1", + "source_reason": "published and bundled are both 2026.09.11.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.09.11.1", + "published_version": "2026.09.11.1", "aliases": [] }, { "id": "codex", "path": "upstream/codex.toml", - "sha256": "1098233b6440bcfcfbf1b5ef93cb0356e96ad3f89ef8b25a217c0d47c0a8d373", - "bytes": 2192, - "version": "2026.08.28.1", + "sha256": "aba3d44a0f7177d5dd9fd60002a8efd11ef6e1a589bb3992c02563ed3e0de5e0", + "bytes": 3309, + "version": "2026.09.23.1", "min_engine_version": 3, - "updated_at": "2026-08-28T00:00:00Z", + "updated_at": "2026-09-23T22:12:00Z", "rule_count": 8, "source": "published", - "source_reason": "published and bundled are both 2026.08.28.1; a Herdr client prefers the remote copy", - "bundled_version": "2026.08.28.1", - "published_version": "2026.08.28.1", + "source_reason": "published and bundled are both 2026.09.23.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.09.23.1", + "published_version": "2026.09.23.1", "aliases": [] }, { @@ -202,16 +202,16 @@ { "id": "grok", "path": "upstream/grok.toml", - "sha256": "618afc3d09d784343fc6c4bcee5801906df2c0fd62a65b447cf5f4b1bd215bb2", - "bytes": 4729, - "version": "2026.07.16.2", + "sha256": "4298dd2ea7d124f3ba5d72b649bbb4fb6edc533d7e6e546d523a8214fa31456d", + "bytes": 5401, + "version": "2026.09.18.2", "min_engine_version": 3, - "updated_at": "2026-07-16T00:00:00Z", - "rule_count": 14, + "updated_at": "2026-09-18T00:00:00Z", + "rule_count": 15, "source": "bundled", - "source_reason": "bundled 2026.07.16.2 is newer than published 2026.07.16.1; a Herdr client ignores the older remote copy", - "bundled_version": "2026.07.16.2", - "published_version": "2026.07.16.1", + "source_reason": "bundled 2026.09.18.2 is newer than published 2026.09.18.1; a Herdr client ignores the older remote copy", + "bundled_version": "2026.09.18.2", + "published_version": "2026.09.18.1", "aliases": [ "grok-build" ] @@ -273,26 +273,36 @@ { "id": "kiro", "path": "upstream/kiro.toml", - "sha256": "2b56fc18c478e2730acd9f8cbfecb69a2496258f8f20ef157813b09fa74c6e37", - "bytes": 1447, - "version": "2026.08.01.1", - "min_engine_version": 1, - "updated_at": "2026-08-01T00:00:00Z", - "rule_count": 5, + "sha256": "c8990f3c9d4810995be97e8df29836411e37d90f6ac9d9303f505787b504806b", + "bytes": 2861, + "version": "2026.09.19.1", + "min_engine_version": 2, + "updated_at": "2026-09-19T00:00:00Z", + "rule_count": 8, "source": "published", - "source_reason": "published and bundled are both 2026.08.01.1; a Herdr client prefers the remote copy", - "bundled_version": "2026.08.01.1", - "published_version": "2026.08.01.1", + "source_reason": "published and bundled are both 2026.09.19.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.09.19.1", + "published_version": "2026.09.19.1", "aliases": [ "kiro-cli" - ], - "regex_incompatibilities": [ - { - "rule_id": "tool_spinner_working", - "field": "line_regex", - "pattern": "^\\s*(◔|◑|◕|●)\\s+\\p{Alphabetic}", - "error": "error parsing regexp: invalid character class range: `\\p{Alphabetic}`" - } + ] + }, + { + "id": "letta", + "path": "upstream/letta.toml", + "sha256": "205b8c135584c86f9f529aa2d061109b5129085cc5c3124c6dbfe0e78fcdba43", + "bytes": 2328, + "version": "2026.08.24.1", + "min_engine_version": 3, + "updated_at": "2026-08-24T00:00:00Z", + "rule_count": 10, + "source": "published", + "source_reason": "published and bundled are both 2026.08.24.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.08.24.1", + "published_version": "2026.08.24.1", + "aliases": [ + "letta-code", + "letta code" ] }, { @@ -319,9 +329,10 @@ "min_engine_version": 2, "updated_at": "2026-08-26T00:00:00Z", "rule_count": 7, - "source": "bundled", - "source_reason": "bundled only; the published catalog does not list this agent", + "source": "published", + "source_reason": "published and bundled are both 2026.08.26.1; a Herdr client prefers the remote copy", "bundled_version": "2026.08.26.1", + "published_version": "2026.08.26.1", "aliases": [ "muse-code", "muse-cli" @@ -348,16 +359,16 @@ { "id": "pi", "path": "upstream/pi.toml", - "sha256": "d852f52d637040fad207a395928dc190d99d24b6c72d6e1b7fbbf4487a416e40", - "bytes": 257, - "version": "2026.06.10.1", + "sha256": "57469b82e4239bf93559ed5d400c9d9f86a9e64d5def728b7d4bb398be7659ca", + "bytes": 456, + "version": "2026.09.14.1", "min_engine_version": 1, - "updated_at": "2026-06-10T00:00:00Z", - "rule_count": 1, + "updated_at": "2026-09-14T00:00:00Z", + "rule_count": 2, "source": "published", - "source_reason": "published and bundled are both 2026.06.10.1; a Herdr client prefers the remote copy", - "bundled_version": "2026.06.10.1", - "published_version": "2026.06.10.1", + "source_reason": "published and bundled are both 2026.09.14.1; a Herdr client prefers the remote copy", + "bundled_version": "2026.09.14.1", + "published_version": "2026.09.14.1", "aliases": [ "herdr:pi" ] @@ -417,7 +428,7 @@ }, { "path": "upstream/NOTICE", - "sha256": "af54c28395c85bbf94f976360c3bcf92cec9aaf665572bfd82d8a6bc4a132e1e", + "sha256": "e4e30c2d5cc1cc37af083c17e837437d889de84bff42f7c2747c2c178a0694f1", "bytes": 1192, "origin": "generated by internal/tools/herdrsync" } diff --git a/internal/agentactivity/manifests/upstream/NOTICE b/internal/agentactivity/manifests/upstream/NOTICE index 012828e53..260b7c3dc 100644 --- a/internal/agentactivity/manifests/upstream/NOTICE +++ b/internal/agentactivity/manifests/upstream/NOTICE @@ -7,7 +7,7 @@ manifests from Herdr. Project: Herdr Repository: https://github.com/herdrdev/herdr Ref: master -Commit: d08e44686d8b19bd9555cc99ec9068d9fde05f16 +Commit: 331775c3e51e8cca4d122468180738101bd9e6b0 Catalog: https://herdr.dev/agent-detection/index.toml License: Apache License, Version 2.0 (see LICENSE in this directory) diff --git a/internal/agentactivity/manifests/upstream/claude.toml b/internal/agentactivity/manifests/upstream/claude.toml index 55adacd68..0c7bdcf25 100644 --- a/internal/agentactivity/manifests/upstream/claude.toml +++ b/internal/agentactivity/manifests/upstream/claude.toml @@ -1,7 +1,7 @@ id = "claude" -version = "2026.08.29.1" +version = "2026.09.11.1" min_engine_version = 2 -updated_at = "2026-08-29T00:00:00Z" +updated_at = "2026-09-11T00:00:00Z" aliases = ["claude-code"] [[rules]] @@ -21,17 +21,9 @@ region = "bottom_non_empty_lines(12)" visible_working = true any = [ { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] }, - { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] }, + { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2733}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] }, ] -[[rules]] -id = "background_shell_working" -state = "working" -priority = 965 -region = "bottom_non_empty_lines(5)" -visible_working = true -line_regex = ['^\s*[⏸⏵].*·\s+[1-9]\d*\s+shells?\s+(?:·|$)'] - [[rules]] id = "background_agents_working" state = "working" @@ -170,8 +162,20 @@ any = [ { contains = ["tab to amend"] }, { contains = ["ctrl+e to explain"] }, ] +# Claude marks the selected option with "❯", so every option branch has to allow +# that prefix. Numbered branches that omit it only match options the cursor has +# moved away from, which left the resting layout below unmatched here (#2650): +# ❯ 1. Yes / 2. Yes, and don't ask again for: / 3. No +# Cover both the two-option and "don't ask again" three-option shapes so this +# rule, not the narrower generic_permission_prompt, claims Bash approvals. all = [ - { any = [{ line_regex = ['(?i)^\s*❯?\s*yes\b'] }, { line_regex = ['(?i)^\s*1\.\s*yes\b'] }, { line_regex = ['(?i)^\s*2\.\s*no\b'] }] }, + { any = [ + { line_regex = ['(?i)^\s*❯?\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] }, + { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] }, + { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] }, + ] }, ] [[rules]] diff --git a/internal/agentactivity/manifests/upstream/cline.toml b/internal/agentactivity/manifests/upstream/cline.toml index b30f4417c..2d3140875 100644 --- a/internal/agentactivity/manifests/upstream/cline.toml +++ b/internal/agentactivity/manifests/upstream/cline.toml @@ -1,7 +1,7 @@ id = "cline" -version = "2026.06.10.1" +version = "2026.09.11.1" min_engine_version = 1 -updated_at = "2026-06-10T00:00:00Z" +updated_at = "2026-09-11T00:00:00Z" [[rules]] id = "tool_permission" @@ -17,6 +17,43 @@ any = [ { contains = ["[plan mode]", "use this tool?", "yes"] }, ] +[[rules]] +id = "inline_tool_permission" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(16)" +visible_blocker = true +contains = ["Cline needs permission", "Approve tool call?", "[y] Approve", "[n] Deny"] + +[[rules]] +id = "inline_question" +state = "blocked" +priority = 300 +region = "bottom_non_empty_lines(24)" +visible_blocker = true +line_regex = ['^\s*Cline is asking a question\s*$', '^\s*>\s+\S'] +contains = ["(Tab)", "Shift+Tab"] + +[[rules]] +id = "active_turn" +state = "working" +priority = 200 +region = "bottom_non_empty_lines(20)" +visible_working = true +any = [ + { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+\S'] }, + { contains = ["Thinking... (esc to cancel)"] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_idle = true +line_regex = ['^\s*❯(?:\s.*)?$'] +contains = ["─", "(Tab)", "Shift+Tab"] + [[rules]] id = "default_cline_working" state = "working" diff --git a/internal/agentactivity/manifests/upstream/codex.toml b/internal/agentactivity/manifests/upstream/codex.toml index 6833f390d..dd275758e 100644 --- a/internal/agentactivity/manifests/upstream/codex.toml +++ b/internal/agentactivity/manifests/upstream/codex.toml @@ -1,7 +1,7 @@ id = "codex" -version = "2026.08.28.1" +version = "2026.09.23.1" min_engine_version = 3 -updated_at = "2026-08-28T00:00:00Z" +updated_at = "2026-09-23T22:12:00Z" [[rules]] id = "osc_title_blocked" @@ -42,6 +42,15 @@ all = [ { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] }, ] +[[rules]] +id = "startup_update" +state = "blocked" +priority = 950 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Update available!", "Update now"] +regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z'] + [[rules]] id = "live_strong_blocker" state = "blocked" @@ -53,6 +62,7 @@ any = [ { contains = ["enter to submit answer"] }, { contains = ["enter to submit all"] }, { contains = ["allow command?"] }, + { contains = ["All Results", "Filesystem Only", "Plugins"] }, ] [[rules]] @@ -60,6 +70,8 @@ id = "weak_blocker" state = "blocked" priority = 600 region = "whole_recent_without_current_prompt_marker" +# Sparkles can replace the space after ›. A later response marker makes that prompt stale. +not = [{ regex = ['(?m)^›[⠁⠂⠄⠈⠐⠠⡀⢀][^\n]*(?:\n(?:[^•■✗✓\n][^\n]*)?)*\z'] }] any = [ { contains = ["[y/n]"] }, { contains = ["yes (y)"] }, @@ -71,19 +83,12 @@ any = [ id = "screen_working_fallback" state = "working" priority = 500 -region = "bottom_non_empty_lines(3)" +region = "before_current_prompt_marker" visible_working = true -line_regex = ['^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$'] -not = [{ contains = ["■ Conversation interrupted"] }] - -[[rules]] -id = "osc_title_idle" -state = "idle" -priority = 100 -region = "osc_title" -visible_idle = true -regex = ['\S'] -not = [ - { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] }, - { contains = ["Action Required"] }, -] +# Support animated and reduced-motion status, including dynamic activity labels. +# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can +# sit below the status. Require the live timer suffix with no later response. +any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }] +regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z'] +# A failed reconnect keeps its final elapsed timer but is no longer working. +not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }] diff --git a/internal/agentactivity/manifests/upstream/grok.toml b/internal/agentactivity/manifests/upstream/grok.toml index 30b6e9c70..67ba4ebcc 100644 --- a/internal/agentactivity/manifests/upstream/grok.toml +++ b/internal/agentactivity/manifests/upstream/grok.toml @@ -1,16 +1,16 @@ id = "grok" -version = "2026.07.16.2" +version = "2026.09.18.2" min_engine_version = 3 -updated_at = "2026-07-16T00:00:00Z" +updated_at = "2026-09-18T00:00:00Z" aliases = ["grok-build"] -# Evidence: Grok Build 0.2.101 source and live pane reads. +# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads. # # Grok emits OSC 0 titles by default. Idle is "grok" or # " - grok". During a turn, the configured title gains a braille # spinner and activity text. Permission prompts add "⚠ Action Required"; # that prefix blinks while the terminal is unfocused, so visible blocker -# rules outrank the generic non-idle title rule. +# rules outrank the title spinner rule. # # Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the # payload after "9;": "4;1;-1" while busy and "4;0;0" when idle. @@ -85,6 +85,16 @@ region = "top_non_empty_lines(1)" visible_working = true line_regex = ['[⋅:⸬⁙.·]\s+[1-9][0-9]*\s+│'] +# Grok 1.0.34 moves background counts above the composer and clears OSC +# progress between turns even while these commands are still running. +[[rules]] +id = "background_status_working" +state = "working" +priority = 1165 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$'] + [[rules]] id = "osc_progress_working" state = "working" @@ -104,16 +114,15 @@ not = [ { regex = ['[\x{2800}-\x{28FF}]'] }, ] -# After known idle titles are excluded, any other non-empty Grok title is -# active. OSC progress remains authoritative when a custom title omits the -# spinner but the terminal supports progress reporting. +# Title items are configurable; omitting "grok" does not imply activity. +# Require a spinner, with OSC progress covering titles that omit it. [[rules]] id = "osc_title_working" state = "working" priority = 1000 region = "osc_title" visible_working = true -regex = ['\S'] +regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)'] [[rules]] id = "osc_progress_idle" @@ -123,10 +132,12 @@ region = "osc_progress" visible_idle = true regex = ['^4;0;0$'] +# Visible activity outranks idle OSC signals: disabling title updates can +# leave the startup "grok" title unchanged throughout a turn. [[rules]] id = "spinner_status_working" state = "working" -priority = 200 +priority = 1160 region = "whole_recent" visible_working = true line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] @@ -134,16 +145,20 @@ line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$'] [[rules]] id = "esc_cancel_hints_working" state = "working" -priority = 190 +priority = 1155 region = "bottom_non_empty_lines(2)" visible_working = true -contains = ["esc:cancel", "ctrl+.:shortcuts"] +contains = ["ctrl+.:shortcuts"] +any = [ + { contains = ["esc:cancel"] }, + { contains = ["ctrl+c:cancel"] }, +] # Pre-0.2.x working chrome kept for older Grok Build releases. [[rules]] id = "waiting_tool_working" state = "working" -priority = 120 +priority = 1140 region = "whole_recent" visible_working = true any = [ diff --git a/internal/agentactivity/manifests/upstream/index.toml b/internal/agentactivity/manifests/upstream/index.toml index f44a83065..1cd6c020d 100644 --- a/internal/agentactivity/manifests/upstream/index.toml +++ b/internal/agentactivity/manifests/upstream/index.toml @@ -56,10 +56,18 @@ path = "kimi.toml" id = "kiro" path = "kiro.toml" +[[agents]] +id = "letta" +path = "letta.toml" + [[agents]] id = "maki" path = "maki.toml" +[[agents]] +id = "muse" +path = "muse.toml" + [[agents]] id = "opencode" path = "opencode.toml" diff --git a/internal/agentactivity/manifests/upstream/kiro.toml b/internal/agentactivity/manifests/upstream/kiro.toml index 59ac0a061..9d50f9320 100644 --- a/internal/agentactivity/manifests/upstream/kiro.toml +++ b/internal/agentactivity/manifests/upstream/kiro.toml @@ -1,63 +1,97 @@ id = "kiro" -version = "2026.08.01.1" -min_engine_version = 1 -updated_at = "2026-08-01T00:00:00Z" +version = "2026.09.19.1" +min_engine_version = 2 +updated_at = "2026-09-19T00:00:00Z" aliases = ["kiro-cli"] +[[rules]] +id = "live_prompt_idle" +state = "idle" +priority = 1100 +region = "bottom_non_empty_lines(4)" +visible_idle = true +line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$'] + [[rules]] id = "tool_approval" state = "blocked" -priority = 300 -region = "whole_recent" +priority = 1050 +region = "bottom_non_empty_lines(8)" visible_blocker = true -contains = ["requires approval"] any = [ - { contains = ["yes, single permission"] }, - { contains = ["trust, always allow"] }, - { contains = ["no (tab to edit)"] }, - { contains = ["esc to close"] }, + { all = [ + { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] }, + { any = [ + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$', + '(?i)^\s*(?:[>❯]\s*)?allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?always allow\s*$', + '(?i)^\s*(?:[>❯]\s*)?deny\s*$', + '(?i)^\s*(?:[>❯]\s*)?always deny\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$', + '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$', + '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$', + '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$', + ] }, + { line_regex = [ + '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$', + '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$', + ] }, + ] }, + ] }, ] [[rules]] -id = "subagent_approval" +id = "tool_approval_edit" state = "blocked" -priority = 290 +priority = 1045 region = "whole_recent" visible_blocker = true -contains = ["pending from subagents"] -any = [ - { contains = ["tool approval"] }, - { contains = ["tool approvals"] }, -] -all = [ - { any = [{ contains = ["approve all pending"] }, { contains = ["configure individually"] }, { contains = ["exit (cancel subagents)"] }] }, -] +regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z'] [[rules]] -id = "prompt_idle" -state = "idle" -priority = 200 -region = "bottom_non_empty_lines(5)" -visible_idle = true -contains = ["ask a question or describe a task", "/copy to clipboard"] -not = [ - { contains = ["kiro is working"] }, - { contains = ["esc to cancel"] }, +id = "crew_approval" +state = "blocked" +priority = 1040 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = [ + "tool approval", + "approve all pending", + "configure individually (agent monitor)", + "exit (cancel subagents)", ] [[rules]] -id = "kiro_working_marker" +id = "question_panel" +state = "blocked" +priority = 1030 +region = "bottom_non_empty_lines(8)" +visible_blocker = true +contains = ["to navigate", "to submit", "esc to cancel"] + +[[rules]] +id = "live_working_footer" state = "working" -priority = 100 -region = "whole_recent" +priority = 950 +region = "bottom_non_empty_lines(4)" visible_working = true -contains = ["kiro is working"] +contains = ["kiro is working", "type to steer", "ctrl+s to queue"] [[rules]] -id = "tool_spinner_working" +id = "osc_title_working" state = "working" -priority = 90 -region = "whole_recent" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:'] + +[[rules]] +id = "osc_progress_working" +state = "working" +priority = 890 +region = "osc_progress" visible_working = true -contains = ["esc to cancel"] -line_regex = ['^\s*(◔|◑|◕|●)\s+\p{Alphabetic}'] +regex = ['^4;3;?$'] diff --git a/internal/agentactivity/manifests/upstream/letta.toml b/internal/agentactivity/manifests/upstream/letta.toml new file mode 100644 index 000000000..e27e2aa72 --- /dev/null +++ b/internal/agentactivity/manifests/upstream/letta.toml @@ -0,0 +1,95 @@ +id = "letta" +version = "2026.08.24.1" +min_engine_version = 3 +updated_at = "2026-08-24T00:00:00Z" +aliases = ["letta-code", "letta code"] + +# Letta keeps completed tool rows and spinners in the transcript. A standalone +# screen spinner is not working evidence. Prefer its live OSC title/progress +# signals, with visible status chrome and running tool rows as fallbacks. +[[rules]] +id = "osc_progress_blocked" +state = "blocked" +priority = 1400 +region = "osc_progress" +visible_blocker = true +regex = ['^4;3(?:;|$)'] + +[[rules]] +id = "osc_title_blocked" +state = "blocked" +priority = 1300 +region = "osc_title" +visible_blocker = true +regex = ['^\[ [!.] \] Action Required(?: \| |$)'] + +[[rules]] +id = "command_approval" +state = "blocked" +priority = 1200 +region = "bottom_non_empty_lines(20)" +visible_blocker = true +contains = ["Run this command?", "Enter to select · Esc to cancel"] + +[[rules]] +id = "osc_title_working" +state = "working" +priority = 900 +region = "osc_title" +visible_working = true +regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] + +[[rules]] +id = "active_status" +state = "working" +priority = 850 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] + +[[rules]] +id = "running_tool" +state = "working" +priority = 800 +region = "bottom_non_empty_lines(8)" +visible_working = true +line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] + +[[rules]] +id = "profile_selector" +state = "unknown" +priority = 700 +region = "bottom_non_empty_lines(12)" +contains = ["Create a new agent (--new)", "Enter select · Esc exit"] + +[[rules]] +id = "composer_input" +state = "unknown" +priority = 150 +region = "bottom_non_empty_lines(8)" +line_regex = ['^\s*›\s+\S.*$'] +not = [ + { line_regex = ['^\s*›\s+Try\s+"'] }, +] + +[[rules]] +id = "composer_idle" +state = "idle" +priority = 100 +region = "bottom_non_empty_lines(8)" +visible_idle = true +any = [ + { line_regex = ['^\s*›\s*$'] }, + { line_regex = ['^\s*›\s+Try\s+"'] }, +] +not = [ + { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] }, + { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] }, +] + +[[rules]] +id = "no_live_state_evidence" +state = "unknown" +priority = 0 +region = "whole_recent" +regex = ['(?s)^.*$'] diff --git a/internal/agentactivity/manifests/upstream/pi.toml b/internal/agentactivity/manifests/upstream/pi.toml index a58d30e90..77b2d6324 100644 --- a/internal/agentactivity/manifests/upstream/pi.toml +++ b/internal/agentactivity/manifests/upstream/pi.toml @@ -1,7 +1,7 @@ id = "pi" -version = "2026.06.10.1" +version = "2026.09.14.1" min_engine_version = 1 -updated_at = "2026-06-10T00:00:00Z" +updated_at = "2026-09-14T00:00:00Z" aliases = ["herdr:pi"] [[rules]] @@ -11,3 +11,11 @@ priority = 100 region = "whole_recent" visible_working = true contains = ["Working..."] + +[[rules]] +id = "working_border" +state = "working" +priority = 100 +region = "bottom_non_empty_lines(12)" +visible_working = true +line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$'] diff --git a/internal/agentintegration/upstream.lock.json b/internal/agentintegration/upstream.lock.json index 65f4b0e46..9a4a369a2 100644 --- a/internal/agentintegration/upstream.lock.json +++ b/internal/agentintegration/upstream.lock.json @@ -1,10 +1,10 @@ { "schema_version": 1, - "generated_at": "2026-09-02T14:03:03Z", + "generated_at": "2026-09-30T14:15:06Z", "herdr": { "repository": "https://github.com/herdrdev/herdr", "ref": "master", - "commit": "d08e44686d8b19bd9555cc99ec9068d9fde05f16", + "commit": "331775c3e51e8cca4d122468180738101bd9e6b0", "assets_dir": "src/integration/assets" }, "providers": [ @@ -32,42 +32,42 @@ { "id": "claude", "directory": "claude", - "version": 9, + "version": 10, "files": [ { "path": "upstream/claude/herdr-agent-state.ps1", "origin": "src/integration/assets/claude/herdr-agent-state.ps1", - "sha256": "6db8bf92db45a3c5aa67994c1fb665231339e1dc3b08c28239c246c0874ebaaa", - "bytes": 1937, - "version": 9 + "sha256": "37faa01a19a1aec4a5dbed4110f9aea4beddd15a195a2b36f8b9d9d5cb27f26e", + "bytes": 1938, + "version": 10 }, { "path": "upstream/claude/herdr-agent-state.sh", "origin": "src/integration/assets/claude/herdr-agent-state.sh", - "sha256": "a61bbe2619a316669406316de260c13b6c1ae525dafd6f4f4956e26646268105", - "bytes": 3069, - "version": 9 + "sha256": "7f117c303ffc1a66975b76dda8189d70b718fc04af4cf7cb11449e4ee5b4ae86", + "bytes": 3070, + "version": 10 } ] }, { "id": "codex", "directory": "codex", - "version": 8, + "version": 9, "files": [ { "path": "upstream/codex/herdr-agent-state.ps1", "origin": "src/integration/assets/codex/herdr-agent-state.ps1", - "sha256": "8fee2867ba1346e472157966dde74d7c5c6976a2a6e394583f03a5b9df690f4a", - "bytes": 1636, - "version": 8 + "sha256": "b7c0ed2a75ede8bc61c2cf25317e0b24ad565771bb1330bf7368364f317e2ac4", + "bytes": 1921, + "version": 9 }, { "path": "upstream/codex/herdr-agent-state.sh", "origin": "src/integration/assets/codex/herdr-agent-state.sh", - "sha256": "38a90a2a99872d06dcbc978e7613406cdd852dbb07503abd7a113f518d5951f3", - "bytes": 2965, - "version": 8 + "sha256": "65da7b36af8e62b0e15f56efe23958ff5ce995fda9cefb1359224a9b283aa249", + "bytes": 3267, + "version": 9 } ] }, @@ -158,21 +158,21 @@ { "id": "grok", "directory": "grok", - "version": 1, + "version": 2, "files": [ { "path": "upstream/grok/herdr-agent-state.ps1", "origin": "src/integration/assets/grok/herdr-agent-state.ps1", - "sha256": "f5868cb925780899a912f06e38c5000cca913627556816d4d9b2f8f73e8bda3b", - "bytes": 1547, - "version": 1 + "sha256": "ef97720e9001d0d257c241a51693df3ffb4b59f051e51c480957478d1a14328f", + "bytes": 1883, + "version": 2 }, { "path": "upstream/grok/herdr-agent-state.sh", "origin": "src/integration/assets/grok/herdr-agent-state.sh", - "sha256": "a5c66f3117037341df12e59fbefc5ff9869e092f8766dedf0500629e24006def", - "bytes": 2851, - "version": 1 + "sha256": "2894f173ca798e448a70467fdf8513e6f4c40355f41d411bbe7d32b234d43857", + "bytes": 2970, + "version": 2 } ] }, @@ -231,6 +231,27 @@ } ] }, + { + "id": "letta", + "directory": "letta", + "version": 1, + "files": [ + { + "path": "upstream/letta/herdr-agent-session.ps1", + "origin": "src/integration/assets/letta/herdr-agent-session.ps1", + "sha256": "493c743eeb869bfd94c30ba9c216074a30fd4ae30a04aa2808f5d5201d3dda75", + "bytes": 1791, + "version": 1 + }, + { + "path": "upstream/letta/herdr-agent-session.sh", + "origin": "src/integration/assets/letta/herdr-agent-session.sh", + "sha256": "f30086feedb6b771bf4fa4afc162f988f151cc1760f87b122010e7558d36acff", + "bytes": 1736, + "version": 1 + } + ] + }, { "id": "mastracode", "directory": "mastracode", @@ -255,61 +276,68 @@ { "id": "omp", "directory": "omp", - "version": 9, + "version": 10, "files": [ { "path": "upstream/omp/herdr-agent-state.ts", "origin": "src/integration/assets/omp/herdr-agent-state.ts", - "sha256": "4cfd1e32da1f15efba0521c52fee3ba4b23c36dc236a77aea922220fdefa024e", - "bytes": 12438, - "version": 9 + "sha256": "e3c650752bb9c2eb8bc1eecc9ce845c27434f6b7331af161353dd06ca6de48b7", + "bytes": 12746, + "version": 10 } ] }, { "id": "opencode", "directory": "opencode", - "version": 10, + "version": 13, "files": [ { "path": "upstream/opencode/herdr-agent-state.js", "origin": "src/integration/assets/opencode/herdr-agent-state.js", - "sha256": "5cb15e059a5f812a20fcfd64b43303efbaa63c1b4874b124e25df64dc023a15e", - "bytes": 5363, - "version": 10 + "sha256": "f70a7d2193f4a9409838b078acee5a35f72f0925af08f63c142ec9cbf0ba5fcd", + "bytes": 6520, + "version": 13 }, { "path": "upstream/opencode/herdr-agent-state.test.ts", "origin": "src/integration/assets/opencode/herdr-agent-state.test.ts", - "sha256": "8c19e191ef4235e8631f381c8a4f85aae67622a77faadbd594d59f4dbb287f7e", - "bytes": 6418 + "sha256": "5a5bb383119eb53d752d3d3b6c83acd59b6f70d262129e6ce9eb9064ce7bfe72", + "bytes": 9061 }, { "path": "upstream/opencode/herdr-tui-session.js", "origin": "src/integration/assets/opencode/herdr-tui-session.js", - "sha256": "dc79b96c1cd123ff2254efd1830b21cfb4c0557ab01f8b9972fdff6df8a85400", - "bytes": 3253, - "version": 10 + "sha256": "50659d47db3cf4e7de3028e56f2666a656efaaddc24711b72f5b6baa9f99c5d3", + "bytes": 21240, + "version": 13 }, { "path": "upstream/opencode/herdr-tui-session.test.ts", "origin": "src/integration/assets/opencode/herdr-tui-session.test.ts", - "sha256": "dfdd7551fafc47e0989ff8305c7378c9b709a6f7e3f274d3ff46d2a0d3a50b3a", - "bytes": 5091 + "sha256": "d076bfadcf1e8c9009099ae509a68060e4ba0366b1666a67d44dc0e0d7190ae7", + "bytes": 26644 + }, + { + "path": "upstream/opencode/tui.js", + "origin": "src/integration/assets/opencode/tui.js", + "sha256": "a70a9d9de68d24e92e68ceb658e535b59ac55eddbef711888e02c2773c0f9cce", + "bytes": 219, + "version": 13 } ] }, { "id": "pi", "directory": "pi", - "version": 8, + "version": 9, "files": [ { "path": "upstream/pi/herdr-agent-state.ts", "origin": "src/integration/assets/pi/herdr-agent-state.ts", - "sha256": "9b1c41cd72520fc2abe5f2a2aec995c12a926cce844df472c7fd5fcae4f4dbfa", - "bytes": 6602, - "version": 8 + "sha256": "2c5272d732b475bbf91a027203b1f98d25fe43d2c1402530a442b288aeaca1e4", + "bytes": 6694, + "version": 9 } ] }, @@ -366,14 +394,14 @@ { "path": "upstream/NOTICE", "origin": "generated by internal/tools/herdrsync", - "sha256": "63b9fc97df1f0672e63bfe4ad45159b2683150b053d91d874dd01a921acbf662", + "sha256": "00e18992c5118250863f4ecd6704b399e7fdbf6ba3ec08759cbc3dfce7385380", "bytes": 1428 }, { "path": "upstream/herdr-agent-state.test.ts", "origin": "src/integration/assets/herdr-agent-state.test.ts", - "sha256": "4ddc306851d6a6504fbb9f178610099c9988a86cdb3354c78b89a60235935f99", - "bytes": 20246 + "sha256": "1ec17623179aed3c521140f59fa3f9ce714412c45b8c69d2d340aa64b46a8cbd", + "bytes": 22476 } ] } diff --git a/internal/agentintegration/upstream/NOTICE b/internal/agentintegration/upstream/NOTICE index c4db0728b..8175b715d 100644 --- a/internal/agentintegration/upstream/NOTICE +++ b/internal/agentintegration/upstream/NOTICE @@ -7,7 +7,7 @@ integration assets from Herdr. Project: Herdr Repository: https://github.com/herdrdev/herdr Ref: master -Commit: d08e44686d8b19bd9555cc99ec9068d9fde05f16 +Commit: 331775c3e51e8cca4d122468180738101bd9e6b0 Source: src/integration/assets License: Apache License, Version 2.0 (see LICENSE in this directory) diff --git a/internal/agentintegration/upstream/claude/herdr-agent-state.ps1 b/internal/agentintegration/upstream/claude/herdr-agent-state.ps1 index fc5968656..d62c5b9f9 100644 --- a/internal/agentintegration/upstream/claude/herdr-agent-state.ps1 +++ b/internal/agentintegration/upstream/claude/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 param([string]$Action = "") diff --git a/internal/agentintegration/upstream/claude/herdr-agent-state.sh b/internal/agentintegration/upstream/claude/herdr-agent-state.sh index 5d11a80c2..e0b88ce78 100644 --- a/internal/agentintegration/upstream/claude/herdr-agent-state.sh +++ b/internal/agentintegration/upstream/claude/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=claude -# HERDR_INTEGRATION_VERSION=9 +# HERDR_INTEGRATION_VERSION=10 set -eu diff --git a/internal/agentintegration/upstream/codex/herdr-agent-state.ps1 b/internal/agentintegration/upstream/codex/herdr-agent-state.ps1 index 81ed381b9..1a701102e 100644 --- a/internal/agentintegration/upstream/codex/herdr-agent-state.ps1 +++ b/internal/agentintegration/upstream/codex/herdr-agent-state.ps1 @@ -2,11 +2,11 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=8 +# HERDR_INTEGRATION_VERSION=9 param([string]$Action = "") -if ($Action -ne "session") { exit 0 } +if ($Action -notin @("session", "working", "idle")) { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } @@ -17,19 +17,20 @@ try { exit 0 } -if ($payload.hook_event_name -and $payload.hook_event_name -ne "SessionStart") { exit 0 } +$expectedEvents = @{ session = @("SessionStart"); working = @("UserPromptSubmit"); idle = @("Stop", "Interrupt") }[$Action] +if ($payload.hook_event_name -and $payload.hook_event_name -notin $expectedEvents) { exit 0 } $sessionId = $payload.session_id if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } -if ([string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 } +if ($Action -eq "session" -and [string]::IsNullOrWhiteSpace($payload.transcript_path)) { exit 0 } if (-not [string]::IsNullOrWhiteSpace($env:CODEX_THREAD_ID) -and $env:CODEX_THREAD_ID -ne $sessionId) { exit 0 } -$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +$seq = [DateTimeOffset]::UtcNow.Ticks $herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } try { $args = @( "pane", - "report-agent-session", + $(if ($Action -eq "session") { "report-agent-session" } else { "report-agent" }), $env:HERDR_PANE_ID, "--source", "herdr:codex", @@ -40,6 +41,7 @@ try { "--agent-session-id", "$sessionId" ) + if ($Action -ne "session") { $args += @("--state", $Action) } if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) { $args += @("--session-start-source", "$($payload.source)") } diff --git a/internal/agentintegration/upstream/codex/herdr-agent-state.sh b/internal/agentintegration/upstream/codex/herdr-agent-state.sh index 2931936a1..0823abd42 100644 --- a/internal/agentintegration/upstream/codex/herdr-agent-state.sh +++ b/internal/agentintegration/upstream/codex/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=codex -# HERDR_INTEGRATION_VERSION=8 +# HERDR_INTEGRATION_VERSION=9 set -eu @@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true case "$action" in - session) ;; + session|working|idle) ;; *) exit 0 ;; esac @@ -49,20 +49,22 @@ if hook_input_file: hook_input = {} hook_event_name = str(hook_input.get("hook_event_name") or "") -if hook_event_name and hook_event_name != "SessionStart": +expected_events = {"session": ("SessionStart",), "working": ("UserPromptSubmit",), "idle": ("Stop", "Interrupt")}[action] +if hook_event_name and hook_event_name not in expected_events: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() session_id = hook_input.get("session_id") agent_session_id = session_id if isinstance(session_id, str) and session_id else None -transcript_path = hook_input.get("transcript_path") -if not isinstance(transcript_path, str) or not transcript_path.strip(): - raise SystemExit(0) +if action == "session": + transcript_path = hook_input.get("transcript_path") + if not isinstance(transcript_path, str) or not transcript_path.strip(): + raise SystemExit(0) inherited_session_id = os.environ.get("CODEX_THREAD_ID") if inherited_session_id and inherited_session_id != agent_session_id: raise SystemExit(0) -session_start_source = hook_input.get("source") if hook_event_name == "SessionStart" else None +session_start_source = hook_input.get("source") if action == "session" else None if not isinstance(session_start_source, str) or not session_start_source: session_start_source = None if agent_session_id: @@ -73,11 +75,16 @@ if agent_session_id: "seq": report_seq, "agent_session_id": agent_session_id, } - if session_start_source: - params["session_start_source"] = session_start_source + if action == "session": + if session_start_source: + params["session_start_source"] = session_start_source + method = "pane.report_agent_session" + else: + params["state"] = action + method = "pane.report_agent" request = { "id": request_id, - "method": "pane.report_agent_session", + "method": method, "params": params, } else: diff --git a/internal/agentintegration/upstream/grok/herdr-agent-state.ps1 b/internal/agentintegration/upstream/grok/herdr-agent-state.ps1 index 69c4f5151..1b183a3b9 100644 --- a/internal/agentintegration/upstream/grok/herdr-agent-state.ps1 +++ b/internal/agentintegration/upstream/grok/herdr-agent-state.ps1 @@ -2,7 +2,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") @@ -26,6 +26,12 @@ $event = if ($null -ne $payload -and $payload.hook_event_name -is [string]) { } if ($null -ne $event -and $event -notin @("session_start", "SessionStart", "sessionStart")) { exit 0 } +$sessionStartSource = if ($null -ne $payload -and $payload.source -is [string]) { + $payload.source +} else { + $null +} + $sessionId = $env:GROK_SESSION_ID if ([string]::IsNullOrWhiteSpace($sessionId) -and $null -ne $payload) { if ($payload.session_id -is [string]) { $sessionId = $payload.session_id } @@ -35,7 +41,17 @@ if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } $seq = [DateTime]::UtcNow.Ticks $herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$herdrArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:grok", + "--agent", "grok", + "--seq", "$seq", + "--agent-session-id", "$sessionId" +) +if (-not [string]::IsNullOrWhiteSpace($sessionStartSource)) { + $herdrArgs += @("--session-start-source", "$sessionStartSource") +} try { - & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:grok --agent grok --seq $seq --agent-session-id $sessionId 2>$null | Out-Null + & $herdr @herdrArgs 2>$null | Out-Null } catch { } diff --git a/internal/agentintegration/upstream/grok/herdr-agent-state.sh b/internal/agentintegration/upstream/grok/herdr-agent-state.sh index bdf0f6715..3eaa3431f 100644 --- a/internal/agentintegration/upstream/grok/herdr-agent-state.sh +++ b/internal/agentintegration/upstream/grok/herdr-agent-state.sh @@ -3,7 +3,7 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=grok -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu @@ -62,6 +62,7 @@ def first_text(*keys): hook_event_name = first_text("hook_event_name", "hookEventName") if hook_event_name not in (None, "session_start", "SessionStart", "sessionStart"): raise SystemExit(0) +session_start_source = first_text("source") # Grok injects GROK_SESSION_ID into every hook process; prefer it and fall # back to the event payload's session id fields. @@ -72,16 +73,19 @@ if not agent_session_id: request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() +params = { + "pane_id": pane_id, + "source": source, + "agent": "grok", + "seq": report_seq, + "agent_session_id": agent_session_id, +} +if session_start_source: + params["session_start_source"] = session_start_source request = { "id": request_id, "method": "pane.report_agent_session", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "grok", - "seq": report_seq, - "agent_session_id": agent_session_id, - }, + "params": params, } try: diff --git a/internal/agentintegration/upstream/herdr-agent-state.test.ts b/internal/agentintegration/upstream/herdr-agent-state.test.ts index 2a86f0ca8..abfeb0ef7 100644 --- a/internal/agentintegration/upstream/herdr-agent-state.test.ts +++ b/internal/agentintegration/upstream/herdr-agent-state.test.ts @@ -5,12 +5,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; const originalPlatform = process.platform; +const originalArgv = process.argv; const originalCreateConnection = net.createConnection; const originalEnvironment = { HERDR_ENV: process.env.HERDR_ENV, HERDR_OMP_IDLE_DEBOUNCE_MS: process.env.HERDR_OMP_IDLE_DEBOUNCE_MS, HERDR_PANE_ID: process.env.HERDR_PANE_ID, HERDR_SOCKET_PATH: process.env.HERDR_SOCKET_PATH, + OMPCODE: process.env.OMPCODE, }; let server: Server | undefined; @@ -34,6 +36,7 @@ afterEach(async () => { Object.defineProperty(process, "platform", { value: originalPlatform }); net.createConnection = originalCreateConnection; + process.argv = originalArgv; for (const [name, value] of Object.entries(originalEnvironment)) { if (value === undefined) { delete process.env[name]; @@ -85,6 +88,8 @@ function createExtensionHarness() { } function configureIntegrationEnvironment(recordingSocketPath: string) { + // Tests may run inside an OMP shell; nested-session cases opt in explicitly. + delete process.env.OMPCODE; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = recordingSocketPath; process.env.HERDR_PANE_ID = "test:p1"; @@ -121,7 +126,7 @@ async function startRecordingServer(name: string): Promise { server = recordingServer; await new Promise((resolve, reject) => { recordingServer.once("error", reject); - recordingServer.listen(recordingSocketPath, resolve); + recordingServer.listen(originalPlatform === "win32" ? `\\\\.\\pipe\\${recordingSocketPath}` : recordingSocketPath, resolve); }); configureIntegrationEnvironment(recordingSocketPath); return requests; @@ -134,6 +139,7 @@ for (const socketPlugin of socketPlugins) { Object.defineProperty(process, "platform", { value: "win32" }); const connectedEndpoint = captureConnectionEndpoint(); + process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; const { HerdrAgentStatePlugin } = await importFresh(socketPlugin.modulePath); const plugin = await HerdrAgentStatePlugin(); await plugin.event({ @@ -229,6 +235,33 @@ for (const integration of integrations) { }); } +test("OMP ignores nested sessions launched inside another OMP shell", async () => { + const requests = await startRecordingServer("omp-nested"); + process.env.OMPCODE = "1"; + const { handlers, pi } = createExtensionHarness(); + + const { default: install } = await importFresh("./omp/herdr-agent-state.ts"); + install(pi); + + // OMP sets OMPCODE on every shell it spawns. A nested `omp` inherits it and + // must not claim the pane's session for its short-lived conversation. + expect(handlers.size).toBe(0); + await handlers.get("session_start")?.( + { reason: "startup" }, + { + hasUI: true, + isIdle: () => true, + sessionManager: { + getSessionFile: () => "/tmp/omp-nested.jsonl", + getSessionId: () => "omp-nested", + }, + }, + ); + await Bun.sleep(25); + + expect(requests).toEqual([]); +}); + test("OMP accepts POSIX and Windows session paths", async () => { const { isAbsoluteSessionPath } = await importFresh("./omp/herdr-agent-state.ts"); @@ -240,6 +273,28 @@ test("OMP accepts POSIX and Windows session paths", async () => { expect(isAbsoluteSessionPath("relative/omp-session.jsonl")).toBe(false); }); +test("Pi reports a Windows session path", async () => { + const requests = await startRecordingServer("pi-windows-session-path"); + const { handlers, pi } = createExtensionHarness(); + const { default: install } = await importFresh("./pi/herdr-agent-state.ts"); + install(pi); + + const sessionPath = "C:\\Users\\User\\.pi\\agent\\sessions\\pi-session.jsonl"; + await handlers.get("session_start")?.( + { reason: "startup" }, + { + ...piContext(() => true), + sessionManager: { + getSessionFile: () => sessionPath, + getSessionId: () => "pi-session", + }, + }, + ); + await waitFor(() => requests.length === 2); + + expect(requests.map(requestSessionPath)).toEqual([sessionPath, sessionPath]); +}); + test("Pi reports idle only after the agent settles", async () => { const requests = await startRecordingServer("pi-settled"); const { handlers, pi } = createExtensionHarness(); @@ -378,7 +433,7 @@ test("Pi waits for a replacement session report before publishing state", async server = recordingServer; await new Promise((resolve, reject) => { recordingServer.once("error", reject); - recordingServer.listen(recordingSocketPath, resolve); + recordingServer.listen(originalPlatform === "win32" ? `\\\\.\\pipe\\${recordingSocketPath}` : recordingSocketPath, resolve); }); configureIntegrationEnvironment(recordingSocketPath); @@ -457,7 +512,7 @@ async function startDroppedFirstResponseServer(name: string) { server = recordingServer; await new Promise((resolve, reject) => { recordingServer.once("error", reject); - recordingServer.listen(recordingSocketPath, resolve); + recordingServer.listen(originalPlatform === "win32" ? `\\\\.\\pipe\\${recordingSocketPath}` : recordingSocketPath, resolve); }); configureIntegrationEnvironment(recordingSocketPath); @@ -617,6 +672,13 @@ function requestState(request: unknown): unknown { return request.params.state; } +function requestSessionPath(request: unknown): unknown { + if (!isRecord(request) || !isRecord(request.params)) { + return undefined; + } + return request.params.agent_session_path; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } diff --git a/internal/agentintegration/upstream/letta/herdr-agent-session.ps1 b/internal/agentintegration/upstream/letta/herdr-agent-session.ps1 new file mode 100644 index 000000000..27f40e9bd --- /dev/null +++ b/internal/agentintegration/upstream/letta/herdr-agent-session.ps1 @@ -0,0 +1,51 @@ +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.conversation_id)) { exit 0 } + +$sessionId = [string]$payload.conversation_id +if ($sessionId -eq "default") { + if ([string]::IsNullOrWhiteSpace($payload.agent_id)) { exit 0 } + $sessionId = "default:" + [string]$payload.agent_id +} + +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +$source = if ($payload.is_new_session -eq $true) { "new" } else { "resume" } +$commandArgs = @( + "pane", "report-agent-session", $env:HERDR_PANE_ID, + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", $sessionId, + "--seq", [string][DateTime]::UtcNow.Ticks, + "--session-start-source", $source +) +$job = $null +try { + $job = Start-Job -ScriptBlock { + param($Executable, [object[]]$Arguments) + & $Executable @Arguments *> $null + } -ArgumentList $herdr, (,$commandArgs) + if ($null -eq (Wait-Job -Job $job -Timeout 1)) { + Stop-Job -Job $job + } +} catch { + # Hook failures must stay silent because Letta injects output into the next prompt. + $null = $_ +} finally { + if ($null -ne $job) { + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue + } +} diff --git a/internal/agentintegration/upstream/letta/herdr-agent-session.sh b/internal/agentintegration/upstream/letta/herdr-agent-session.sh new file mode 100644 index 000000000..d6e052d66 --- /dev/null +++ b/internal/agentintegration/upstream/letta/herdr-agent-session.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=letta +# HERDR_INTEGRATION_VERSION=1 + +[ "${1:-}" = "session" ] || exit 0 +[ "${HERDR_ENV:-}" = "1" ] || exit 0 +[ -n "${HERDR_PANE_ID:-}" ] || exit 0 +[ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 +if [ -n "${HERDR_BIN_PATH:-}" ]; then + [ -x "$HERDR_BIN_PATH" ] || exit 0 +else + command -v herdr >/dev/null 2>&1 || exit 0 +fi +command -v python3 >/dev/null 2>&1 || exit 0 + +# SessionStart stdout is injected into Letta's next user message. Keep every +# path silent, including successful reports and malformed hook input. +python3 -c ' +import json +import os +import subprocess +import sys +import time + +try: + payload = json.load(sys.stdin) + conversation_id = payload.get("conversation_id") + agent_id = payload.get("agent_id") + if not isinstance(conversation_id, str) or not conversation_id: + raise ValueError + if conversation_id == "default": + if not isinstance(agent_id, str) or not agent_id: + raise ValueError + session_id = "default:" + agent_id + else: + session_id = conversation_id + + command = os.environ.get("HERDR_BIN_PATH") or "herdr" + args = [ + command, "pane", "report-agent-session", os.environ["HERDR_PANE_ID"], + "--source", "herdr:letta", "--agent", "letta", + "--agent-session-id", session_id, "--seq", str(time.time_ns()), + "--session-start-source", "new" if payload.get("is_new_session") else "resume", + ] + subprocess.run( + args, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=1, + check=False, + ) +except Exception: + pass +' 2>/dev/null || true diff --git a/internal/agentintegration/upstream/omp/herdr-agent-state.ts b/internal/agentintegration/upstream/omp/herdr-agent-state.ts index 0e4937875..fce62106f 100644 --- a/internal/agentintegration/upstream/omp/herdr-agent-state.ts +++ b/internal/agentintegration/upstream/omp/herdr-agent-state.ts @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=omp -// HERDR_INTEGRATION_VERSION=9 +// HERDR_INTEGRATION_VERSION=10 // @ts-nocheck import net from "node:net"; @@ -14,9 +14,13 @@ const socketEndpoint = process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; const paneId = process.env.HERDR_PANE_ID; const source = "herdr:omp"; +// OMP marks every shell it spawns with OMPCODE=1. A nested `omp` launched from +// a parent session's shell inherits it, so that process is not the pane's root +// agent and must not report its short-lived session over the parent's. +const nestedOmpSession = process.env.OMPCODE === "1"; function enabled() { - return HERDR_ENV === "1" && !!socketPath && !!paneId; + return HERDR_ENV === "1" && !!socketPath && !!paneId && !nestedOmpSession; } let requestQueue = Promise.resolve(); diff --git a/internal/agentintegration/upstream/opencode/herdr-agent-state.js b/internal/agentintegration/upstream/opencode/herdr-agent-state.js index 2840c221a..09c007043 100644 --- a/internal/agentintegration/upstream/opencode/herdr-agent-state.js +++ b/internal/agentintegration/upstream/opencode/herdr-agent-state.js @@ -2,7 +2,7 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=10 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -13,8 +13,8 @@ let requestChain = Promise.resolve(); let reportedRootSessionID; // Track child sessions so their events cannot replace the pane's root session. -// Their user prompts still project state without attaching the child session id. -const childSessions = new Set(); +// User prompts carry the root id to preserve its identity and cross-talk guard. +const childSessions = new Map(); const CHILD_EVENT_STATES = new Map([ ["permission.asked", "blocked"], ["question.asked", "blocked"], @@ -118,8 +118,23 @@ function reportState(state, sessionID) { return request("pane.report_agent", params); } +function ownsLocalLifecycle() { + const args = process.argv.slice(2); + const separator = args.indexOf("--"); + if (separator !== -1) args.splice(separator); + if (args.some((arg) => arg === "--attach" || arg.startsWith("--attach="))) return false; + while (args[0] === "--print-logs" || args[0] === "--log-level" || args[0]?.startsWith("--log-level=")) { + args.splice(0, args[0] === "--log-level" ? 2 : 1); + } + // These local clients have no TUI plugin. Shared servers and the TUI worker + // cannot identify their attached panes; their lifecycle belongs to each TUI. + return args[0] === "run" || + (!["serve", "web", "attach"].includes(args[0]) && args.includes("--mini")); +} + export const HerdrAgentStatePlugin = async () => { if ( + !ownsLocalLifecycle() || process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID @@ -141,12 +156,16 @@ export const HerdrAgentStatePlugin = async () => { const info = properties.info; if (info?.id && info.parentID) { - childSessions.add(info.id); + childSessions.set(info.id, info.parentID); } if (sessionID && childSessions.has(sessionID)) { const state = CHILD_EVENT_STATES.get(type); if (state) { - await reportState(state); + let rootSessionID = sessionID; + while (childSessions.has(rootSessionID)) { + rootSessionID = childSessions.get(rootSessionID); + } + await reportState(state, rootSessionID); } return; } @@ -195,3 +214,11 @@ export const HerdrAgentStatePlugin = async () => { }, }; }; + +// V1 local run/Mini retain their server hooks. V1/V2 full TUIs own both +// selection and lifecycle, including when attached to a shared remote server. +export default { + id: "herdr.opencode", + server: HerdrAgentStatePlugin, + setup() {}, +}; diff --git a/internal/agentintegration/upstream/opencode/herdr-agent-state.test.ts b/internal/agentintegration/upstream/opencode/herdr-agent-state.test.ts index 6f8ee5900..93d27583c 100644 --- a/internal/agentintegration/upstream/opencode/herdr-agent-state.test.ts +++ b/internal/agentintegration/upstream/opencode/herdr-agent-state.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + +const originalArgv = process.argv; +afterEach(() => { process.argv = originalArgv; }); const requests: unknown[] = []; const clients: FakeClient[] = []; @@ -43,6 +46,7 @@ beforeEach(() => { clients.length = 0; requestWaiters.length = 0; autoAcknowledge = true; + process.argv = ["bun", "/$bunfs/root/src/index.js", "run"]; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = "test.sock"; process.env.HERDR_PANE_ID = "test:p1"; @@ -167,7 +171,6 @@ test("reports child prompts without replacing the root session", async () => { event: { type: "session.created", properties: { - sessionID: "child-session", info: { id: "child-session", parentID: "root-session" }, }, }, @@ -188,18 +191,77 @@ test("reports child prompts without replacing the root session", async () => { "working", ]); expect(requests.map(requestSessionID)).toEqual([ - undefined, - undefined, - undefined, - undefined, - undefined, + "root-session", + "root-session", + "root-session", + "root-session", + "root-session", ]); }); +test("routes nested child prompts to their own root, not the last active root", async () => { + const plugin = await loadPlugin(); + for (const info of [ + { id: "child-session", parentID: "root-session" }, + { id: "nested-session", parentID: "child-session" }, + ]) { + await plugin.event({ event: { type: "session.created", properties: { info } } }); + } + await plugin["chat.message"]({ sessionID: "other-root" }); + await plugin.event({ + event: { type: "permission.asked", properties: { sessionID: "nested-session" } }, + }); + await plugin.event({ + event: { type: "permission.replied", properties: { sessionID: "nested-session" } }, + }); + await plugin.event({ + event: { type: "session.idle", properties: { sessionID: "nested-session" } }, + }); + await plugin["chat.message"]({ sessionID: "nested-session" }); + + expect(requests.map(requestState)).toEqual(["working", "blocked", "working"]); + expect(requests.map(requestSessionID)).toEqual([ + "other-root", + "root-session", + "root-session", + ]); +}); + +test("only local run and Mini own server lifecycle, never shared servers or TUI workers", async () => { + for (const args of [ + ["run"], ["run", "--session", "existing"], ["--mini"], ["--mini", "--session", "existing"], + ["--print-logs", "--log-level", "DEBUG", "run"], ["run", "--", "--attach"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect((await loadPlugin()).event).toBeFunction(); + } + for (const args of [ + [], ["--session", "existing"], ["serve"], ["web"], ["attach", "http://localhost:4096"], + ["run", "--attach", "http://localhost:4096"], ["--mini", "--attach=http://localhost:4096"], + ["serve", "--", "--mini"], + ]) { + process.argv = ["bun", "/$bunfs/root/src/index.js", ...args]; + expect(await loadPlugin()).toEqual({}); + } + process.argv = ["bun", "/$bunfs/root/src/cli/tui/worker.js"]; + expect(await loadPlugin()).toEqual({}); + expect(requests).toHaveLength(0); +}); + function requestMethod(request: unknown): unknown { return isRecord(request) ? request.method : undefined; } +test("dual server entrypoint keeps V1 hooks and never reports from the V2 shared server", async () => { + const module = await import(`./herdr-agent-state.js?test=${++importCounter}`); + expect(module.default.server).toBe(module.HerdrAgentStatePlugin); + expect(await module.default.setup({})).toBeUndefined(); + expect(requests).toHaveLength(0); + const hooks = await module.default.server(); + await hooks["chat.message"]({ sessionID: "v1-root" }); + expect(requests.map(requestState)).toEqual(["working"]); +}); + function requestState(request: unknown): unknown { return requestParam(request, "state"); } diff --git a/internal/agentintegration/upstream/opencode/herdr-tui-session.js b/internal/agentintegration/upstream/opencode/herdr-tui-session.js index b5be5bbc8..d21c1f7e8 100644 --- a/internal/agentintegration/upstream/opencode/herdr-tui-session.js +++ b/internal/agentintegration/upstream/opencode/herdr-tui-session.js @@ -1,7 +1,7 @@ // installed by herdr // managed by herdr; reinstalling or updating the integration overwrites this file. // HERDR_INTEGRATION_ID=opencode-tui -// HERDR_INTEGRATION_VERSION=10 +// HERDR_INTEGRATION_VERSION=13 import net from "node:net"; @@ -10,11 +10,11 @@ const AGENT = "opencode"; const ROUTE_POLL_INTERVAL_MS = 100; const SELECTION_RETRY_DELAYS_MS = [100, 400, 1_000]; -function requestOnce(sessionID) { +function requestOnce(sessionID, state, seq, isCurrent = () => true) { const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; if (!paneId || !socketPath) { - return Promise.resolve(); + return Promise.resolve(true); } const socketEndpoint = @@ -23,91 +23,582 @@ function requestOnce(sessionID) { id: `${SOURCE}:tui:${Date.now()}:${Math.floor(Math.random() * 1_000_000) .toString() .padStart(6, "0")}`, - method: "pane.report_agent_session", + method: state === undefined ? "pane.report_agent_session" : "pane.report_agent", params: { pane_id: paneId, source: SOURCE, agent: AGENT, agent_session_id: sessionID, - session_start_source: "select", + ...(state === undefined ? { session_start_source: "select" } : { state, seq }), }, }; return new Promise((resolve) => { + let settled = false; + let timer; + const settle = (delivered) => { + if (settled) return; + settled = true; + clearTimeout(timer); + client.destroy(); + resolve(delivered); + }; const client = net.createConnection(socketEndpoint, () => { + if (!isCurrent()) { + settle(false); + return; + } client.write(`${JSON.stringify(request)}\n`); }); - const finish = () => { - client.destroy(); - resolve(); - }; - client.setTimeout(500, finish); - client.on("data", finish); - client.on("error", finish); - client.on("end", finish); - client.on("close", resolve); + // A plain timer, not socket.setTimeout, so a connection that never finishes + // connecting still settles and cannot block later reports behind the queue. + timer = setTimeout(() => settle(false), 500); + timer.unref?.(); + client.on("data", () => settle(true)); + client.on("error", () => settle(false)); + client.on("end", () => settle(false)); + client.on("close", () => settle(false)); }); } export default { id: "herdr.opencode.session-selection", - tui: async (api) => { - if ( - process.env.HERDR_ENV !== "1" || - !process.env.HERDR_SOCKET_PATH || - !process.env.HERDR_PANE_ID - ) { + // Keep this plain object dependency-free: V1 and V2 expose different SDK + // packages, but both loaders accept their own lifecycle entry on this object. + setup, + tui, +}; + +async function tui(api) { + if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; + + let disposed = false; + let context; + let sequence = Date.now() * 1000; + let chain = Promise.resolve(); + + function routeID() { + const route = api.route.current; + return route?.name === "session" ? route.params?.sessionID : undefined; + } + + function current(ctx) { + return !disposed && context === ctx && routeID() === ctx.route; + } + + async function read(ctx, request) { + const result = await request({ + signal: AbortSignal.any([ctx.controller.signal, AbortSignal.timeout(5_000)]), + throwOnError: true, + }); + if (!current(ctx) || result?.data === undefined) throw new Error("session data unavailable"); + return result.data; + } + + // The first selected session owns this pane's subtree. Browsing descendants + // keeps that boundary; directly attaching to a child does not claim siblings. + function root(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + const session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) return; + if (!session.parentID) return id; + id = session.parentID; + } + } + + async function resolveRoot(ctx, id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id) && !ctx.deleted.has(id)) { + seen.add(id); + if (id === ctx.boundary) return id; + let session = ctx.sessions.get(id) ?? api.state.session.get(id); + if (!session) { + if (!ctx.lookups.has(id)) { + const requestedID = id; + const lookup = read(ctx, (options) => api.client.session.get({ sessionID: requestedID }, options)); + ctx.lookups.set(id, lookup); + lookup.finally(() => ctx.lookups.delete(requestedID)).catch(() => {}); + } + session = await ctx.lookups.get(id); + if (ctx.deleted.has(id)) return; + session = ctx.sessions.get(id) ?? session; + if (session.id !== id) throw new Error("unexpected session identity"); + ctx.sessions.set(id, session); + } + if (!session.parentID) return id; + id = session.parentID; + } + } + + function owners(ctx) { + return new Set([...ctx.statuses.keys(), ...[...ctx.blockers.values()].map((r) => r.sessionID)]); + } + + function state(ctx) { + if (ctx.settled) return "idle"; + for (const request of ctx.blockers.values()) { + if (root(ctx, request.sessionID) === ctx.selected) return "blocked"; + } + if (ctx.errors.has(ctx.selected)) return "blocked"; + for (const id of ctx.statuses.keys()) { + if (root(ctx, id) === ctx.selected) return "working"; + } + if (ctx.hydrated && [...owners(ctx)].every((id) => root(ctx, id) !== undefined)) return "idle"; + } + + function publish(ctx, selection = false) { + if (!current(ctx) || !ctx.selected) return; + ctx.selectionPending ||= selection; + if (ctx.queued) return; + ctx.queued = true; + chain = chain.then(async () => { + ctx.queued = false; + const selected = ctx.selected; + const isCurrent = () => current(ctx) && !!selected && ctx.selected === selected && + (ctx.settled || root(ctx, ctx.route) === selected); + if (!isCurrent()) return; + if (ctx.selectionPending) { + if (!await requestOnce(selected, undefined, undefined, isCurrent)) { + ctx.retryAt = Date.now() + 500; + return; + } + if (!isCurrent()) return; + ctx.selectionPending = false; + ctx.lastState = undefined; + } + const value = state(ctx); + if (value === undefined || value === ctx.lastState) return; + const delivered = await requestOnce(selected, value, ++sequence, isCurrent); + if (!isCurrent()) return; + ctx.lastState = delivered ? value : undefined; + if (!delivered) ctx.retryAt = Date.now() + 500; + }).catch(() => { + if (current(ctx)) { + ctx.lastState = undefined; + ctx.retryAt = Date.now() + 500; + } + }); + } + + function retire(ctx, route) { + ctx?.controller?.abort(); + context = ctx?.selected + ? { route, selected: ctx.selected, settled: true, retryAt: Infinity } + : undefined; + if (context) publish(context); + } + + function reconcile(ctx) { + if (!current(ctx)) return; + publish(ctx); + if (ctx.resolving) { + ctx.resolveAgain = true; return; } + ctx.resolving = true; + void (async () => { + const ancestor = await resolveRoot(ctx, ctx.route); + if (!current(ctx) || !ancestor) return; + const selected = ancestor === ctx.boundary ? ctx.boundary : ctx.route; + if (selected !== ctx.selected) { + ctx.boundary = selected; + ctx.selected = selected; + publish(ctx, true); + } + // Fetch ancestry only for active/request-bearing sessions, not historical trees. + await Promise.all([...owners(ctx)].map((id) => resolveRoot(ctx, id))); + if (current(ctx)) publish(ctx); + })().catch(() => { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + }).finally(() => { + ctx.resolving = false; + if (ctx.resolveAgain) { + ctx.resolveAgain = false; + reconcile(ctx); + } + }); + } - let selectedSessionID; - let retryIndex = 0; - let nextReportAt = 0; - let reportPending = false; - const syncSelectedSession = async () => { - const route = api.route.current; - const sessionID = route?.name === "session" ? route.params?.sessionID : undefined; - const session = - typeof sessionID === "string" && sessionID - ? api.state.session.get(sessionID) - : undefined; - if (!session || session.parentID) { - selectedSessionID = undefined; - retryIndex = 0; - nextReportAt = 0; - return; + function clearRequests(ctx, matches) { + for (const [key, request] of ctx.blockers) if (matches(request)) ctx.blockers.delete(key); + } + + function terminalTool(part) { + return part?.type === "tool" && ["completed", "error"].includes(part.state?.status); + } + + function apply(ctx, event) { + const data = event.properties; + if (!data) return; + if (event.type === "message.part.updated") { + const part = data.part; + if (terminalTool(part)) { + clearRequests(ctx, (r) => r.sessionID === part.sessionID && + r.tool?.messageID === part.messageID && r.tool?.callID === part.callID); } - if (sessionID !== selectedSessionID) { - selectedSessionID = sessionID; - retryIndex = 0; - nextReportAt = 0; + return; + } + const id = data.sessionID ?? data.info?.id; + if (typeof id !== "string" || ctx.deleted.has(id)) return; + switch (event.type) { + case "session.created": + case "session.updated": + if (data.info) ctx.sessions.set(id, data.info); + break; + case "session.deleted": + ctx.deleted.add(id); + ctx.sessions.delete(id); + ctx.statuses.delete(id); + ctx.errors.delete(id); + clearRequests(ctx, (r) => r.sessionID === id); + if (ctx.selected === id) retire(ctx, ctx.route); + break; + case "session.status": + case "session.idle": { + const status = event.type === "session.idle" ? "idle" : data.status?.type; + if (status === "busy" || status === "retry") { + ctx.statuses.set(id, status); + ctx.errors.delete(id); + } else if (status === "idle") { + ctx.statuses.delete(id); + ctx.errors.delete(id); + } + break; } - if (reportPending || Date.now() < nextReportAt) { + case "session.error": + if (data.error?.name !== "MessageAbortedError") ctx.errors.add(id); + break; + case "permission.asked": + case "question.asked": + if (typeof data.id === "string") ctx.blockers.set(`${event.type.split(".")[0]}:${data.id}`, data); + break; + case "permission.replied": + case "question.replied": + case "question.rejected": + ctx.blockers.delete(`${event.type.split(".")[0]}:${data.requestID}`); + break; + } + } + + async function hydrate(ctx) { + if (!current(ctx) || ctx.loading) return; + ctx.loading = true; + try { + const [statuses, permissions, questions] = await Promise.all([ + read(ctx, (options) => api.client.session.status(undefined, options)), + read(ctx, (options) => api.client.permission.list(undefined, options)), + read(ctx, (options) => api.client.question.list(undefined, options)), + ]); + if (!statuses || typeof statuses !== "object" || Array.isArray(statuses) || + !Object.values(statuses).every((s) => ["busy", "retry", "idle"].includes(s?.type)) || + !Array.isArray(permissions) || !Array.isArray(questions)) { + throw new Error("incomplete session snapshot"); + } + const messages = new Map(); + const blockers = new Map(); + let validated = true; + await Promise.all([["permission", permissions], ["question", questions]].flatMap(([kind, requests]) => + requests.map(async (request) => { + if (ctx.deleted.has(request.sessionID)) return; + if (request.tool) { + const { messageID, callID } = request.tool; + const key = `${request.sessionID}:${messageID}`; + if (!messages.has(key)) messages.set(key, read(ctx, (options) => api.client.session.message({ + sessionID: request.sessionID, messageID, + }, options))); + try { + const message = await messages.get(key); + const part = message.parts?.find((p) => p.type === "tool" && p.callID === callID); + // OpenCode can retain pending requests after abort. Status alone is + // insufficient: the same session may already be running a new turn. + if (terminalTool(part)) return; + } catch { + validated = false; + } + } + if (!ctx.deleted.has(request.sessionID)) blockers.set(`${kind}:${request.id}`, request); + }))); + if (!current(ctx)) return; + ctx.statuses = new Map(Object.entries(statuses) + .filter(([id, status]) => !ctx.deleted.has(id) && (status.type === "busy" || status.type === "retry"))); + ctx.blockers = blockers; + // Preserve deltas until hydration succeeds, including retry backoff: + // a later snapshot must not revive a replied or cancelled request. + for (const event of ctx.events) apply(ctx, event); + ctx.hydrated = validated; + if (!validated) ctx.retryAt = Date.now() + 500; + reconcile(ctx); + } catch { + if (current(ctx)) ctx.retryAt = Date.now() + 500; + } finally { + ctx.loading = false; + if (ctx.hydrated) ctx.events = []; + } + } + + function syncSelection(reset = false) { + if (disposed) return; + const id = routeID(); + if (reset || id !== context?.route) { + if (typeof id !== "string" || !id) { + retire(context, id); return; } + const boundary = context?.settled ? undefined : context?.selected; + context?.controller?.abort(); + const ctx = { + route: id, boundary, controller: new AbortController(), sessions: new Map(), lookups: new Map(), + statuses: new Map(), blockers: new Map(), errors: new Set(), deleted: new Set(), + events: [], hydrated: false, loading: false, resolving: false, + retryAt: Infinity, selectionAt: 0, retryIndex: 0, + }; + context = ctx; + reconcile(ctx); + void hydrate(ctx); + } + const ctx = context; + if (!ctx) return; + if (!ctx.settled && ctx.selected && Date.now() >= ctx.selectionAt) { + publish(ctx, true); + const delay = SELECTION_RETRY_DELAYS_MS[ctx.retryIndex++]; + ctx.selectionAt = delay === undefined ? Infinity : Date.now() + delay; + } + if (Date.now() >= ctx.retryAt) { + ctx.retryAt = Infinity; + if (ctx.settled) publish(ctx); + else { + if (!ctx.hydrated) void hydrate(ctx); + reconcile(ctx); + } + } + } + + const subscriptions = [ + "session.created", "session.updated", "session.deleted", "session.status", "session.idle", "session.error", + "permission.asked", "permission.replied", "question.asked", "question.replied", "question.rejected", + "message.part.updated", + ].map((type) => api.event.on(type, (event) => { + if (type === "message.part.updated" && !terminalTool(event.properties?.part)) return; + syncSelection(); + const ctx = context; + if (!ctx || ctx.settled) return; + if (ctx.loading || !ctx.hydrated) ctx.events.push(event); + apply(ctx, event); + reconcile(ctx); + })); + for (const type of ["server.connected", "server.instance.disposed", "global.disposed"]) { + subscriptions.push(api.event.on(type, () => syncSelection(true))); + } + const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS); + api.lifecycle.onDispose(() => { + disposed = true; + context?.controller?.abort(); + clearInterval(poll); + for (const unsubscribe of subscriptions) unsubscribe(); + }); + syncSelection(); +} + +function setup(api) { + if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_SOCKET_PATH || !process.env.HERDR_PANE_ID) return; + + let disposed = false; + let selected; + let generation = 0; + let sequence = Date.now() * 1000; + let chain = Promise.resolve(); + let retryIndex = 0; + let nextSelectionAt = 0; + let state = "idle"; + let retryTimer; + const sessions = new Map(); + let blockers = new Map(); + // Event callbacks may precede cache updates. Retain each delta until the + // cache reflects it, so late hydration cannot undo a reply or lose an ask. + const blockerChanges = new Map(); + + function root(id) { + const seen = new Set(); + while (typeof id === "string" && !seen.has(id)) { + seen.add(id); + const session = api.data.session.get(id) ?? sessions.get(id); + if (!session) return; + if (!session.parentID) return id; + id = session.parentID; + } + } + + function current() { + const route = api.ui.router.current(); + return route.type === "session" ? root(route.sessionID) : undefined; + } + + // Selection and lifecycle use one queue. Recheck attribution at dispatch, + // not just when receiving the event, and reject A -> B -> A stale work too. + function enqueue(value) { + const sessionID = selected; + const revision = generation; + const isCurrent = () => !disposed && revision === generation && !!sessionID && current() === sessionID; + chain = chain.then(async () => { + if (!isCurrent()) return; + const delivered = await requestOnce(sessionID, value, value === undefined ? undefined : ++sequence, isCurrent); + if (!delivered) scheduleStateRetry(); + }).catch(() => {}); + } + + // A dropped report must not strand the pane on a stale state once the + // selection retry schedule has run out: resend the latest state until the + // socket accepts it or the selection is no longer current. + function scheduleStateRetry() { + if (disposed || retryTimer) return; + retryTimer = setTimeout(() => { + retryTimer = undefined; + publish(); + }, 500); + retryTimer.unref?.(); + } + + function publish() { + enqueue(blockers.size ? "blocked" : state); + } + + function changeBlocker(id, kind, requestID, present) { + if (typeof requestID !== "string") return; + const key = `${kind}:${requestID}`; + blockerChanges.set(key, { id, kind, present }); + if (present) blockers.set(key, id); + else blockers.delete(key); + } + + function reconcileBlockers() { + const next = new Map(); + const hydrated = new Set(); + const members = new Set([selected, ...api.data.session.family(selected), ...blockers.values()]); + for (const member of members) { + if (root(member) !== selected) continue; + for (const kind of ["permission", "form"]) { + const items = api.data.session[kind].list(member); + if (items === undefined) { + for (const [key, owner] of blockers) { + if (owner === member && key.startsWith(`${kind}:`)) next.set(key, owner); + } + continue; + } + hydrated.add(`${kind}:${member}`); + for (const item of items) next.set(`${kind}:${item.id}`, member); + } + } + for (const [key, change] of blockerChanges) { + if (hydrated.has(`${change.kind}:${change.id}`) && next.has(key) === change.present) { + blockerChanges.delete(key); + } else if (change.present) { + next.set(key, change.id); + } else { + next.delete(key); + } + } + const changed = (blockers.size > 0) !== (next.size > 0); + blockers = next; + return changed; + } - const reportingSessionID = sessionID; - reportPending = true; - try { - await requestOnce(reportingSessionID); - } catch { - // Best-effort reporting retries below while the selected route remains active. - } finally { - reportPending = false; + function syncSelection() { + if (disposed) return; + const id = current(); + if (id !== selected) { + selected = id; + generation += 1; + retryIndex = 0; + nextSelectionAt = 0; + blockers.clear(); + blockerChanges.clear(); + if (id) { + state = api.data.session.status(id) === "running" ? "working" : "idle"; } - if (selectedSessionID !== reportingSessionID) { - retryIndex = 0; - nextReportAt = 0; - return; + } + if (!id) return; + const blockersChanged = reconcileBlockers(); + if (Date.now() < nextSelectionAt) { + if (blockersChanged) publish(); + return; + } + enqueue(undefined); + publish(); + const delay = SELECTION_RETRY_DELAYS_MS[retryIndex++]; + nextSelectionAt = delay === undefined ? Number.POSITIVE_INFINITY : Date.now() + delay; + } + + function receive({ details: event }) { + if (disposed) return; + const data = event.data; + if (data == null) return; + if (event.type === "session.created") { + sessions.set(data.sessionID, { id: data.sessionID, parentID: data.parentID }); + } + if (event.type === "session.deleted") { + const affected = data.sessionID === selected || [...blockers.values()].includes(data.sessionID); + sessions.delete(data.sessionID); + // Deletion is delivered after the cache can remove the session. Use + // stored ownership rather than looking up the deleted child's ancestry. + for (const [key, owner] of blockers) if (owner === data.sessionID) blockers.delete(key); + for (const [key, change] of blockerChanges) { + if (change.id === data.sessionID) blockerChanges.delete(key); } - const retryDelay = SELECTION_RETRY_DELAYS_MS[retryIndex]; - retryIndex += 1; - nextReportAt = retryDelay === undefined ? Number.POSITIVE_INFINITY : Date.now() + retryDelay; - }; + syncSelection(); + if (selected && affected) publish(); + return; + } + syncSelection(); + const id = event.type === "form.created" ? data.form.sessionID : data.sessionID; + if (!selected || root(id) !== selected) return; + switch (event.type) { + case "permission.asked": + changeBlocker(id, "permission", data.id, true); + break; + case "permission.replied": + changeBlocker(id, "permission", data.requestID, false); + break; + case "form.created": + changeBlocker(id, "form", data.form.id, true); + break; + case "form.replied": + case "form.cancelled": + changeBlocker(id, "form", data.id, false); + break; + case "session.execution.started": + if (id !== selected) return; + state = "working"; + break; + case "session.execution.succeeded": + case "session.execution.interrupted": + if (id !== selected) return; + state = "idle"; + break; + case "session.execution.failed": + if (id !== selected) return; + state = "blocked"; + break; + default: + return; + } + publish(); + } - await syncSelectedSession(); - const routePoll = setInterval(() => void syncSelectedSession(), ROUTE_POLL_INTERVAL_MS); - api.lifecycle.onDispose(() => clearInterval(routePoll)); - }, -}; + const unsubscribe = api.data.listen(receive); + syncSelection(); + const poll = setInterval(syncSelection, ROUTE_POLL_INTERVAL_MS); + return () => { + disposed = true; + generation += 1; + clearTimeout(retryTimer); + clearInterval(poll); + unsubscribe(); + sessions.clear(); + blockers.clear(); + blockerChanges.clear(); + }; +} diff --git a/internal/agentintegration/upstream/opencode/herdr-tui-session.test.ts b/internal/agentintegration/upstream/opencode/herdr-tui-session.test.ts index 1bcb1f81e..5ef5ce1af 100644 --- a/internal/agentintegration/upstream/opencode/herdr-tui-session.test.ts +++ b/internal/agentintegration/upstream/opencode/herdr-tui-session.test.ts @@ -3,15 +3,25 @@ import { afterEach, beforeEach, expect, mock, test } from "bun:test"; const requests: unknown[] = []; const activeDisposers: Array<() => void> = []; const requestWaiters: Array<() => void> = []; +const stateWaiters: Array<() => void> = []; let importCounter = 0; +let holdConnections = false; +let failConnections = false; +const connections: Array<() => void> = []; mock.module("node:net", () => ({ default: { createConnection(_path: string, onConnect: () => void) { const handlers = new Map void>(); const client = { + destroyed: false, write(input: string) { - requests.push(JSON.parse(input.trim())); + if (client.destroyed) return; + const request = JSON.parse(input.trim()); + requests.push(request); + if (isRecord(request) && isRecord(request.params) && request.params.state !== undefined) { + stateWaiters.shift()?.(); + } requestWaiters.shift()?.(); queueMicrotask(() => client.emit("data")); }, @@ -19,12 +29,16 @@ mock.module("node:net", () => ({ on(event: string, handler: () => void) { handlers.set(event, handler); }, - destroy() {}, + destroy() { + client.destroyed = true; + }, emit(event: string) { handlers.get(event)?.(); }, }; - queueMicrotask(onConnect); + if (holdConnections) connections.push(onConnect); + else if (failConnections) queueMicrotask(() => client.emit("error")); + else queueMicrotask(onConnect); return client; }, }, @@ -33,6 +47,10 @@ mock.module("node:net", () => ({ beforeEach(() => { requests.length = 0; requestWaiters.length = 0; + stateWaiters.length = 0; + holdConnections = false; + failConnections = false; + connections.length = 0; process.env.HERDR_ENV = "1"; process.env.HERDR_SOCKET_PATH = "test.sock"; process.env.HERDR_PANE_ID = "test:p1"; @@ -52,12 +70,48 @@ async function loadPlugin() { function fakeApi() { const sessions = new Map(); + const statuses: Record = {}; + const permissions: Array<{ id: string; sessionID: string; tool?: { messageID: string; callID: string } }> = []; + const questions: typeof permissions = []; + const messages = new Map }>(); + const listeners = new Map void>>(); + const calls: string[] = []; let current: { name: string; params?: { sessionID: string } } = { name: "home" }; let dispose: (() => void) | undefined; activeDisposers.push(() => dispose?.()); return { + statuses, permissions, questions, messages, listeners, calls, + emit(type: string, properties: object) { + for (const receive of listeners.get(type) ?? []) receive({ type, properties }); + }, api: { + client: { + session: { + async get({ sessionID }: { sessionID: string }) { + calls.push(`get:${sessionID}`); + const data = sessions.get(sessionID); + if (!data) throw new Error("session not found"); + return { data }; + }, + async status() { calls.push("status"); return { data: { ...statuses } }; }, + async message({ messageID }: { sessionID: string; messageID: string }) { + calls.push(`message:${messageID}`); + const data = messages.get(messageID); + if (!data) throw new Error("message unavailable"); + return { data }; + }, + }, + permission: { async list() { return { data: [...permissions] }; } }, + question: { async list() { return { data: [...questions] }; } }, + }, + event: { + on(type: string, receive: (event: object) => void) { + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(receive); + return () => listeners.get(type)!.delete(receive); + }, + }, route: { get current() { return current; @@ -83,6 +137,7 @@ function fakeApi() { select(sessionID: string) { current = { name: "session", params: { sessionID } }; }, + home() { current = { name: "home" }; }, dispose() { dispose?.(); }, @@ -93,6 +148,10 @@ function waitForNextRequest(): Promise { return new Promise((resolve) => requestWaiters.push(resolve)); } +function waitForStateReport(): Promise { + return new Promise((resolve) => stateWaiters.push(resolve)); +} + test("reports a root session when only the local route changes", async () => { const plugin = await loadPlugin(); const tui = fakeApi(); @@ -118,10 +177,9 @@ test("retries an initial selection while Herdr detects the process", async () => await plugin.tui(tui.api); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests.map((request) => requestParam(request, "agent_session_id"))).toEqual([ - "session-a", - "session-a", - ]); + const selections = requests.filter((request) => requestParam(request, "state") === undefined); + expect(selections.length).toBeGreaterThanOrEqual(2); + expect(selections.every((request) => requestParam(request, "agent_session_id") === "session-a")).toBe(true); }); test("does not report root sessions not selected by this TUI", async () => { @@ -147,13 +205,13 @@ test("does not replace the root session with a selected child session", async () tui.addSession({ id: "child-session", parentID: "root-session" }); tui.select("root-session"); await plugin.tui(tui.api); - expect(requests).toHaveLength(1); + await flushReports(); tui.select("child-session"); await new Promise((resolve) => setTimeout(resolve, 125)); - expect(requests).toHaveLength(1); - expect(requestParam(requests[0], "agent_session_id")).toBe("root-session"); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root-session")).toBe(true); }); test("stops route polling when the TUI plugin is disposed", async () => { @@ -179,3 +237,467 @@ function requestParam(request: unknown, name: string): unknown { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } + +function v2Api() { + const sessions = new Map([ + ["a", { id: "a" }], + ["b", { id: "b" }], + ["child", { id: "child", parentID: "a" }], + ]); + let route = { type: "session", sessionID: "a" }; + const listeners = new Set<(event: unknown) => void>(); + const permissions = new Map | undefined>(); + const forms = new Map | undefined>(); + return { + api: { + ui: { router: { current: () => route } }, + data: { + session: { + get: (id: string) => sessions.get(id), + family: () => [...sessions.keys()], + status: () => "idle", + permission: { list: (id: string) => permissions.get(id) }, + form: { list: (id: string) => forms.get(id) }, + }, + listen: (handler: (event: unknown) => void) => { + listeners.add(handler); + return () => listeners.delete(handler); + }, + }, + }, + select(sessionID: string) { route = { type: "session", sessionID }; }, + home() { route = { type: "home", sessionID: "" }; }, + emit(type: string, data?: object) { + for (const listener of listeners) listener({ details: { type, data } }); + }, + listeners, + sessions, + permissions, + forms, + }; +} + +const flushReports = () => new Promise((resolve) => setTimeout(resolve, 10)); +const states = () => requests.filter((r) => requestParam(r, "state") !== undefined) + .map((r) => requestParam(r, "state")); + +function familyApi() { + const tui = fakeApi(); + tui.addSession({ id: "root" }); + tui.addSession({ id: "child", parentID: "root" }); + tui.addSession({ id: "sibling", parentID: "root" }); + tui.addSession({ id: "grandchild", parentID: "child" }); + tui.addSession({ id: "other" }); + tui.select("root"); + return tui; +} + +test("V1 hydrates active descendants and keeps working until the whole family settles", async () => { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui.statuses.grandchild = { type: "retry" }; + tui.statuses.other = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "child", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "idle" } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "root")).toBe(true); +}); + +test("V1 retains sibling blockers and cancellation does not finish an active parent", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.asked", { id: "p", sessionID: "child" }); + tui.emit("question.asked", { id: "q", sessionID: "sibling", tool: { messageID: "m", callID: "call" } }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("permission.replied", { requestID: "p", sessionID: "child" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("session.idle", { sessionID: "sibling" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "sibling", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.emit("session.idle", { sessionID: "root" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 hydration rejects aborted tool requests even if their session is busy again", async () => { + for (const kind of ["permissions", "questions"] as const) { + const tui = familyApi(); + tui.statuses.child = { type: "busy" }; + tui[kind].push({ id: "stale", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { info: { error: { name: "MessageAbortedError" } }, parts: [ + { type: "tool", callID: "call", state: { status: "error" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.dispose(); + } +}); + +test("V1 validates pending tools instead of assuming an idle owner has no requests", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "pending", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 replays completion and replies over a late initial snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states()).not.toContain("idle"); + tui.emit("session.idle", { sessionID: "child" }); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 retains replies received between failed hydration and its retry", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child" }); + const status = tui.api.client.session.status; + tui.api.client.session.status = async () => { + tui.api.client.session.status = status; + throw new Error("temporarily unavailable"); + }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + expect(states()).not.toContain("blocked"); +}); + +test("V1 deleted sessions cannot return through a late hydration snapshot", async () => { + const tui = familyApi(); + let resolveStatus!: (result: { data: Record }) => void; + tui.api.client.session.status = () => new Promise((resolve) => { resolveStatus = resolve; }); + tui.permissions.push({ id: "p", sessionID: "child" }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("session.deleted", { info: { id: "child", parentID: "root" } }); + resolveStatus({ data: { child: { type: "busy" } } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 ignores stale hydration across A/B/A route changes and disposal", async () => { + const tui = familyApi(); + let resolveOld!: (result: { data: Record }) => void; + const status = tui.api.client.session.status; + tui.api.client.session.status = () => { + tui.api.client.session.status = status; + return new Promise((resolve) => { resolveOld = resolve; }); + }; + await (await loadPlugin()).tui(tui.api); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + requests.length = 0; + resolveOld({ data: { root: { type: "busy" } } }); + await flushReports(); + expect(states()).not.toContain("working"); + tui.dispose(); + expect([...tui.listeners.values()].every((set) => set.size === 0)).toBe(true); +}); + +test("V1 a directly attached child owns its descendants, not its parent or siblings", async () => { + const tui = familyApi(); + tui.select("child"); + tui.statuses.root = { type: "busy" }; + tui.statuses.sibling = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("idle"); + tui.emit("session.status", { sessionID: "grandchild", status: { type: "busy" } }); + await flushReports(); + expect(states().at(-1)).toBe("working"); + tui.select("grandchild"); + tui.emit("session.updated", { info: { id: "grandchild", parentID: "child" } }); + await flushReports(); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "child")).toBe(true); +}); + +test("V1 a terminal tool event during hydration cannot revive a cancelled blocker", async () => { + const tui = familyApi(); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + let resolveMessage!: (result: { data: { parts: Array } }) => void; + tui.api.client.session.message = () => new Promise((resolve) => { resolveMessage = resolve; }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.emit("message.part.updated", { part: { + type: "tool", sessionID: "child", messageID: "m", callID: "call", state: { status: "error" }, + } }); + resolveMessage({ data: { parts: [{ type: "tool", callID: "call", state: { status: "running" } }] } }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 unknown tool evidence remains blocked and retries failed message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 request validation matches the exact tool and deduplicates message reads", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "running" } }); + tui.questions.push({ id: "q", sessionID: "child", tool: { messageID: "m", callID: "finished" } }); + tui.messages.set("m", { parts: [ + { type: "tool", callID: "running", state: { status: "running" } }, + { type: "tool", callID: "finished", state: { status: "completed" } }, + ] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + expect(tui.calls.filter((call) => call === "message:m")).toHaveLength(1); + tui.emit("permission.replied", { sessionID: "child", requestID: "p" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V1 reselecting an aborted request does not resurrect it or poll completed tools", async () => { + const tui = familyApi(); + tui.permissions.push({ id: "p", sessionID: "child", tool: { messageID: "m", callID: "call" } }); + tui.messages.set("m", { parts: [{ type: "tool", callID: "call", state: { status: "error" } }] }); + await (await loadPlugin()).tui(tui.api); + await flushReports(); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + await flushReports(); + tui.select("root"); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(states()).not.toContain("blocked"); + const reads = tui.calls.length; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(tui.calls).toHaveLength(reads); +}); + +test("V1 home and selected-session deletion settle authority and retry a dropped idle report", async () => { + for (const action of ["home", "delete"]) { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + expect(states().at(-1)).toBe("working"); + failConnections = true; + if (action === "home") { + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + } else tui.emit("session.deleted", { info: { id: "root" } }); + await flushReports(); + failConnections = false; + await new Promise((resolve) => setTimeout(resolve, 650)); + expect(states().at(-1)).toBe("idle"); + tui.dispose(); + } +}); + +test("V1 a delayed home settlement cannot overwrite the next selected session", async () => { + const tui = familyApi(); + tui.statuses.root = { type: "busy" }; + await (await loadPlugin()).tui(tui.api); + await flushReports(); + requests.length = 0; + holdConnections = true; + tui.home(); + tui.emit("session.updated", { info: { id: "root" } }); + await flushReports(); + expect(connections.length).toBeGreaterThan(0); + tui.select("other"); + tui.emit("session.updated", { info: { id: "other" } }); + holdConnections = false; + for (const connect of connections.splice(0)) connect(); + await flushReports(); + expect(requests.length).toBeGreaterThan(0); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "other")).toBe(true); + expect(states().at(-1)).toBe("idle"); +}); + +test("V2 ignores events without data", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + requests.length = 0; + expect(() => tui.emit("legacy.event")).not.toThrow(); + tui.emit("session.execution.started", { sessionID: "a" }); + await flushReports(); + expect(states()).toEqual(["working"]); +}); + +test("V2 completes and interrupts without legacy idle events", async () => { + for (const terminal of ["succeeded", "interrupted", "failed"]) { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + requests.length = 0; + tui.emit("session.execution.started", { sessionID: "a" }); + tui.emit(`session.execution.${terminal}`, { sessionID: "a" }); + await flushReports(); + expect(states()).toEqual(["working", terminal === "failed" ? "blocked" : "idle"]); + dispose(); + } +}); + +test("V2 aggregates root and child blockers and ignores other roots and child completion", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + requests.length = 0; + tui.emit("session.execution.started", { sessionID: "a" }); + tui.emit("permission.asked", { sessionID: "a", id: "permission-a" }); + tui.emit("form.created", { form: { sessionID: "child", id: "form-child" } }); + tui.emit("permission.replied", { sessionID: "a", requestID: "permission-a" }); + tui.emit("session.execution.succeeded", { sessionID: "child" }); + tui.emit("session.execution.started", { sessionID: "b" }); + tui.emit("permission.asked", { sessionID: "b", id: "other" }); + await flushReports(); + expect(states().at(-1)).toBe("blocked"); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "a")).toBe(true); + tui.emit("form.cancelled", { sessionID: "child", id: "form-child" }); + tui.emit("session.execution.succeeded", { sessionID: "a" }); + await flushReports(); + expect(states().slice(-2)).toEqual(["working", "idle"]); +}); + +test("V2 discards queued reports after selection changes and stops on disposal", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + requests.length = 0; + tui.emit("session.execution.started", { sessionID: "a" }); + tui.select("b"); + tui.emit("session.execution.started", { sessionID: "b" }); + await flushReports(); + expect(requests.every((r) => requestParam(r, "agent_session_id") === "b")).toBe(true); + requests.length = 0; + tui.emit("session.execution.succeeded", { sessionID: "b" }); + tui.home(); + await flushReports(); + expect(requests).toHaveLength(0); + dispose(); + expect(tui.listeners.size).toBe(0); + tui.select("a"); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(requests).toHaveLength(0); +}); + +test("V2 reconciles late blocker hydration without reviving an already-replied request", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + tui.permissions.set("child", [{ id: "late" }]); + await waitForStateReport(); + expect(states().at(-1)).toBe("blocked"); + tui.emit("permission.replied", { sessionID: "child", requestID: "late" }); + await waitForStateReport(); + expect(states().at(-1)).toBe("idle"); + tui.permissions.set("child", []); + tui.forms.set("child", [{ id: "second" }]); + await waitForStateReport(); + expect(states().at(-1)).toBe("blocked"); + tui.sessions.delete("child"); + tui.emit("session.deleted", { sessionID: "child" }); + await flushReports(); + expect(states().at(-1)).toBe("idle"); +}); + +test("V2 never writes a delayed connection after disposal or a session switch", async () => { + for (const action of ["dispose", "switch"]) { + const plugin = await loadPlugin(); + const tui = v2Api(); + holdConnections = true; + requests.length = 0; + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + expect(connections.length).toBeGreaterThan(0); + if (action === "dispose") dispose(); + else tui.select("b"); + holdConnections = false; + for (const connect of connections.splice(0)) connect(); + await flushReports(); + expect(requests).toHaveLength(0); + dispose(); + } +}); + +test("V2 settles a connection that never completes", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + holdConnections = true; + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + const started = Date.now(); + while (connections.length <= 1 && Date.now() - started < 2_000) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(connections.length).toBeGreaterThan(1); + dispose(); +}); + +test("V2 resends the latest state after a failed delivery", async () => { + const plugin = await loadPlugin(); + const tui = v2Api(); + const dispose = await plugin.setup(tui.api); + activeDisposers.push(dispose); + await flushReports(); + // Exhaust the selection retry schedule so only the event report remains. + await new Promise((resolve) => setTimeout(resolve, 1_600)); + requests.length = 0; + tui.emit("session.execution.started", { sessionID: "a" }); + await flushReports(); + failConnections = true; + tui.emit("session.execution.succeeded", { sessionID: "a" }); + const resend = waitForStateReport(); + await new Promise((resolve) => setTimeout(resolve, 700)); + failConnections = false; + await resend; + expect(states().at(-1)).toBe("idle"); + dispose(); +}); diff --git a/internal/agentintegration/upstream/opencode/tui.js b/internal/agentintegration/upstream/opencode/tui.js new file mode 100644 index 000000000..92423a8ff --- /dev/null +++ b/internal/agentintegration/upstream/opencode/tui.js @@ -0,0 +1,5 @@ +// installed by herdr +// HERDR_INTEGRATION_ID=opencode-tui-v2 +// HERDR_INTEGRATION_VERSION=13 +// V2 resolves the directory's tui entrypoint; V1 uses the original file. +export { default } from "../herdr-tui-session.js"; diff --git a/internal/agentintegration/upstream/pi/herdr-agent-state.ts b/internal/agentintegration/upstream/pi/herdr-agent-state.ts index 67bc41889..276c964da 100644 --- a/internal/agentintegration/upstream/pi/herdr-agent-state.ts +++ b/internal/agentintegration/upstream/pi/herdr-agent-state.ts @@ -2,10 +2,11 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=pi -// HERDR_INTEGRATION_VERSION=8 +// HERDR_INTEGRATION_VERSION=9 // @ts-nocheck import net from "node:net"; +import path from "node:path"; const HERDR_ENV = process.env.HERDR_ENV; const socketPath = process.env.HERDR_SOCKET_PATH; @@ -74,7 +75,10 @@ function updateSessionRef(ctx: any): void { try { const file = ctx?.sessionManager?.getSessionFile?.(); currentAgentSessionPath = - typeof file === "string" && file.startsWith("/") ? file : undefined; + typeof file === "string" && + (path.posix.isAbsolute(file) || path.win32.isAbsolute(file)) + ? file + : undefined; } catch { currentAgentSessionPath = undefined; }