diff --git a/.smithers/spec/content/features/herdr-supervision.md b/.smithers/spec/content/features/herdr-supervision.md new file mode 100644 index 0000000000..2afff73950 --- /dev/null +++ b/.smithers/spec/content/features/herdr-supervision.md @@ -0,0 +1,81 @@ +# Herdr supervision, steer, and hijack + +> **Status:** Partial | **Priority:** P1 | **Owner:** smithers-maintainers | **Group:** Recover & replay + +Mirror a run into a herdr terminal workspace and supervise it beside your coding agent: a pane per agent node, a cockpit outline, one-key steer, live hijack, first-class reasoning effort, and `approve/deny` auto-resume. Fully degradable. + +## What you can do + +Watch and steer long-running agent workflows from the same terminal as the agent driving them, without leaving the CLI. + +## Capabilities + +### Herdr workspace mirroring + +Mirror any run into a Herdr workspace with `smithers up` --herdr or smithers herdr attach. + +### Portable workflow supervisor + +Inspect live workflow state with smithers supervisor and its top alias. + +### Durable steer queue + +Queue an instruction against a running node with smithers steer. + +### Live session handoff + +Hand off a live agent session with `smithers hijack` and resume the workflow afterward. + +### In-pane approvals + +Answer approval gates with `smithers approve` --watch and auto-resume parked detached runs. + +## Endpoints and commands + +- `CLI smithers herdr` ([docs](docs/integrations/herdr.mdx)) + +## Related docs + +- [herdr integration](docs/integrations/herdr.mdx) +- [workflow supervisor](docs/guide/workflow-supervisor.mdx) +- [watch and steer](docs/guide/watch-and-steer.mdx) + +## Test cases + +- `packages/herdr/tests/createHerdrRunSurface.test.js` +- `packages/herdr/tests/cockpitPolicy.test.js` +- `apps/cli/tests/herdr-cli.e2e.test.js` +- `apps/cli/tests/herdr-full-loop.e2e.test.js` +- `apps/cli/tests/smithers-top.test.js` +- `apps/cli/tests/steer-command.e2e.test.js` +- `apps/cli/tests/tail-steer-keys.test.js` +- `apps/cli/tests/approve-watch.e2e.test.js` + +## Observability + +- SteerQueued and SteerExpired carry runId, nodeId, and steerId; SteerConsumed additionally carries attempt and iteration attribution. +- RunHijackRequested / RunHijacked mark the park-and-hand-off transition; attempt effort is queryable on \_smithers\_attempts.effort. + +## Debugging + +- smithers herdr status reports server version, protocol, and client compatibility; a missing server emits one stderr warning and the run continues unchanged. +- `smithers inspect` lists `queued/consumed/expired` steers; `smithers why` shows steers alongside blockers. + +## Architecture + +- `packages/herdr` owns the herdr client and HerdrRunSurface; it renders and relays only — Smithers keeps execution, isolation, and durability. +- `apps/cli/src/herdr.js` mirrors runs into `workspaces/panes`; smithers-top.js is the portable workflow supervisor (gateway-sourced by default, --direct for local store). +- `packages/engine/src/steers.js` queues and expires steers; the engine consumes them at the next generate() boundary. + +## Fixes and diffs + +- 2026-07-26 initial record: herdr supervision, steer, hijack, first-class effort, and `approve/deny` auto-resume. +- `packages/herdr/src/HerdrRunSurface.ts` +- `apps/cli/src/herdr.js` +- `apps/cli/src/smithers-top.js` +- `apps/cli/src/steer.js` +- `packages/engine/src/steers.js` + +## Open gaps + +- Mid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary. diff --git a/.smithers/spec/content/features/workflow-testing.md b/.smithers/spec/content/features/workflow-testing.md index e788a755a0..e85a65e8a8 100644 --- a/.smithers/spec/content/features/workflow-testing.md +++ b/.smithers/spec/content/features/workflow-testing.md @@ -39,7 +39,7 @@ Integration and e2e harnesses require executable database and process adapters a ## Test cases - `packages/testing/tests/simulate.test.ts` -- `packages/testing/tests/fakeAgent.test.ts` +- `packages/testing/tests/unit/fakeAgent.test.ts` - `packages/testing/tests/runtimeConformance.test.ts` - `packages/testing/tests/replay-identity-fresh-process.test.ts` - `e2e/testing-framework/real-db-integration.test.ts` diff --git a/.smithers/spec/features.json b/.smithers/spec/features.json index d8aacba373..0b06ed6a00 100644 --- a/.smithers/spec/features.json +++ b/.smithers/spec/features.json @@ -2721,7 +2721,7 @@ { "tests": [ "packages/testing/tests/simulate.test.ts", - "packages/testing/tests/fakeAgent.test.ts", + "packages/testing/tests/unit/fakeAgent.test.ts", "packages/testing/tests/runtimeConformance.test.ts", "packages/testing/tests/replay-identity-fresh-process.test.ts", "e2e/testing-framework/real-db-integration.test.ts", @@ -2976,5 +2976,95 @@ "href": "docs/rpc/cron-create.mdx" } ] + }, + { + "tests": [ + "packages/herdr/tests/createHerdrRunSurface.test.js", + "packages/herdr/tests/cockpitPolicy.test.js", + "apps/cli/tests/herdr-cli.e2e.test.js", + "apps/cli/tests/herdr-full-loop.e2e.test.js", + "apps/cli/tests/smithers-top.test.js", + "apps/cli/tests/steer-command.e2e.test.js", + "apps/cli/tests/tail-steer-keys.test.js", + "apps/cli/tests/approve-watch.e2e.test.js" + ], + "observability": [ + "SteerQueued and SteerExpired carry runId, nodeId, and steerId; SteerConsumed additionally carries attempt and iteration attribution.", + "RunHijackRequested / RunHijacked mark the park-and-hand-off transition; attempt effort is queryable on _smithers_attempts.effort." + ], + "debug": [ + "smithers herdr status reports server version, protocol, and client compatibility; a missing server emits one stderr warning and the run continues unchanged.", + "smithers inspect lists queued/consumed/expired steers; smithers why shows steers alongside blockers." + ], + "architecture": [ + "packages/herdr owns the herdr client and HerdrRunSurface; it renders and relays only \u2014 Smithers keeps execution, isolation, and durability.", + "apps/cli/src/herdr.js mirrors runs into workspaces/panes; smithers-top.js is the portable workflow supervisor (gateway-sourced by default, --direct for local store).", + "packages/engine/src/steers.js queues and expires steers; the engine consumes them at the next generate() boundary." + ], + "changes": [ + "2026-07-26 initial record: herdr supervision, steer, hijack, first-class effort, and approve/deny auto-resume." + ], + "diffHints": [ + "packages/herdr/src/HerdrRunSurface.ts", + "apps/cli/src/herdr.js", + "apps/cli/src/smithers-top.js", + "apps/cli/src/steer.js", + "packages/engine/src/steers.js" + ], + "missing": [ + "Mid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary." + ], + "id": "herdr-supervision", + "title": "Herdr supervision, steer, and hijack", + "summary": "Mirror a run into a herdr terminal workspace and supervise it beside your coding agent: a pane per agent node, a cockpit outline, one-key steer, live hijack, first-class reasoning effort, and approve/deny auto-resume. Fully degradable.", + "status": "partial", + "priority": "p1", + "owner": "smithers-maintainers", + "tier": "feature", + "group": "Recover & replay", + "userValue": "Watch and steer long-running agent workflows from the same terminal as the agent driving them, without leaving the CLI.", + "capabilities": [ + { + "title": "Herdr workspace mirroring", + "detail": "Mirror any run into a Herdr workspace with smithers up --herdr or smithers herdr attach." + }, + { + "title": "Portable workflow supervisor", + "detail": "Inspect live workflow state with smithers supervisor and its top alias." + }, + { + "title": "Durable steer queue", + "detail": "Queue an instruction against a running node with smithers steer." + }, + { + "title": "Live session handoff", + "detail": "Hand off a live agent session with smithers hijack and resume the workflow afterward." + }, + { + "title": "In-pane approvals", + "detail": "Answer approval gates with smithers approve --watch and auto-resume parked detached runs." + } + ], + "endpoints": [ + { + "method": "CLI", + "path": "smithers herdr", + "doc": "docs/integrations/herdr.mdx" + } + ], + "links": [ + { + "href": "docs/integrations/herdr.mdx", + "label": "herdr integration" + }, + { + "href": "docs/guide/workflow-supervisor.mdx", + "label": "workflow supervisor" + }, + { + "href": "docs/guide/watch-and-steer.mdx", + "label": "watch and steer" + } + ] } ] diff --git a/.smithers/ui/ddd-docsContent.generated.ts b/.smithers/ui/ddd-docsContent.generated.ts index 69c399fbdf..56e011d90c 100644 --- a/.smithers/ui/ddd-docsContent.generated.ts +++ b/.smithers/ui/ddd-docsContent.generated.ts @@ -66,6 +66,12 @@ export const docsContent: { path: string; title: string; level: "product" | "tec "level": "technical", "content": "# Gateway, RPC, and server\n\n> **Status:** Partial | **Priority:** P0 | **Owner:** smithers-maintainers | **Group:** Run & observe | **Tier:** Platform\n\nThe Gateway and server expose versioned `run/workflow/approval/memory/ticket/cron/devtools` RPCs, HTTP routes, WebSocket event streams, scoped bearer auth, local workspace daemon discovery, and serverless `resume/cron` entry points.\n\n## What you can do\n\nWatch and control durable runs from browser UIs, local monitors, webhooks, or remote automation over one typed API.\n\n## Capabilities\n\n### Live events\n\nWebSocket run events power live UIs, including detached runs.\n\n### Serverless tick\n\n`Resume/cron` tick plus run-lease claims for serverless deployment.\n\n### Stable RPC contract\n\nGatewayRpcDefinition freezes v1 methods, schemas, scopes, errors, and examples.\n\n### Live event streams\n\nstreamRunEvents and streamDevTools provide bounded replay and gap-resync semantics over WebSocket.\n\n### Scoped auth\n\nGateway scopes gate run, approval, cron, memory, ticket, observability, and account access.\n\n### Webhook and cron paths\n\nServer integrations can verify signed webhooks, enqueue external events, and drive cron ticks.\n\n### Run diffs\n\ngetRunDiff returns bounded run-wide diff bundles for DevTools and review UIs.\n\n### Score comparison\n\nlistScoresForRuns and getScoreDetail expose cross-run scorer rows and detailed evidence through the stable RPC contract.\n\n## Endpoints and commands\n\n- `RPC launchRun` ([docs](docs/rpc/launch-run.mdx))\n- `RPC streamRunEvents` ([docs](docs/rpc/stream-run-events.mdx))\n- `RPC getRun` ([docs](docs/rpc/get-run.mdx))\n- `RPC listWorkflows` ([docs](docs/rpc/list-workflows.mdx))\n- `HTTP /metrics` ([docs](docs/deployment/production-hardening.mdx))\n- `RPC getRunDiff` ([docs](docs/rpc/get-run-diff.mdx))\n- `RPC listScoresForRuns` ([docs](docs/rpc/list-scores-for-runs.mdx))\n\n## Related docs\n\n- [Gateway integration](docs/integrations/gateway.mdx)\n- [HTTP server](docs/integrations/server.mdx)\n- [RPC reference](docs/rpc/launch-run.mdx)\n\n## Test cases\n\n- `packages/gateway/tests/rpc-contract.test.ts`\n- `packages/gateway/tests/generate-openapi.test.ts`\n- `packages/server/tests/gateway-bounds.test.js`\n- `apps/cli/tests/gateway-command.test.js`\n- `apps/cli/tests/gateway-runtime.test.js`\n- `apps/cli/tests/gateway-root-and-workflow-ui.e2e.test.js`\n- `e2e/faults/case14-gateway-rpc-roundtrip.test.ts`\n- `e2e/faults/case15-ws-drop-reconnect.test.ts`\n- `e2e/faults/case16-n5-subscribers-bounded-memory.test.ts`\n- `packages/server/tests/gateway-score-rpcs.test.jsx`\n\n## Observability\n\n- Gateway tracks httpRequests, httpRequestDuration, gatewayApprovalDecisionsTotal, gatewaySignalsTotal, gatewayWebhooksVerifiedTotal, and stream backpressure events.\n- streamRunEvents retains a bounded event window and reports gap-resync when clients reconnect from outside the window.\n\n## Debugging\n\n- Use `smithers gateway` `status/stop` and the runtime state file identity checks to diagnose local daemon discovery.\n- Use `packages/gateway/tests/rpc-contract.test.ts` when changing RPC schemas, scopes, or examples.\n- Use e2e websocket drop and bounded subscriber fault cases for stream regressions.\n\n## Architecture\n\n- `packages/gateway/src/rpc/index.js` defines the stable v1 method union, `request/response` schemas, scopes, and errors; index.d.ts publishes the corresponding types.\n- `packages/server/src/index.js` implements node:http routes, request bounds, webhook verification, metrics, and workflow loading.\n- `packages/gateway-client` and `packages/gateway-react` consume the same RPC contract for non-React and React clients.\n\n## Fixes and diffs\n\n- 2026-07-06 refresh: read README.md, package exports, selected package entry points, `docs/how-it-works.mdx`, `docs/cli/overview.mdx`, `docs/agents/overview.mdx`, `docs/integrations/custom-ui.mdx`, `docs/integrations/mcp-server.mdx`, `docs/deployment/production-hardening.mdx`, `docs/deployment/control-plane.mdx`, and targeted test inventories.\n- 2026-07-18 feature and docs audit: added run-wide diffs, score comparison RPCs, and the shared protocol package.\n- `packages/gateway/src/rpc/index.js`\n- `packages/server/src/index.js`\n- `packages/server/src/gateway.js`\n- `packages/gateway-client/src/SmithersGatewayClient.ts`\n- `packages/gateway-react/src/*.ts`\n- `packages/gateway`\n- `packages/server`\n- `packages/protocol`\n\n## Open gaps\n\n- Serverless `resume/cron` tick and run-lease claims need broader end-to-end proof beyond `unit/RPC` contract tests.\n- Hosted gateway deployments still need explicit production hardening around TLS, token rotation, and multi-tenant boundaries.\n" }, + { + "path": "features/herdr-supervision.md", + "title": "Herdr supervision, steer, and hijack", + "level": "technical", + "content": "# Herdr supervision, steer, and hijack\n\n> **Status:** Partial | **Priority:** P1 | **Owner:** smithers-maintainers | **Group:** Recover & replay\n\nMirror a run into a herdr terminal workspace and supervise it beside your coding agent: a pane per agent node, a cockpit outline, one-key steer, live hijack, first-class reasoning effort, and `approve/deny` auto-resume. Fully degradable.\n\n## What you can do\n\nWatch and steer long-running agent workflows from the same terminal as the agent driving them, without leaving the CLI.\n\n## Capabilities\n\n### Herdr workspace mirroring\n\nMirror any run into a Herdr workspace with `smithers up` --herdr or smithers herdr attach.\n\n### Portable workflow supervisor\n\nInspect live workflow state with smithers supervisor and its top alias.\n\n### Durable steer queue\n\nQueue an instruction against a running node with smithers steer.\n\n### Live session handoff\n\nHand off a live agent session with `smithers hijack` and resume the workflow afterward.\n\n### In-pane approvals\n\nAnswer approval gates with `smithers approve` --watch and auto-resume parked detached runs.\n\n## Endpoints and commands\n\n- `CLI smithers herdr` ([docs](docs/integrations/herdr.mdx))\n\n## Related docs\n\n- [herdr integration](docs/integrations/herdr.mdx)\n- [workflow supervisor](docs/guide/workflow-supervisor.mdx)\n- [watch and steer](docs/guide/watch-and-steer.mdx)\n\n## Test cases\n\n- `packages/herdr/tests/createHerdrRunSurface.test.js`\n- `packages/herdr/tests/cockpitPolicy.test.js`\n- `apps/cli/tests/herdr-cli.e2e.test.js`\n- `apps/cli/tests/herdr-full-loop.e2e.test.js`\n- `apps/cli/tests/smithers-top.test.js`\n- `apps/cli/tests/steer-command.e2e.test.js`\n- `apps/cli/tests/tail-steer-keys.test.js`\n- `apps/cli/tests/approve-watch.e2e.test.js`\n\n## Observability\n\n- SteerQueued and SteerExpired carry runId, nodeId, and steerId; SteerConsumed additionally carries attempt and iteration attribution.\n- RunHijackRequested / RunHijacked mark the park-and-hand-off transition; attempt effort is queryable on \\_smithers\\_attempts.effort.\n\n## Debugging\n\n- smithers herdr status reports server version, protocol, and client compatibility; a missing server emits one stderr warning and the run continues unchanged.\n- `smithers inspect` lists `queued/consumed/expired` steers; `smithers why` shows steers alongside blockers.\n\n## Architecture\n\n- `packages/herdr` owns the herdr client and HerdrRunSurface; it renders and relays only — Smithers keeps execution, isolation, and durability.\n- `apps/cli/src/herdr.js` mirrors runs into `workspaces/panes`; smithers-top.js is the portable workflow supervisor (gateway-sourced by default, --direct for local store).\n- `packages/engine/src/steers.js` queues and expires steers; the engine consumes them at the next generate() boundary.\n\n## Fixes and diffs\n\n- 2026-07-26 initial record: herdr supervision, steer, hijack, first-class effort, and `approve/deny` auto-resume.\n- `packages/herdr/src/HerdrRunSurface.ts`\n- `apps/cli/src/herdr.js`\n- `apps/cli/src/smithers-top.js`\n- `apps/cli/src/steer.js`\n- `packages/engine/src/steers.js`\n\n## Open gaps\n\n- Mid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary.\n" + }, { "path": "features/init-workflow-pack.md", "title": "Init workflow pack and starters", @@ -154,7 +160,7 @@ export const docsContent: { path: string; title: string; level: "product" | "tec "path": "features/workflow-testing.md", "title": "Workflow testing and durability scenarios", "level": "technical", - "content": "# Workflow testing and durability scenarios\n\n> **Status:** Fixed | **Priority:** P1 | **Owner:** smithers-maintainers | **Group:** Improve quality\n\nTest workflows with scripted agents, in-memory simulation, prompt and frame rendering, single-task execution, Bun matchers, and a tiered durability scenario harness with real database and process adapters.\n\n## What you can do\n\nCatch graph, prompt, output, retry, crash, and replay regressions before running expensive live agents or deploying a workflow.\n\n## Capabilities\n\n### Simulation and fake agents\n\nsimulate and fakeAgent execute real workflow rendering with explicit scripted outputs and no accidental provider calls.\n\n### Render and task helpers\n\nrenderWorkflow, renderPrompt, and runTask expose frames, descriptors, prompts, and isolated task execution.\n\n### Durability scenarios\n\nscenario, step, fault, barriers, cut points, and mediated effects model timing, crashes, ambiguity, and replay.\n\n### Real capability tiers\n\nIntegration and e2e harnesses require executable database and process adapters and report unsupported capability instead of silently mocking it.\n\n## Endpoints and commands\n\n- `API simulate/fakeAgent` ([docs](docs/guides/testing-workflows.mdx))\n- `API renderWorkflow/renderPrompt/runTask` ([docs](docs/guides/testing-workflows.mdx))\n- `API runScenario` ([docs](docs/guides/testing-workflows.mdx))\n\n## Related docs\n\n- [Testing workflows](docs/guides/testing-workflows.mdx)\n\n## Test cases\n\n- `packages/testing/tests/simulate.test.ts`\n- `packages/testing/tests/fakeAgent.test.ts`\n- `packages/testing/tests/runtimeConformance.test.ts`\n- `packages/testing/tests/replay-identity-fresh-process.test.ts`\n- `e2e/testing-framework/real-db-integration.test.ts`\n- `e2e/testing-framework/real-process-kill-resume.test.ts`\n- `e2e/testing-framework/cutpoint-conformance.test.ts`\n\n## Observability\n\n- Scenario results include structured traces, control logs, capability reports, ambiguity records, replay identity, and determinism reports.\n- Simulation records executed task ids, prompts, validated output rows, unused mocks, and final status for assertions.\n\n## Debugging\n\n- Start with renderWorkflow or simulate for prompt and graph failures, then move durability behavior to the scenario harness tier that owns the required capability.\n- Use the replay bundle and first-divergence report to diagnose nondeterministic scenarios across fresh processes.\n\n## Architecture\n\n- `packages/testing` exports consumer-facing simulation, fake-agent, render, single-task, matcher, scenario, replay, and conformance APIs.\n- Unit, integration, and e2e harnesses use virtual time, real database adapters, or real process adapters without representing mocks as production backends.\n\n## Fixes and diffs\n\n- 2026-07-18 feature and docs audit: added the complete workflow testing and durability scenario surface to the feature ledger and LLM bundle; the full package suite passed 144 tests.\n- `packages/testing`\n- `e2e/testing-framework`\n- `docs/guides/testing-workflows.mdx`\n" + "content": "# Workflow testing and durability scenarios\n\n> **Status:** Fixed | **Priority:** P1 | **Owner:** smithers-maintainers | **Group:** Improve quality\n\nTest workflows with scripted agents, in-memory simulation, prompt and frame rendering, single-task execution, Bun matchers, and a tiered durability scenario harness with real database and process adapters.\n\n## What you can do\n\nCatch graph, prompt, output, retry, crash, and replay regressions before running expensive live agents or deploying a workflow.\n\n## Capabilities\n\n### Simulation and fake agents\n\nsimulate and fakeAgent execute real workflow rendering with explicit scripted outputs and no accidental provider calls.\n\n### Render and task helpers\n\nrenderWorkflow, renderPrompt, and runTask expose frames, descriptors, prompts, and isolated task execution.\n\n### Durability scenarios\n\nscenario, step, fault, barriers, cut points, and mediated effects model timing, crashes, ambiguity, and replay.\n\n### Real capability tiers\n\nIntegration and e2e harnesses require executable database and process adapters and report unsupported capability instead of silently mocking it.\n\n## Endpoints and commands\n\n- `API simulate/fakeAgent` ([docs](docs/guides/testing-workflows.mdx))\n- `API renderWorkflow/renderPrompt/runTask` ([docs](docs/guides/testing-workflows.mdx))\n- `API runScenario` ([docs](docs/guides/testing-workflows.mdx))\n\n## Related docs\n\n- [Testing workflows](docs/guides/testing-workflows.mdx)\n\n## Test cases\n\n- `packages/testing/tests/simulate.test.ts`\n- `packages/testing/tests/unit/fakeAgent.test.ts`\n- `packages/testing/tests/runtimeConformance.test.ts`\n- `packages/testing/tests/replay-identity-fresh-process.test.ts`\n- `e2e/testing-framework/real-db-integration.test.ts`\n- `e2e/testing-framework/real-process-kill-resume.test.ts`\n- `e2e/testing-framework/cutpoint-conformance.test.ts`\n\n## Observability\n\n- Scenario results include structured traces, control logs, capability reports, ambiguity records, replay identity, and determinism reports.\n- Simulation records executed task ids, prompts, validated output rows, unused mocks, and final status for assertions.\n\n## Debugging\n\n- Start with renderWorkflow or simulate for prompt and graph failures, then move durability behavior to the scenario harness tier that owns the required capability.\n- Use the replay bundle and first-divergence report to diagnose nondeterministic scenarios across fresh processes.\n\n## Architecture\n\n- `packages/testing` exports consumer-facing simulation, fake-agent, render, single-task, matcher, scenario, replay, and conformance APIs.\n- Unit, integration, and e2e harnesses use virtual time, real database adapters, or real process adapters without representing mocks as production backends.\n\n## Fixes and diffs\n\n- 2026-07-18 feature and docs audit: added the complete workflow testing and durability scenario surface to the feature ledger and LLM bundle; the full package suite passed 144 tests.\n- `packages/testing`\n- `e2e/testing-framework`\n- `docs/guides/testing-workflows.mdx`\n" }, { "path": "features/workflow-uis.md", diff --git a/.smithers/ui/ddd-features.generated.ts b/.smithers/ui/ddd-features.generated.ts index 347e57dbd8..357dfb0df5 100644 --- a/.smithers/ui/ddd-features.generated.ts +++ b/.smithers/ui/ddd-features.generated.ts @@ -2772,7 +2772,7 @@ export const featuresData = [ ], "tests": [ "packages/testing/tests/simulate.test.ts", - "packages/testing/tests/fakeAgent.test.ts", + "packages/testing/tests/unit/fakeAgent.test.ts", "packages/testing/tests/runtimeConformance.test.ts", "packages/testing/tests/replay-identity-fresh-process.test.ts", "e2e/testing-framework/real-db-integration.test.ts", @@ -2977,5 +2977,95 @@ export const featuresData = [ "missing": [ "Alert policy is stored and an AlertRuntime wrapper exists, but core does not yet evaluate rules, poll approval age, deliver notifications, or execute pause, cancel, and approval reactions automatically." ] + }, + { + "id": "herdr-supervision", + "title": "Herdr supervision, steer, and hijack", + "summary": "Mirror a run into a herdr terminal workspace and supervise it beside your coding agent: a pane per agent node, a cockpit outline, one-key steer, live hijack, first-class reasoning effort, and approve/deny auto-resume. Fully degradable.", + "status": "partial", + "priority": "p1", + "owner": "smithers-maintainers", + "tier": "feature", + "group": "Recover & replay", + "userValue": "Watch and steer long-running agent workflows from the same terminal as the agent driving them, without leaving the CLI.", + "capabilities": [ + { + "title": "Herdr workspace mirroring", + "detail": "Mirror any run into a Herdr workspace with smithers up --herdr or smithers herdr attach." + }, + { + "title": "Portable workflow supervisor", + "detail": "Inspect live workflow state with smithers supervisor and its top alias." + }, + { + "title": "Durable steer queue", + "detail": "Queue an instruction against a running node with smithers steer." + }, + { + "title": "Live session handoff", + "detail": "Hand off a live agent session with smithers hijack and resume the workflow afterward." + }, + { + "title": "In-pane approvals", + "detail": "Answer approval gates with smithers approve --watch and auto-resume parked detached runs." + } + ], + "endpoints": [ + { + "method": "CLI", + "path": "smithers herdr", + "doc": "docs/integrations/herdr.mdx" + } + ], + "links": [ + { + "label": "herdr integration", + "href": "docs/integrations/herdr.mdx" + }, + { + "label": "workflow supervisor", + "href": "docs/guide/workflow-supervisor.mdx" + }, + { + "label": "watch and steer", + "href": "docs/guide/watch-and-steer.mdx" + } + ], + "tests": [ + "packages/herdr/tests/createHerdrRunSurface.test.js", + "packages/herdr/tests/cockpitPolicy.test.js", + "apps/cli/tests/herdr-cli.e2e.test.js", + "apps/cli/tests/herdr-full-loop.e2e.test.js", + "apps/cli/tests/smithers-top.test.js", + "apps/cli/tests/steer-command.e2e.test.js", + "apps/cli/tests/tail-steer-keys.test.js", + "apps/cli/tests/approve-watch.e2e.test.js" + ], + "observability": [ + "SteerQueued and SteerExpired carry runId, nodeId, and steerId; SteerConsumed additionally carries attempt and iteration attribution.", + "RunHijackRequested / RunHijacked mark the park-and-hand-off transition; attempt effort is queryable on _smithers_attempts.effort." + ], + "debug": [ + "smithers herdr status reports server version, protocol, and client compatibility; a missing server emits one stderr warning and the run continues unchanged.", + "smithers inspect lists queued/consumed/expired steers; smithers why shows steers alongside blockers." + ], + "architecture": [ + "packages/herdr owns the herdr client and HerdrRunSurface; it renders and relays only — Smithers keeps execution, isolation, and durability.", + "apps/cli/src/herdr.js mirrors runs into workspaces/panes; smithers-top.js is the portable workflow supervisor (gateway-sourced by default, --direct for local store).", + "packages/engine/src/steers.js queues and expires steers; the engine consumes them at the next generate() boundary." + ], + "changes": [ + "2026-07-26 initial record: herdr supervision, steer, hijack, first-class effort, and approve/deny auto-resume." + ], + "diffHints": [ + "packages/herdr/src/HerdrRunSurface.ts", + "apps/cli/src/herdr.js", + "apps/cli/src/smithers-top.js", + "apps/cli/src/steer.js", + "packages/engine/src/steers.js" + ], + "missing": [ + "Mid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary." + ] } ]; diff --git a/.smithers/ui/ddd-ticketsBacklog.generated.ts b/.smithers/ui/ddd-ticketsBacklog.generated.ts index 7fc22c1bb7..a7b5b076d6 100644 --- a/.smithers/ui/ddd-ticketsBacklog.generated.ts +++ b/.smithers/ui/ddd-ticketsBacklog.generated.ts @@ -310,5 +310,15 @@ export const ticketsBacklog: { path: string; kind: string; status: string; prior "featureId": "schedules-alerts", "featureTitle": "Schedules and durable alerts", "content": "# Alert policy is stored and an AlertRuntime wrapper exists, but core does not yet evaluate rules, poll approval age, deliver notifications, or execute pause, cancel, and approval reactions automatically.\n\nFeature: Schedules and durable alerts (schedules-alerts)\nStatus: todo · Kind: issue · Priority: P1 · Feature status: partial\n\n## Gap\n\nAlert policy is stored and an AlertRuntime wrapper exists, but core does not yet evaluate rules, poll approval age, deliver notifications, or execute pause, cancel, and approval reactions automatically.\n" + }, + { + "path": "tickets/herdr-supervision--01-mid-turn-steer-injection-landing-an-instruction-between-an-a-fac60fa6.md", + "kind": "issue", + "status": "todo", + "priority": "p1", + "updatedAtMs": 0, + "featureId": "herdr-supervision", + "featureTitle": "Herdr supervision, steer, and hijack", + "content": "# Mid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary.\n\nFeature: Herdr supervision, steer, and hijack (herdr-supervision)\nStatus: todo · Kind: issue · Priority: P1 · Feature status: partial\n\n## Gap\n\nMid-turn steer injection (landing an instruction between an agent's tool calls) is deferred; steers apply at the next generate() boundary.\n" } ]; diff --git a/apps/cli/docs/SKILL.md b/apps/cli/docs/SKILL.md index 60c9e89f6b..0bbe243187 100644 --- a/apps/cli/docs/SKILL.md +++ b/apps/cli/docs/SKILL.md @@ -228,13 +228,19 @@ before every workflow you build, and the rest of this skill assumes them. user. See [Authoring new workflows](#authoring-new-workflows). 3. **Proactively offer to visualize, every time.** Whenever a workflow or run is in play, suggest ways to *see* it instead of leaving the user with prose: - - **Open the Smithers Monitor proactively.** Whenever you start or attach to - a run, explicitly run `smithers monitor ` so the live web UI - (status, execution tree, per-node live output, events, approvals) opens in - the user's browser without being asked. `smithers up` and - `smithers workflow run` do not open a browser themselves. Browser opening - belongs to `smithers monitor`, `smithers gui`, and `smithers ui`; use - `smithers ui ` when you want the workflow's custom UI instead. + - **Match the view to where the user already is.** If you are in a herdr + terminal workspace (`HERDR_ENV=1`) or the user has a `smithers supervisor` + open, that terminal cockpit **is** their live view: do **not** open the + browser Monitor. Mirror the run into it with `smithers up … --herdr` and + let the supervisor pick it up (it polls the workspace store and shows every + run automatically); the user drives steer/hijack from the node panes or the + supervisor's `Enter`. Only when there is **no** terminal cockpit, open the + Smithers Monitor proactively: run `smithers monitor ` so the live + web UI (status, execution tree, per-node live output, events, approvals) + opens in the user's browser without being asked (pass `--no-open` to just + print the URL). `smithers up` / `smithers workflow run` never open a browser + themselves; browser opening belongs to `smithers monitor`, `smithers gui`, + and `smithers ui`. Use `smithers ui ` for a workflow's custom UI. - `smithers graph .tsx` renders the workflow graph without executing (also your pre-run sanity check; it must exit 0). - `smithers tree ` prints the run's live node tree, and @@ -576,7 +582,7 @@ The same substrate carries the concerns you'd otherwise bolt on later: - **Observability / serving**: `smithers observability --detach` (Grafana/Prometheus/Tempo/OTLP); `smithers observability --down` stops it; `smithers up … --serve --metrics` exposes an HTTP API, SSE event stream, and `/metrics`. A workflow can even serve its own React front-end. - **Agents**: pluggable runtimes (claude, codex, antigravity, kimi, amp, forge, Effect-native) configured in `agents.ts`; `agent={[primary, fallback]}` falls back on failure. - **Tools**: built-in `read`/`write`/`edit`/`bash`/`grep`/`ls` with path containment (`--root`); `smithers openapi ` generates typed AI SDK tools from an OpenAPI spec. -- **Integrations**: run Smithers itself as an MCP server (`smithers mcp add`), sync skills into agent dirs (`smithers skills add`), durable schedules (`smithers cron`), pager-style `smithers alerts`, a structured `` queue (`smithers human`), and `smithers hijack` to hand off a live agent session. +- **Integrations**: run Smithers itself as an MCP server (`smithers mcp add`), sync skills into agent dirs (`smithers skills add`), durable schedules (`smithers cron`), pager-style `smithers alerts`, a structured `` queue (`smithers human`), and `smithers hijack` to hand off a live agent session. Optionally mirror a run into a [herdr](https://herdr.dev) terminal workspace with `smithers up … --herdr` (a pane per agent node running `smithers tail`, plus `smithers herdr attach ` / `smithers herdr status`); it is fully degradable and never affects the run. - **Lower-level API**: `Smithers.workflow().step(...)` exposes the raw Effect-ts surface (Schedules, Layers, fibers); mix it with JSX in one workflow. ## The `.smithers/` folder @@ -675,6 +681,9 @@ smithers up workflow.tsx --run-id --resume true # resume after a c smithers ps # list runs smithers inspect # full run state smithers logs -f # follow events +smithers tail --node # tail one node's agent output verbatim +smithers up workflow.tsx --herdr # also mirror the run into a herdr workspace +smithers herdr attach # mirror an already-running run into herdr smithers approve --node review --by alice # clear an approval gate smithers deny --node review --by alice # reject an approval gate smithers signal --data '{}' # deliver a Signal/WaitForEvent payload diff --git a/apps/cli/docs/llms-full.txt b/apps/cli/docs/llms-full.txt index 230d350596..8bf6aaef1e 100644 --- a/apps/cli/docs/llms-full.txt +++ b/apps/cli/docs/llms-full.txt @@ -2739,13 +2739,13 @@ See Time-travel API and Revert for the programmatic surface. Commands listed by dotted name. `human` and `alerts` use an action positional instead of nested subcommands. ```toon -commands[102]: +commands[110]: - name: add purpose: Install a workflow pack from GitHub, npm, or a local file args[1]{name,type,required,desc}: spec,string,true,GitHub npm or file pack spec flags[2]{name,short,type,default,desc}: - global,,boolean,false,Install in ~/.smithers/packs instead of the local project + global,g,boolean,false,Install in ~/.smithers/packs instead of the local project yes,,boolean,false,Skip trust confirmation - name: init purpose: Install the local Smithers workflow pack into .smithers/. In an interactive terminal init asks one question (your preferred coding agent), installs the pack with defaults plus that agent's plugin (or skill if no plugin), then opens a hijacked tutorial session hosted by that agent; piped/agent/CI runs (or --yes) install defaults. Pass an optional prompt to also launch the create-workflow builder after init. @@ -2863,6 +2863,7 @@ commands[102]: started-by-harness,,string,,Durable self-reported launch harness; environment may fill when omitted started-by-session,,string,,Durable self-reported harness session; environment may fill when omitted started-by-prompt,,string,,Explicit durable launch context; never inferred from workflow input + herdr,,boolean|string,,Mirror this run into a herdr terminal workspace (one pane per agent node); optionally =SESSION for a named session - name: migrate purpose: Copy the legacy bun:sqlite smithers.db into PGlite or Postgres and write the migrated.json marker flags[3]{name,short,type,default,desc}: @@ -2921,6 +2922,16 @@ commands[102]: interval,i,string,10s,Poll interval stale-threshold,t,string,30s,Heartbeat staleness threshold before resume max-concurrent,c,number,3,Max runs resumed per poll + - name: supervisor + purpose: Workflow supervisor live outline of phases and agents; sources through the workspace gateway by default (start-or-attach with a silent fallback to direct smithers.db reads) and doubles as the herdr cockpit right pane. j/k select, Enter opens a detail tab in herdr, [ ] switch runs, f follow live, q quit + flags[5]{name,short,type,default,desc}: + db,,string,,Path to smithers.db (default: discover from cwd) + cwd,,string,,Project directory for DB discovery (default: process.cwd()) + interval,i,number,0.5,Poll interval in seconds + gateway,g,string,,Source through a workspace gateway over RPC: a base URL to attach (hard-fails if unreachable) or auto to start-or-attach; default with no flag is auto with a silent fallback to direct-db + direct,,boolean,false,Force direct smithers.db reads and bypass the gateway (escape hatch for a broken gateway) + - name: top + purpose: Alias for smithers supervisor (workflow supervisor) - name: gateway purpose: Serve the multi-run Gateway RPC/WS control plane for workspace run state (one singleton per workspace; a second start refuses); unlike up --serve, this is not tied to one run args[1]{name,type,required,desc}: @@ -2986,7 +2997,7 @@ commands[102]: args[1]{name,type,required,desc}: runId,string,true,Run ID flags[5]{name,short,type,default,desc}: - follow,f,boolean,true,Poll for new events while run is active + follow,f,boolean,,Follow a live run to completion; default follows only when stdout is a TTY (pipes/redirects snapshot and exit); -f forces follow and --no-follow forces snapshot from-seq,,number,,Start from event sequence number (exclusive) since,,number,,Deprecated alias of --from-seq (an event sequence number, not a duration) tail,n,number,50,Last N events @@ -3005,6 +3016,16 @@ commands[102]: watch,w,boolean,false,Append new events as they arrive from the live cursor interval,i,number,2,Watch poll seconds history,,boolean,false,Replay existing history before tailing in watch mode + - name: tail + purpose: "Tail a run's output: verbatim node output with --node, or a concise run-level event overview" + args[1]{name,type,required,desc}: + runId,string,true,Run ID + flags[5]{name,short,type,default,desc}: + node,n,string,,Filter to a single node and print its output verbatim + follow,f,boolean,true,Poll for new events until the run reaches a terminal state + format,,enum,pretty,pretty|jsonl + linger,,boolean,false,After the run is terminal stay open until q/Enter or Ctrl-C (herdr panes pass this) + overview,,boolean,false,Run-level supervision board (per-node state + attempt, approve/steer CTAs, queue summary) above the event scroll - name: chat purpose: Show agent chat output for the latest run or a specific run args[1]{name,type,required,desc}: @@ -3050,6 +3071,17 @@ commands[102]: target,,string,,"Expected engine such as claude-code or codex" timeout-ms,,number,30000,Wait time for live handoff launch,,boolean,true,Open session immediately + - name: steer + purpose: "Steer a running workflow in one step: with a message, queue a durable steer for the target node's next agent step (the run never stops); with --takeover, hand off the live agent session (a run-wide hijack that warns before aborting in-flight siblings); bare steer auto-picks the single active run" + args[2]{name,type,required,desc}: + runId,string,false,Run ID; auto-picks the single active run (or prompts) if omitted + message,string,false,Steer message to queue for the target node's next agent step (prompts if omitted and not --takeover) + flags[5]{name,short,type,default,desc}: + node,n,string,,Node id to steer (default: the run's current in-flight agent node) + takeover,t,boolean,false,Hijack the live agent session instead of queuing a steer (run-wide; warns before aborting in-flight siblings) + yes,y,boolean,false,Skip the takeover confirmation prompt (answer yes) + timeout-ms,,number,30000,Wait time for live handoff (takeover) + session,,string,,herdr session hosting the mirror (auto-detected from the pane env by default) - name: inspect purpose: Output detailed state of a run: steps, agents, approvals, timers, loops, outputs args[1]{name,type,required,desc}: @@ -3119,11 +3151,12 @@ commands[102]: purpose: Approve a paused approval gate; auto-detects the node if only one is pending args[1]{name,type,required,desc}: runId,string,true,Run ID - flags[4]{name,short,type,default,desc}: + flags[5]{name,short,type,default,desc}: node,n,string,,Node ID required if multiple approvals are pending iteration,,number,0,Loop iteration note,,string,,Approval note by,,string,,Approver identifier + watch,,boolean,false,Interactively answer each approval gate and human request by keystroke as it appears then linger until the run ends (herdr gate panes) - name: deny purpose: Deny a paused approval gate args[1]{name,type,required,desc}: @@ -3428,7 +3461,7 @@ commands[102]: purpose: Run a discovered workflow by ID args[1]{name,type,required,desc}: name,string,false,Workflow ID (omit with --interactive to pick one) - flags[41]{name,short,type,default,desc}: + flags[42]{name,short,type,default,desc}: detach,d,boolean,false,Background mode; preflight the graph, then print runId/pid/logFile once the run is admitted run-id,r,string,,Explicit run ID max-concurrency,c,number,4,Maximum parallel tasks @@ -3470,6 +3503,7 @@ commands[102]: started-by-harness,,string,,Durable self-reported launch harness; environment may fill when omitted started-by-session,,string,,Durable self-reported harness session; environment may fill when omitted started-by-prompt,,string,,Explicit durable launch context; never inferred from workflow input + herdr,,boolean|string,,Mirror this run into a herdr terminal workspace (one pane per agent node); optionally =SESSION for a named session - name: workflow.path purpose: Resolve a workflow ID to its entry file path args[1]{name,type,required,desc}: @@ -3624,6 +3658,27 @@ commands[102]: no-global,,boolean,false,Install to project instead of globally - name: skills.list purpose: List available skills + - name: herdr.status + purpose: Ping the herdr server and report its version, protocol, and client compatibility + flags[1]{name,short,type,default,desc}: + session,,string,,Target a named herdr session's socket + - name: herdr.attach + purpose: Mirror an existing run into a herdr workspace and follow its status until the run ends or Ctrl-C + args[1]{name,type,required,desc}: + runId,string,true,Run ID to mirror into herdr + flags[1]{name,short,type,default,desc}: + session,,string,,Target a named herdr session's socket + - name: herdr.open + purpose: "Open (or re-open) an on-demand herdr pane for a run: a node's lingering output tail, or the run-level overview when no node is given; adopts an existing pane instead of duplicating it" + args[2]{name,type,required,desc}: + runId,string,true,Run ID whose workspace to open a pane in + nodeId,string,false,Node ID to open a tail pane for (omit for the run-level overview pane) + flags[1]{name,short,type,default,desc}: + session,,string,,Target a named herdr session's socket + - name: herdr.clean + purpose: Close herdr workspaces that mirror a smithers run whose run is terminal in the DB; never touches workspaces that do not map to a known run, and leaves active runs open + flags[1]{name,short,type,default,desc}: + session,,string,,Target a named herdr session's socket ``` ## Operational notes @@ -11040,6 +11095,7 @@ debugging notes, and open gaps. | `durable-engine` | Durable engine, scheduler, and driver | `fixed` | `platform` | How it works | | `approvals-human-gates` | Approvals, human tasks, and durable waits | `fixed` | `feature` | Approval | | `gateway-server` | Gateway, RPC, and server | `partial` | `platform` | Gateway | +| `herdr-supervision` | Herdr supervision, steer, and hijack | `partial` | `feature` | herdr | | `workflow-uis` | Custom workflow UIs and monitor surfaces | `fixed` | `feature` | Custom workflow UI | | `crash-recovery-resume` | Crash recovery, supervisor, and resume | `partial` | `feature` | Durability and resume | | `time-travel-replay` | Time travel, rewind, replay, and snapshots | `partial` | `feature` | Time travel quickstart | @@ -11170,6 +11226,7 @@ Most applications should import from `smthrs`. The workspace packages below are | `@smthrs/gateway-react` | React hooks and root helpers for Gateway-backed UIs | Gateway | | `@smthrs/gateway-ui` | Prebuilt React components for Gateway-backed UIs, built on the gateway-react hooks | Gateway UI Components | | `@smthrs/graph` | Framework-neutral workflow graph model, XML nodes, task descriptors, and graph snapshots | Planner Internals, Types | +| `@smthrs/herdr` | Herdr socket client and run surface: mirror a run into a herdr terminal workspace as an optional presentation and steering plane | Herdr | | `@smthrs/init-site` | Private Cloudflare Worker deployment for the interactive Smithers init wizard and workflow builder marketing site | Ecosystem | | `@smthrs/integrations` | External webhook and polling event sources, cursor storage, signature verification, and delivery helpers for workflow integrations | Integrations, Server | | `@smthrs/jj-darwin-arm64` | Vendored jj (Jujutsu) binary for darwin-arm64; auto-installed as an optional dependency of `@smthrs/vcs`, not depended on directly | VCS Guide | @@ -21224,6 +21281,618 @@ visualization work unmodified on the machine definition. --- +## Herdr + +> Mirror a Smithers run into a herdr terminal workspace with an overview tab, a tab per attention-worthy node, authoritative status, in-pane approvals, and one-key steering (press s to steer, h to hijack, or smithers steer), all optional and degradable. + + +[Herdr](https://herdr.dev) is a terminal workspace manager. Smithers can mirror a +run into a herdr workspace so you can watch the whole run at a glance, drop into +any node, answer a parked gate in place, and take an agent over by hand, without +giving up any of Smithers' guarantees. + +Herdr is a **mirrored presentation and steering plane**. Smithers keeps owning +everything that matters: it spawns the agents, isolates them, records the durable +event log, and resumes after a crash. Herdr only renders that run and lets you +steer it. The mirror is fed from Smithers' event stream, so the herdr sidebar +shows **authoritative** status pushed from Smithers rather than screen-scraped +guesses. + +The integration is **optional and degradable**. If no herdr server is running, +`--herdr` warns once on stderr and the run proceeds unchanged. If herdr dies +mid-run, every call soft-fails and the run finishes normally. No herdr code is on +the engine's hot path. + +## Quickstart + +Install and start a herdr server (see the herdr docs for +platform details), then mirror a run: + +```bash +herdr server # start a herdr server (or run the herdr app) +bunx smthrs up workflow.tsx --herdr # run and mirror it into herdr +``` + +`--herdr` targets herdr's default session. Pass `--herdr=SESSION` to target a +named session, or set `HERDR_SESSION` / `HERDR_SOCKET_PATH` (herdr's own env) to +pick the socket. + +## The adaptive layout + +Smithers maps a run onto herdr's own hierarchy (workspace, then tabs, then panes) +so the workspace reads like a run and each tab reads like one thing you might need +to look at. + +- One **workspace** per attached run, labelled + `WORKFLOW_ID [smithers:v1:ENCODED_RUN_ID]`. The versioned suffix is Smithers' + ownership marker. The complete label is the find-or-create key, so a re-attach + or resume reuses the same workspace without adopting an operator-created label + that merely mentions the same run id. +- The workspace's **first tab is the cockpit**. By default it is a **split**: left + = your harness shell (or a spawned harness), right = portable **`smithers supervisor`** + (`top --db ` - long-lived fleet board; see The overview board + and the workflow supervisor guide). Operator-owned workspaces can + pre-split and dock top into the right pane without renaming the workspace. +- Each mirrored node gets its **own tab**, labelled with the node id, holding + **one full-size pane** running that node's live output + (`tail RUN_ID --node NODE_ID --hud --linger`) with a **fixed bottom dual-control + dock** (`s` steer · `h` hijack · `q` close). Because only one tab renders at a + time, every node pane gets the full terminal area instead of a shrinking sliver. + The pane is named `smithers:RUN_ID:NODE_ID`. + +Why tabs rather than splitting one tab into many panes: herdr splits divide a +single shared area, so panes halve with each new node (a fourth node is already a +narrow column, a sixth is an unreadable two-column sliver). Tabs each get the full +area regardless of how many exist, so a tab per node stays legible where splits do +not. + +### The adaptive cap + +Auto-spawning a tab per node breaks down under fan-out: a swarm over dozens of +issues would open dozens of tabs, most idle then gone. So the mirror uses an +adaptive cap, **`tabCap` (default 6 tabs including the overview)**, which leaves +room for five mirrored node tabs. Past the cap an ordinary node stays **unpaned** +and issues no herdr calls at all, so a large fan-out does not flood the tab bar or +the socket. The overview tab always reflects every node (see +the queue summary), and you can pull any unpaned node up on +demand with `herdr open`. + +### Attention promotion (always bypasses the cap) + +The cap only holds back *ordinary* nodes. Three kinds of node are +attention-worthy and **always get a tab, even past the cap**: + +- **Parked approval gates** (`NodeWaitingApproval` / `ApprovalRequested`). A pure + gate node carries no agent attempt row, so the ordinary agent-only pane filter + would drop it; because a parked human gate is exactly what you need to see, it + overrides both the filter and the cap and its pane runs + `approve --watch` so you can decide in place. +- **Failed nodes** (`NodeFailed` on the final attempt). A worker that fails deep + in a swarm is promoted out of the unpaned pool into its own tab so the one thing + that went wrong is visible without hunting for it. +- **Hijack panes** (see Hijack into a pane). + +Loop iterations **reuse their node's tab** rather than opening a new one per +iteration, so a validation loop that retries three times is one tab, not three. +**Focus is never stolen** by any of this: a background gate or failure lights up +the sidebar and fires a notification but never yanks your screen off what you are +looking at. The one deliberate exception is a hijack pane, which does focus, +because you asked to drive that agent right now. + +Everything in the mirror is soft-fail, replay-idempotent, cross-run-filtered, and +seq-monotonic: a re-attach or resume adopts the tabs and panes already there +instead of duplicating them, stale events from other runs are filtered out, and +out-of-order pushes are dropped rather than flapping a pane's status backward. + +## Status mapping + +Smithers pushes each pane's status from the run's event stream. `blocked` always +means "needs a human", so approvals and failures both surface as `blocked` (with a +notification), which is what makes the herdr sidebar a useful worklist. + +| Smithers event | Pane status | Meaning | +|---|---|---| +| `NodeStarted`, `NodeRetrying` | `working` | the agent node is executing (retry shows the attempt) | +| `ApprovalGranted`, `ApprovalAutoApproved` | `working` | a gate cleared; the node resumed | +| `NodeWaitingApproval`, `ApprovalRequested` | `blocked` | paused on a human approval gate; the message carries the question and a notification fires | +| `NodeFailed` | `blocked` | the node failed; the message is `failed: SUMMARY` | +| `NodeFinished` | `idle` | the node completed (custom status `done`) | +| `NodeCancelled` | `idle` | the node was cancelled (custom status `cancelled`) | +| `RunFinished` | working panes go `idle` (`done`) | the run completed; a pane already `blocked` on a tolerated failure keeps its failure | +| `RunFailed`, `RunCancelled` | working or blocked panes go `blocked` | the run ended abnormally | + +The gate question is pushed both as the pane's status message and as its queryable +`custom_status`, so `herdr agent list` shows what needs approving, not just that +the pane is blocked. When such a gate clears, its pane resolves to `idle` with the +custom status `approved` rather than the `working`/`done` an ordinary agent node +reports. + +Because `bunx smthrs up` exits when it parks at a gate (exit code +3, awaiting a decision), you approve and resume in a new process. On resume +Smithers re-adopts the parked gate pane and re-flags it, so the pane moves from +`blocked` to `approved` when the resumed run clears the gate instead of staying +stuck `blocked` in the mirror. + +When an agent reports a resumable session id, Smithers also forwards it to the +pane so herdr can associate the pane with the agent's session. + +### Outcome labels + +When the run reaches a terminal state, Smithers renames its workspace with an +outcome marker while keeping the run id, so the sidebar shows the result at a +glance: + +| Marker | Terminal state | +|---|---| +| `✓ WORKFLOW_ID [smithers:v1:ENCODED_RUN_ID]` | finished | +| `✗ WORKFLOW_ID [smithers:v1:ENCODED_RUN_ID]` | failed | +| `◻ WORKFLOW_ID [smithers:v1:ENCODED_RUN_ID]` | cancelled | + +The rename is derived from the clean label and never stacks markers on replay, and +find-or-create/attach re-adopt a renamed workspace (the marker prefix is tolerated +when matching), so a resumed run reuses its marked workspace rather than opening a +fresh one. Panes still linger after the run ends (below), so a `✓` or `✗` +workspace is a finished run you can still read; close it yourself, or sweep every +terminal run's workspace at once with `herdr clean`. + +## The supervision kit + +The mirror is most useful with a few herdr settings turned on, so a parked gate or +a failed worker rises to the top of the sidebar on its own instead of waiting to +be found. Configure these in your herdr config (`herdr --default-config` prints +the full template): + +- **Sort the agent panel by attention.** Set `agent_panel_sort = "priority"` in + `[ui]`. This flips the agent list from grouped-by-workspace to a flat queue + ordered blocked, done, working, idle, unknown. Since Smithers maps every parked + gate and every failure to `blocked`, and herdr rolls `blocked` up over + `working`/`idle`/`unknown` at the workspace level, the run that needs you is + first in the list. +- **Filter to what is blocked with `prefix+g`.** The session navigator opens on + `prefix+g` and gives a searchable tree of every workspace, tab, and pane. Press + `/` to fuzzy-find by text, or `b` / `w` / `i` / `d` to filter to blocked / + working / idle / done panes. `prefix+g` then `b` is a one-keystroke "show me only + what is waiting on a human" across every mirrored run. +- **Turn on notifications.** Smithers fires a herdr notification when a gate parks, + a node fails, a hijack is ready, and when a run ends. herdr only surfaces these + if toasts are enabled: set `[ui.toast] delivery = "herdr"` for in-UI toasts (or + `system` for desktop notifications). Then `prefix+o` + (`open_notification_target`) jumps focus straight to the pane that raised the + most recent toast, so an alert is one keystroke from the thing that raised it. + +With those on, the day-to-day loop is: leave herdr on the priority agent panel, +let runs mirror themselves, and act only when a row goes blocked, either by +clicking it, pressing `prefix+g b`, or following a toast with `prefix+o`. + +## Answering a gate in-pane + +A parked approval gate is promoted to its own tab whose pane runs an interactive +watcher: + +```bash +bunx smthrs approve RUN_ID --watch +``` + +`approve --watch` polls the run for pending approval gates **and** human requests +(`ask` / `confirm` / `select` / `json`) and answers each one by keystroke as it +appears (`y` / `n` for a gate, a picker or prompt for a human request), committing +through the exact same engine machinery as the single-shot `approve` / `deny` / +`human answer` commands. After a decision it lingers and waits for the next gate, +so one pane walks a multi-gate run end to end. It is a standalone command; you can +run it in any terminal, and the herdr gate tab is simply where the mirror runs it +for you. + +The single-shot forms are unchanged and still the right tool from a script or a +one-off terminal: + +```bash +bunx smthrs approve RUN_ID # auto-detects the pending node +bunx smthrs approve RUN_ID --node NODE_ID # when several gates are pending +bunx smthrs deny RUN_ID --node NODE_ID +``` + +## The overview board + +The cockpit **right** pane hosts **`smithers supervisor`** - a portable, long-lived +workflow board (not a scrolling log). Herdr only *places* that process; the board +itself is a standalone CLI surface. Full guide: workflow supervisor. + +```bash +# Anywhere (including outside herdr) +smithers supervisor --db /path/to/smithers.db + +# Single-run attach still available +smithers tail RUN_ID --overview --hud --linger +``` + +What you see at a glance: + +- **Fleet** - multi-run strip when the store has more than one run (`j`/`k` to focus). +- **Header** - run id, status, elapsed, tallies (working / blocked / failed / done). +- **Attention** - fails, gates, queued steers (empty when calm). +- **Board** - per-node status (attention-first); large fan-outs trim to attention + queue summary. +- **Digest** - deterministic progress (~30s), no LLM. +- **CTAs** - exact `approve` / steer commands when something needs a human; dual-control + keys live on the **node tab dock** (`s` steer · `h` hijack), not on the workflow supervisor outline. + +### The browser stays out of the way + +Inside a herdr pane (`HERDR_ENV=1`), the commands that would normally launch a +browser - `monitor`, `ui`, `gui`, and the post-run HTML summary - **print their +URL instead**. The cockpit is already your live view, and a per-command +`--no-open` is useless when an *agent* runs the command on your behalf. + +```bash +SMITHERS_NO_BROWSER=1 # suppress anywhere (plain tmux/iTerm split, SSH, CI) +SMITHERS_NO_BROWSER=0 # force the browser back on, even inside herdr +``` + +Nothing is lost: the URL is printed and any report file is still written. + +### Packaging: workflow supervisor vs herdr-only + +| Layer | Owns | Installs with | +|---|---|---| +| **workflow supervisor** (`smithers supervisor`) | Board UI, poll loop, keys, `--db` | Smithers CLI | +| **herdr integration** | Workspace/tab/pane chrome, dock, soft-pin, harness left | Optional; needs herdr server | +| **campaign / testing** | Fixtures, watch-pack, bridge that *invokes* top in a pane | Dev/testing | + +If you are writing guidance for herdr, document **placement and keys around the +host**. Do not re-document the whole board - link to workflow supervisor. + + +### Testing without LLMs + +Herdr is one **visibility plane**. Agent-trace fixtures and core scenarios are shared +across HUD, herdr, and other UIs - see +Token-free visibility testing. + +## On-demand panes + +The cap deliberately leaves most swarm workers unpaned. When you do want to look +at a specific one (or re-open a finished node's output), pull it up on demand: + +```bash +bunx smthrs herdr open RUN_ID NODE_ID # a node's lingering output tail +bunx smthrs herdr open RUN_ID # the run-level overview pane +``` + +`herdr open` places one full-size pane into the run's mirror workspace. It is +find-or-create and outcome-tolerant: an outcome-marked (`✓`/`✗`/`◻`) workspace is +re-adopted, and a node that already has a pane is adopted rather than duplicated, +so opening the same node twice is idempotent. This is the escape valve for +"the mirror never paned that worker, but I need to see it now" and works whether +the run is live or already finished. + +### Cleaning up + +Finished runs leave their workspaces up on purpose so you can read them. When you +are done, sweep them: + +```bash +bunx smthrs herdr clean +``` + +`herdr clean` closes every herdr workspace that mirrors a Smithers run **whose run +is terminal in the local store**. Before closing anything, it requires the +versioned ownership marker and an exact match with the workflow and run identity +reconstructed from the DB row. Unknown, mismatched, and active-run workspaces are +left open. It prints each workspace it closed. + +## Supervision profiles + +Workflows differ in how much a human watches them, and the adaptive layout is +built to match that spread. Four shapes cover the pack. + +### Bounded and steerable + +*"I want to watch this whole thing and jump in when it asks."* A short run +(`hello`, `research`, `review`, `plan`) is one to three agent nodes. It fits under +the cap comfortably: the overview tab plus a tab per node, and you can read the +entire run. The interactive members of this shape (`grill-me`, a clarify phase) +want a focused tab you type into, which is exactly a node tab; nothing steals focus +away from it while you answer. + +### Pipeline + +*"I care about the current stage and the gate, not every token."* A sequential +multi-stage run (`implement`, `research-plan-implement`, `create-workflow`, +`release`) is a handful of distinct stages with zero to a few approval gates. +Each stage gets a tab, and loop iterations (a validation loop retrying) reuse that +stage's tab instead of piling up. The approval gate is promoted to its own tab +running `approve --watch`, so your attention lands on the gate and the current +stage, which is where a pipeline's decisions live, and the overview tab carries the +banner for everything else. + +### Swarm and long-horizon + +*"I am an exception handler, not a babysitter."* A fan-out over issues, features, +or tickets (`fix-all-issues`, `merge-train-all-issues`, `mission`, `ralph`) runs +dozens of mostly-mechanical workers, most with no human gate at all. Here the cap +earns its keep: the workers stay unpaned, the overview tab is the dashboard (its +queue summary tallies working/blocked/failed/done across the whole swarm), and only +attention-worthy nodes take a tab, a failing worker promoted out of the pool, an +up-front plan-approval gate, a merge or final-report node. When you want a specific +worker, `herdr open RUN_ID worker-3` pulls it up; when a worker fails, it is +already waiting in its own tab and the sidebar has gone blocked. + +### Co-pilot + +*"The agent is asking me questions; I want to answer fast."* Interactive workflows +put the human upstream of the agents, answering clarify or grill questions rather +than approving output. These want a single focused, low-latency tab. Because the +mirror never steals focus, you drive the conversation from the node's tab (reach it +with `prefix+g` or a click), and a parked human request surfaces the same way a +gate does: promoted, blocked, and answerable in place with `approve --watch`. + +## Attach to a run already in flight + +`bunx smthrs herdr attach` mirrors an existing run into herdr and +follows it live until the run ends or you press Ctrl-C: + +```bash +bunx smthrs herdr attach RUN_ID +bunx smthrs herdr attach RUN_ID --session SESSION +``` + +Attach reconciles against existing herdr state: it finds-or-creates the run's +workspace by the deterministic label (tolerating an outcome marker), adopts the +overview and node tabs already there, and creates tabs only for nodes that are +still active or attention-worthy (long-finished nodes are not replayed). Detaching +(run end or Ctrl-C) never closes the workspace. Attaching to an already-terminal +run prints a final status line and creates nothing. + +## Check the server + +`bunx smthrs herdr status` pings the server and reports its +version, protocol, and whether this client is compatible. It needs no run and no +herdr code path beyond the socket: + +```bash +bunx smthrs herdr status +bunx smthrs herdr status --session SESSION +``` + +It exits non-zero with a clear message when no server is reachable. + +## Standalone tail + +The panes are just `smithers tail`, which is a standalone, herdr-free command. +Use it directly to follow a run, a single node, or the run-level board from any +terminal: + +```bash +bunx smthrs tail RUN_ID # run-level event overview +bunx smthrs tail RUN_ID --overview # run-level supervision board +``` + +It reads the run's persisted and live event stream directly from the local store, +so it works for detached runs and needs no gateway server. It follows a live run +to completion by default and prints a one-line final status, then exits. Pass +`--linger` to keep it open after the run reaches a terminal state until you press +`q`, Enter, or Ctrl-C; this is what mirror panes use so they do not vanish at run +end. See the CLI overview for the full flag list. + +On an interactive **node** tail (a TTY with `--node`, live or lingering), the same +one-key controls apply - press `s` to steer this node's +agent, `h` to hijack it, `q` (or Ctrl-C) to close - shown as a subtle +`s steer · h hijack · q close` hint (a lingering pane after run end drops the +`s`, since a steer can no longer land once every node is terminal). Piped, +`--format jsonl`, and non-TTY tails are unaffected: no raw mode, no keys. + +## Steer with one key + +Steering a live run comes in two gestures, and they differ on one thing: whether +the run keeps running. + +- A **steer** is a short instruction you drop into a running node. The run **never + stops** - the steer is consumed on that node's next agent step and the agent + carries on. +- A **takeover** hands you the agent's live session to drive by hand. The run + **parks** while you drive, and resumes when you hand control back. + +In the mirror each is **one key** on an agent's node tab. No run id, no node id, no +env vars: the pane already knows its own run and node (it is a `smithers tail +--node`) and which herdr session it lives in (herdr exports `HERDR_SOCKET_PATH` +into every pane), so a single keystroke is the whole gesture. + +| Key | Where | What it does | +| --- | --- | --- | +| `s` | any agent node tab (live) | steer - open an inline `steer:` input line; type an instruction and press Enter to queue it as a steer for this node (Esc cancels) | +| `h` | any agent node tab (live or lingering) | hijack - hand off this agent's live session as a focused hijack pane (run-wide; warns first if other agents are in flight) | +| `y` / `n` | a parked gate tab (`approve --watch`) | approve / deny the gate in place | +| `q` | any node tab, and a pane lingering after run end | close the pane (Ctrl-C also works) | + +The live node tab shows a subtle `s steer · h hijack · q close` hint; a pane +lingering after the run ends drops the `s` (a steer can no longer land once every +node is terminal) and shows `h hijack · q close`. The overview tab spells the +same options as text (`in its node tab press s to steer · h to hijack`), so you +can act from wherever your attention landed. + +Prefer to type it? `smithers steer` is the one-word equivalent, with zero env +ceremony: + +```bash +bunx smthrs steer # steer the single active run's current agent +bunx smthrs steer RUN_ID --node NODE_ID "…" # target a specific node's agent +``` + +With no run id it auto-picks the single active run (or prompts you among several); +a bare `smithers steer` with no message prompts for one. For a takeover it +auto-detects the run's herdr mirror from the pane/env session and opens the +hand-off as a hijack pane there; with no mirror reachable it hands the session off +in your current terminal instead. Either way there is nothing to set up - this is +the replacement for the old `cd … && SMITHERS_HERDR=… SMITHERS_HERDR_HIJACK=1 … +hijack --target …` incantation. + +### The steering contract + +**Steer - the run never stops.** A queued steer is consumed on the target node's +**next agent `generate()` call** - its first start, a retry attempt, or the next +loop iteration - and injected as a user message *before* the structured-output +schema wrap, so it never breaks a node that returns JSON. Consumption is recorded +in the attempt's persisted conversation, so a steer is **replay-safe**: resuming +or replaying the run reproduces the identical turn without re-reading the inbox, +and never double-injects. A steer that is never consumed - its node already +finished, or the run ends first - **expires** deterministically when the run +reaches a terminal state (finished / failed; a cancelled or hijacked run keeps its +queued steers so a resume can still consume them), emitting `SteerExpired`; +run-level expiry (rather than per-node) is what keeps a steer aimed at a `` +node's *next* iteration from being thrown away early. The pane you are watching +renders each step as a one-liner: `↪ steer queued: …`, `✓ steer consumed by +attempt N`, `✗ steer expired - node finished first; press h to hijack`. + +**Hijack - run-wide today.** A hijack is the hijack hand-off: +Smithers parks the **whole run** (its status goes to `cancelled`) and hands you +the target node's live session; you resume when you are done. Because the hijack +aborts the shared run signal, any *other* agent node that was mid-generate is +cancelled and **re-runs on resume**. So `steer --takeover` (and the `h` key) gate +on an honest warning: when the run has in-flight agent siblings it prints the +exact count - `⚠ Takeover is run-wide: it aborts N in-flight siblings (…); they +re-run on resume.` - and requires a `y` confirmation (`--yes` to skip; on a +non-TTY without `--yes` it refuses rather than silently aborting the wave). +Answering `n` leaves the run completely untouched. A **node-scoped** takeover that +parks only the one node and leaves its siblings running is on the roadmap; it +needs a per-node interrupt the engine does not have yet, so today taking one worker +over means taking the run over. + +herdr stays a **pure mirror** throughout: it never writes to the engine or the +run's store. Pressing `s` or `h` in a pane just spawns the same `smithers steer` +the CLI would, and the pane only *displays* the `SteerQueued` / `SteerConsumed` / +`SteerExpired` events Smithers emits. + +## Hijack into a pane + +`smithers steer --takeover` (and the `h` key) is a thin, zero-ceremony front end +over `bunx smthrs hijack RUN_ID`, the underlying hand-off. `hijack` +by default launches the interactive agent CLI in your **current terminal**; the +`SMITHERS_HERDR_HIJACK` env var opts it into a **herdr pane** inside the run's +mirror workspace instead. `steer --takeover` just sets that up for you after +auto-detecting the mirror (and after the in-flight-sibling warning above), so you +rarely reach for the raw form - but it stays available for scripts and for driving +the pane-vs-terminal decision explicitly. The hijack pane is cap-exempt and does +take focus, because you asked to drive that agent right now. + +Pane hosting is gated on `SMITHERS_HERDR_HIJACK`, and the session it targets comes +from `SMITHERS_HERDR` (not from herdr's own `HERDR_SESSION`). So to host the pane +in the same named session your run mirrors into, set **both** variables: + +```bash +SMITHERS_HERDR=SESSION SMITHERS_HERDR_HIJACK=1 bunx smthrs hijack RUN_ID +``` + +`SMITHERS_HERDR_HIJACK` accepts `1`, `true`, or an explicit session name. With `1` +or `true` it reuses the session from `SMITHERS_HERDR`; if `SMITHERS_HERDR` is unset +the pane carries no explicit session, so the client falls through to herdr's own +socket resolution (`HERDR_SOCKET_PATH`, then `HERDR_SESSION`, then the default +session). That is why setting **both** variables, above, is the reliable way to +host the pane in the same named session your run mirrors into. The hijack pane is +an agent named `smithers:RUN_ID:hijack:NODE_ID`, marked `blocked` ("hijacked - +attach to drive"). The command prints the exact agent name and attach command on +stderr; attach and hijack with herdr's own CLI: + +```bash +herdr agent attach smithers:RUN_ID:hijack:NODE_ID +``` + +### Hijack afterlife + +A hijack pane does not collapse to a bare shell when your session exits. Smithers +wraps the launch spec so that when the agent CLI exits, the pane prints a handback +summary (the exact resume command) and lingers for a keypress instead of vanishing, +so a hijack you stepped away from is still readable when you come back. The inner +exit code is preserved, and argv/cwd/env are passed through exactly; only the +herdr-pane path is wrapped, the current-terminal hijack flow is byte-identical. + +Handback is **manual**, and the run does not keep running while you drive the agent +by hand: on handoff Smithers parks the run (its status goes to `cancelled`), so the +durable run stops advancing until you return control. Because the pane's process is +owned by herdr and its exit event carries no exit code, Smithers does not +auto-resume (auto-resuming after an aborted or errored session could corrupt the +run). When you are done, hand control back with the resume command the hijack +prints: + +```bash +bunx smthrs up WORKFLOW_FILE --resume --run-id RUN_ID +``` + +If no herdr server is reachable or the pane launch fails, hijack falls back to the +byte-identical current-terminal flow, so opting in never removes your ability to +hijack. + +## Detached runs + +`bunx smthrs up --detach` (`-d`) re-spawns the run in a background +process and returns immediately. `--herdr` is honored there too: the parent hands +the setting to the detached child through the `SMITHERS_HERDR` env var (`1` or the +session name), which the child inherits, so the mirror runs in the process that +actually executes the run. You do not set `SMITHERS_HERDR` yourself; `--herdr` sets +it for the handoff. The detach message leads its watch options with +`herdr attach RUN_ID` so you can re-open the mirror after detaching. + +## Degradability + +Herdr never affects a run. Every interaction is fire-and-forget with a per-call +timeout (5s by default) and a consecutive-timeout circuit breaker that drops +pushes fast once herdr stops answering. Concretely: + +- No herdr server reachable: `--herdr` logs one warning on stderr and the run + proceeds with no mirror. For a detached run, that warning lands in the detach + log file. +- Herdr dies mid-run: calls soft-fail, the breaker opens, and the run reaches its + terminal state normally. +- Host shutdown is bounded: closing the surface abandons a hung herdr after a + short deadline rather than blocking. + +## Version pinning + +This client is built and tested against **herdr 0.7.3 / wire protocol 16**. +`bunx smthrs herdr status` reports the server's protocol and whether it +matches, which is the quickest compatibility check. + +A protocol other than 16 is handled two different ways, on purpose: + +- **Optional mirroring** (`up --herdr`, auto-detected mirrors, the supervisor's + pane integration) degrades softly: one warning, then the run proceeds with no + mirror. Nothing about execution or durability depends on herdr. +- **Explicit `herdr` commands** (`status`, `open`, `attach`, `clean`) fail closed + with `HERDR_PROTOCOL_MISMATCH` and exit 4, reporting both protocol numbers and + confirming that no mutating call was made. Driving panes over a wire format + the server may have redefined is not worth the risk of corrupting a workspace. + +Herdr's protocol advances faster than its user-visible version: releases after +0.7.3 speak higher protocol numbers and are **not** wire-compatible with this +client, so herdr features are inert (safely) until the client is updated to +match. Check `herdr status client` against the number above before filing a bug +about herdr features doing nothing. + +## Remote runs over SSH + +Herdr talks to Smithers over a local unix socket, so herdr runs **where your +agents run**. To watch a run executing on a remote host, run both Smithers and +herdr on that host and reach them over SSH (for example an SSH session into the +host, then `bunx smthrs herdr attach RUN_ID` there). Do not try to +forward or expose the socket itself; SSH to the host and run the client locally. + +## Security + +The herdr control socket is **unauthenticated local control**. Anything that can +reach it can start processes and drive panes. Keep it on a trusted local machine +(the default socket lives under `~/.config/herdr/`). Never expose it over the +network or forward the raw socket. For a remote host, SSH in and run the client +there rather than opening the socket up. + +## Mirror vs interactive moments + +The mirror is the **architecture**: Smithers always owns execution, isolation, +and durability, and herdr is a read-mostly presentation and steering plane fed +from Smithers' event stream. In-pane approvals (`approve --watch`) and hijack panes +are the **interactive escape hatches** for the moments you need to decide or drive +by hand. A per-task interactive mode, where herdr would own a task's process end to +end, is explicitly out of scope for this integration. + +--- + ## Microsandbox Provider > Run a Smithers child workflow in a local Microsandbox microVM with createMicrosandboxSandboxProvider. @@ -22469,6 +23138,9 @@ millis from the stored row) and surface `stateVersion: 0`. | `ApprovalGranted` | `runId`, `nodeId`, `iteration`, `timestampMs` | | `ApprovalAutoApproved` | `runId`, `nodeId`, `iteration`, `timestampMs` | | `ApprovalDenied` | `runId`, `nodeId`, `iteration`, `timestampMs` | +| `SteerQueued` | `runId`, `nodeId`, `steerId`, `message`, `author?`, `timestampMs` | +| `SteerConsumed` | `runId`, `nodeId`, `iteration`, `attempt`, `steerId`, `timestampMs` | +| `SteerExpired` | `runId`, `nodeId`, `steerId`, `timestampMs` | | `ToolCallStarted` | `runId`, `nodeId`, `iteration`, `attempt`, `toolCallId`, `toolName`, `seq`, `timestampMs` | | `ToolCallFinished` | `runId`, `nodeId`, `iteration`, `attempt`, `toolCallId`, `toolName`, `seq`, `status`, `timestampMs` | | `NodeOutput` | `runId`, `nodeId`, `iteration`, `attempt`, `text`, `stream`, `timestampMs` | diff --git a/apps/cli/package.json b/apps/cli/package.json index 63b422bb30..6c0c08a098 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -34,9 +34,9 @@ "@clack/prompts": "^1.6.0", "@mdx-js/esbuild": "^3.1.1", "@modelcontextprotocol/sdk": "^1.29.0", - "@pierre/diffs": "^1.2.11", "@opentui/core": "^0.4.2", "@opentui/react": "^0.4.2", + "@pierre/diffs": "^1.2.11", "@smthrs/accounts": "workspace:*", "@smthrs/agents": "workspace:*", "@smthrs/components": "workspace:*", @@ -46,6 +46,7 @@ "@smthrs/engine": "workspace:*", "@smthrs/errors": "workspace:*", "@smthrs/graph": "workspace:*", + "@smthrs/herdr": "workspace:*", "@smthrs/memory": "workspace:*", "@smthrs/observability": "workspace:*", "@smthrs/openapi": "workspace:*", diff --git a/apps/cli/src/EventCategory.ts b/apps/cli/src/EventCategory.ts index 668273672c..d361f1dee5 100644 --- a/apps/cli/src/EventCategory.ts +++ b/apps/cli/src/EventCategory.ts @@ -4,6 +4,7 @@ export type EventCategory = | "frame" | "memory" | "node" + | "steer" | "openapi" | "output" | "revert" diff --git a/apps/cli/src/SupervisorObservationSource.ts b/apps/cli/src/SupervisorObservationSource.ts new file mode 100644 index 0000000000..00d415c274 --- /dev/null +++ b/apps/cli/src/SupervisorObservationSource.ts @@ -0,0 +1,83 @@ +/** + * The read seam for the `smithers supervisor` main TUI. + * + * A source wraps exactly what smithers-top.js's `refreshData()` reads each poll: + * the run fleet, the focused run's paint input, its outline tree, and the + * selected agent's activity strip. Two implementations satisfy this one + * contract — `createDirectDbObservationSource` (SQLite, the default + permanent + * fallback) and `createGatewayObservationSource` (poll-over-RPC, opt-in) — with + * identical shapes so the TUI is agnostic to where its data comes from. + * + * The gateway source polls the fleet/focus/outline reads over RPC and feeds the + * per-node activity strip from a background `StreamRunEvents` subscription (spec + * item 1). node-detail-entry.js and the herdr tail panes stay direct-db. + */ + +// cockpit-activity.js / cockpit-outline-graph.js expose these as JSDoc @typedef +// exports; the inline import() form is how smithers-top.js already references +// OutlineTreeNode, so it resolves under allowJs without a named type import. +type ActivityLine = import("./cockpit-activity.js").ActivityLine; +type OutlineTreeNode = import("./cockpit-outline-graph.js").OutlineTreeNode; + +/** One run row in the fleet list (SQLite row, or a gateway run row, + derivedStatus). */ +export type FleetRow = Record & { + runId: string; + status?: string; + derivedStatus?: string; + createdAtMs?: number; + startedAtMs?: number; + finishedAtMs?: number | null; + workflowName?: string; + workflowKey?: string; +}; + +/** The paint input for one focused run (the object smithers-top spreads into baseInput). */ +export type FocusPaintInput = { + runId: string; + workflowName: string; + status: string; + nodes: Array>; + agentMetaByNode?: Record>; + startedAtMs: number; + finishedAtMs?: number | null; + nowMs: number; + live?: boolean; + liveElsewhere?: boolean; + queuedSteers?: Array<{ nodeId: string; status?: string }>; +}; + +/** Result of focusing one run: the resolved index, the run row, and its paint input. */ +export type FocusView = { + focusIndex: number; + run: FleetRow | null; + input: FocusPaintInput; +}; + +/** Graph-primary outline for a run (null when no frame / unavailable). */ +export type OutlineTreeResult = { + roots: OutlineTreeNode[]; + frameNo: number; + source: "graph"; +} | null; + +export type SupervisorObservationSource = { + /** Which backend answers the reads (for banners/telemetry). */ + kind: "direct-db" | "gateway"; + /** The run fleet: active runs first, then a capped tail of finished runs. */ + listFleet(): Promise; + /** The focused run's paint input (status, nodes, agent identity, timers). */ + focusView(fleetRuns: FleetRow[], focusIndex: number): Promise; + /** The focused run's hierarchical outline, joined with agent identity meta. */ + outlineTree(runId: string, metaByNode: Record>): Promise; + /** + * The selected agent's activity strip. Direct-db reads durable events; the + * gateway path drains a background `StreamRunEvents` ring (last-known/empty + * on a stream drop). + */ + nodeActivity(runId: string, nodeId: string, opts: { limit?: number; detailMax?: number }): Promise; + /** + * Release background resources (the gateway path's activity WebSocket). + * Optional: the direct-db source has nothing to dispose. + */ + dispose?(): void; +}; diff --git a/apps/cli/src/approve-watch.js b/apps/cli/src/approve-watch.js new file mode 100644 index 0000000000..a96751219a --- /dev/null +++ b/apps/cli/src/approve-watch.js @@ -0,0 +1,675 @@ +import { Effect } from "effect"; +import { computeRunStateFromRow } from "@smthrs/db/runState"; +import { approveNode, denyNode } from "@smthrs/engine/approvals"; +import { isHumanRequestPastTimeout, validateHumanRequestValue } from "@smthrs/engine/human-requests"; +import { deriveTailStatus, formatTailFinalStatusLine, isTailActiveState, lingerUntilClosed } from "./tail.js"; + +/** + * Interactive `smithers approve --watch ` loop. Runs inside a herdr gate + * pane (or any terminal): polls the run's pending approval gates AND pending + * human requests, renders each with its question/options, reads a single-key (or + * short line) answer in raw mode, commits it through the SAME engine machinery the + * non-interactive `approve` / `deny` / `human answer` commands use, then keeps + * watching for the next block. When the run reaches a terminal state it prints the + * final status line and lingers (reusing tail's `lingerUntilClosed`) so the pane + * does not vanish. Every DB interaction is read-or-commit against the real store — + * no mocks, no fabricated state. + */ + +/** Poll cadence for the watch loop while nothing is actionable. Human-in-the-loop, so a touch slower than tail's 500ms. */ +export const APPROVE_WATCH_POLL_INTERVAL_MS = 1000; + +/** The raw ETX byte Ctrl-C sends on a raw-mode TTY stdin (no SIGINT fires there). */ +const CTRL_C_CHAR = String.fromCharCode(3); +/** DEL and BS, either of which a terminal may send for the Backspace key. */ +const DEL_CHAR = String.fromCharCode(127); +const BS_CHAR = "\b"; + +/** + * Returned by a key/line read (and propagated up the prompt helpers) when the + * operator asked to quit: Ctrl-C (raw ETX 0x03, which fires no SIGINT in raw + * mode), a `q`/`Q` keypress at an approval prompt, or a delivered SIGINT/SIGTERM. + * A unique symbol so it can never collide with a real keystroke string. + * @type {unique symbol} + */ +export const CANCEL = Symbol("approve-watch-cancel"); + +/** + * Outcome returned when an approve/deny/answer was attempted but the engine + * commit threw. The gate is still pending, so the watch loop must NOT auto-resume + * the run (nothing was decided) and simply re-polls to re-prompt. + */ +const COMMIT_FAILED = "commit-failed"; + +/** Human-request kinds the watch loop answers interactively. `json` accepts a raw JSON line. */ +const INTERACTIVE_HUMAN_KINDS = new Set(["ask", "confirm", "select", "json"]); + +/** + * @param {string} nodeId + * @param {number | null | undefined} iteration + * @returns {string} + */ +function targetKey(nodeId, iteration) { + // NUL separator so a nodeId containing digits can never collide with another + // (nodeId, iteration) pair. + return `${nodeId}\u0000${iteration ?? 0}`; +} + +/** + * A raw-mode-aware reader over a stdin stream that yields either a single + * keypress ({@link nextKey}) or an accumulated line ({@link nextLine}). Buffers + * input so a chunk carrying several bytes (a paste, or a test writing a whole + * string) is consumed one unit at a time. In a TTY it enables raw mode (so single + * keys arrive without Enter and it can catch the raw Ctrl-C byte) and echoes typed + * characters itself (the terminal does not echo in raw mode); a non-TTY stream (a + * pipe, or a test PassThrough) is read as-is with no echo. A ref'd keepalive timer + * holds the event loop open. SIGINT/SIGTERM and a raw Ctrl-C (0x03) resolve any + * pending read — and every later read — with {@link CANCEL}. + * + * @param {NodeJS.ReadStream | import("node:stream").Readable} stdin + * @param {(text: string) => void} emit + * @returns {{ nextKey: () => Promise, nextLine: () => Promise, drain: () => void, cancelled: () => boolean, waitCancel: () => Promise, close: () => void }} + */ +export function createKeyReader(stdin, emit) { + let rawSet = false; + let closed = false; + let cancelled = false; + /** @type {string} */ + let buffer = ""; + /** @type {null | { resolve: (value: string | typeof CANCEL) => void, mode: "key" | "line", line: string }} */ + let waiter = null; + /** @type {(() => void) | null} */ + let resolveCancel = null; + const cancelPromise = new Promise((resolve) => { + resolveCancel = resolve; + }); + + // A ref'd no-op interval holds the event loop open until a key/line/signal + // arrives, independent of stdin being a TTY, a pipe, or an already-EOF stream. + const keepAlive = setInterval(() => {}, 1 << 30); + + try { + if (/** @type {any} */ (stdin).isTTY && typeof stdin.setRawMode === "function") { + stdin.setRawMode(true); + rawSet = true; + } + } catch { + // stdin is not a TTY / cannot enter raw mode: read it cooked. + } + if (typeof stdin.setEncoding === "function") { + stdin.setEncoding("utf8"); + } + if (typeof stdin.resume === "function") { + stdin.resume(); + } + + function triggerCancel() { + if (cancelled) { + return; + } + cancelled = true; + resolveCancel?.(); + if (waiter) { + const w = waiter; + waiter = null; + w.resolve(CANCEL); + } + } + + /** Feed buffered input into the active waiter, if any. */ + function pump() { + if (!waiter) { + return; + } + if (waiter.mode === "key") { + if (buffer.length === 0) { + return; + } + const ch = buffer[0]; + buffer = buffer.slice(1); + if (ch === CTRL_C_CHAR) { + // Raw Ctrl-C (no SIGINT in raw mode): treat as quit. + triggerCancel(); + return; + } + const w = waiter; + waiter = null; + w.resolve(ch); + return; + } + // line mode: accumulate until CR/LF, echoing in raw mode so the operator + // sees what they type (the terminal does not echo with ISIG/ICANON off). + while (buffer.length > 0) { + const ch = buffer[0]; + buffer = buffer.slice(1); + if (ch === "\r" || ch === "\n") { + const w = waiter; + waiter = null; + if (rawSet) { + emit("\n"); + } + w.resolve(w.line); + return; + } + if (ch === CTRL_C_CHAR) { + triggerCancel(); + return; + } + if (ch === DEL_CHAR || ch === BS_CHAR) { + if (waiter.line.length > 0) { + waiter.line = waiter.line.slice(0, -1); + if (rawSet) { + emit("\b \b"); + } + } + continue; + } + waiter.line += ch; + if (rawSet) { + emit(ch); + } + } + } + + /** @param {Buffer | string} chunk */ + function onData(chunk) { + buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8"); + pump(); + } + const onSignal = () => triggerCancel(); + + stdin.on("data", onData); + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + return { + nextKey() { + return new Promise((resolve) => { + if (cancelled) { + resolve(CANCEL); + return; + } + waiter = { resolve, mode: "key", line: "" }; + pump(); + }); + }, + nextLine() { + return new Promise((resolve) => { + if (cancelled) { + resolve(CANCEL); + return; + } + waiter = { resolve, mode: "line", line: "" }; + pump(); + }); + }, + // Discard buffered-but-unconsumed input, so a stray keypress during the + // idle poll window cannot pre-answer the NEXT gate that appears. + drain() { + buffer = ""; + }, + cancelled() { + return cancelled; + }, + waitCancel() { + return cancelPromise; + }, + close() { + if (closed) { + return; + } + closed = true; + clearInterval(keepAlive); + if (typeof stdin.off === "function") { + stdin.off("data", onData); + } + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + if (rawSet && typeof stdin.setRawMode === "function") { + try { + stdin.setRawMode(false); + } catch { + // no longer a TTY: nothing to restore + } + } + if (typeof stdin.pause === "function") { + try { + stdin.pause(); + } catch { + // best-effort + } + } + }, + }; +} + +/** + * @param {string | null | undefined} requestJson + * @returns {{ title?: string, summary?: string }} + */ +function parseApprovalRequest(requestJson) { + if (typeof requestJson !== "string" || requestJson === "") { + return {}; + } + try { + const parsed = JSON.parse(requestJson); + if (parsed && typeof parsed === "object") { + return parsed; + } + } catch { + // ignore malformed request json + } + return {}; +} + +/** + * Render the block shown when a run parks on an approval gate. + * + * @param {string} runId + * @param {{ nodeId: string, iteration?: number | null, requestJson?: string | null }} approval + * @returns {string} + */ +export function renderApprovalPrompt(runId, approval) { + const request = parseApprovalRequest(approval.requestJson); + const iteration = approval.iteration ?? 0; + const lines = [ + "", + "────────────────────────────────────────", + `⏸ approval needed · ${approval.nodeId} (iteration ${iteration})`, + ]; + if (typeof request.title === "string" && request.title !== "") { + lines.push(request.title); + } + if (typeof request.summary === "string" && request.summary !== "" && request.summary !== request.title) { + lines.push(request.summary); + } + lines.push(`(equivalent: smithers approve ${runId} --node ${approval.nodeId} --iteration ${iteration})`); + lines.push("[y] approve [n] deny [q] quit"); + return `${lines.join("\n")}\n`; +} + +/** + * Parse a select request's stored options into `{ label, value }` pairs. Options + * are typically strings (from `smithers ask-human --choices`), but an object + * option ({ label, value } / { label }) is tolerated. + * + * @param {string | null | undefined} optionsJson + * @returns {Array<{ label: string, value: unknown }>} + */ +export function parseSelectOptions(optionsJson) { + if (typeof optionsJson !== "string" || optionsJson === "") { + return []; + } + let parsed; + try { + parsed = JSON.parse(optionsJson); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + return parsed.map((opt) => { + if (opt && typeof opt === "object") { + const label = typeof opt.label === "string" ? opt.label : JSON.stringify(opt); + const value = "value" in opt ? opt.value : opt; + return { label, value }; + } + return { label: String(opt), value: opt }; + }); +} + +/** + * Render the block shown when a run parks on a human request. + * + * @param {{ nodeId: string, iteration?: number | null, kind: string, prompt?: string | null, optionsJson?: string | null }} request + * @param {Array<{ label: string, value: unknown }>} options + * @returns {string} + */ +export function renderHumanRequestPrompt(request, options) { + const iteration = request.iteration ?? 0; + const lines = [ + "", + "────────────────────────────────────────", + `⏸ human request (${request.kind}) · ${request.nodeId} (iteration ${iteration})`, + ]; + if (typeof request.prompt === "string" && request.prompt !== "") { + lines.push(request.prompt); + } + if (request.kind === "select" && options.length > 0) { + for (let i = 0; i < options.length; i += 1) { + lines.push(` [${i + 1}] ${options[i].label}`); + } + lines.push(options.length <= 9 ? "Press a digit to choose (q to quit)." : "Type a number then Enter (q to quit)."); + } else if (request.kind === "confirm") { + lines.push("[y] yes [n] no [q] quit"); + } else if (request.kind === "json") { + lines.push("Type a JSON value then Enter (q to quit)."); + } else { + lines.push("Type your answer then Enter (q to quit)."); + } + return `${lines.join("\n")}\n`; +} + +/** + * List the next actionable block for a run: its pending approval gates and + * pending human requests, optionally filtered to a single node. Human requests + * win for a node that has one (a HumanTask parks on an approval AND a request; the + * human-answer path resolves both, so treating it as a bare approval would leave + * the request pending). Approvals shadowed by a human request are dropped; the + * remaining candidates are ordered oldest-first. + * + * @param {any} adapter + * @param {string} runId + * @param {string | undefined} nodeFilter + * @returns {Promise<{ kind: "approval" | "human", at: number, item: any } | null>} + */ +export async function findNextPending(adapter, runId, nodeFilter) { + const descendants = await adapter.listRunDescendants(runId); + const scopedRunIds = new Set([runId, ...descendants.map((row) => row.runId)]); + const humanRows = (await adapter.listPendingHumanRequests()).filter( + (r) => r && scopedRunIds.has(r.runId) && (!nodeFilter || r.nodeId === nodeFilter), + ); + const approvals = (await adapter.listPendingApprovals(runId)).filter( + (a) => a && (!nodeFilter || a.nodeId === nodeFilter), + ); + const humanKeys = new Set(humanRows.map((r) => targetKey(r.nodeId, r.iteration))); + /** @type {Array<{ kind: "approval" | "human", at: number, item: any }>} */ + const candidates = []; + for (const r of humanRows) { + if (!INTERACTIVE_HUMAN_KINDS.has(r.kind)) { + continue; + } + candidates.push({ kind: "human", at: typeof r.requestedAtMs === "number" ? r.requestedAtMs : 0, item: r }); + } + for (const a of approvals) { + if (humanKeys.has(targetKey(a.nodeId, a.iteration))) { + continue; + } + candidates.push({ kind: "approval", at: typeof a.requestedAtMs === "number" ? a.requestedAtMs : 0, item: a }); + } + if (candidates.length === 0) { + return null; + } + candidates.sort((x, y) => x.at - y.at); + return candidates[0]; +} + +/** + * Prompt for and commit a decision on one pending approval gate. Loops on an + * unrecognized key. Returns {@link CANCEL} if the operator quit, else a short + * outcome string (the loop re-polls on any return). + * + * @param {{ runId: string, nodeId: string, iteration?: number | null, requestJson?: string | null }} approval + * @param {{ adapter: any, reader: ReturnType, emit: (t: string) => void, decidedBy?: string }} ctx + * @returns {Promise} + */ +async function resolveApproval(approval, ctx) { + const { adapter, reader, emit, decidedBy } = ctx; + const iteration = approval.iteration ?? 0; + reader.drain(); + emit(renderApprovalPrompt(approval.runId ?? "", approval)); + for (;;) { + const key = await reader.nextKey(); + if (key === CANCEL || key === "q" || key === "Q") { + return CANCEL; + } + if (key === "y" || key === "Y" || key === "\r" || key === "\n") { + try { + await Effect.runPromise(approveNode(adapter, approval.runId, approval.nodeId, iteration, undefined, decidedBy)); + emit(`✓ approved ${approval.nodeId}\n`); + } catch (err) { + emit(`✗ could not approve ${approval.nodeId}: ${err?.message ?? String(err)}\n`); + return COMMIT_FAILED; + } + return "approved"; + } + if (key === "n" || key === "N") { + emit("Deny note (optional, Enter to skip): "); + const note = await reader.nextLine(); + if (note === CANCEL) { + return CANCEL; + } + const trimmed = typeof note === "string" ? note.trim() : ""; + try { + await Effect.runPromise( + denyNode(adapter, approval.runId, approval.nodeId, iteration, trimmed || undefined, decidedBy), + ); + emit(`✗ denied ${approval.nodeId}\n`); + } catch (err) { + emit(`✗ could not deny ${approval.nodeId}: ${err?.message ?? String(err)}\n`); + return COMMIT_FAILED; + } + return "denied"; + } + emit("Press y to approve, n to deny, or q to quit.\n"); + } +} + +/** + * Read the answer VALUE for a human request of the given kind. Loops until a + * valid value or {@link CANCEL}. + * + * @param {{ kind: string }} request + * @param {Array<{ label: string, value: unknown }>} options + * @param {{ reader: ReturnType, emit: (t: string) => void }} ctx + * @returns {Promise<{ value: unknown } | typeof CANCEL>} + */ +async function readHumanValue(request, options, ctx) { + const { reader, emit } = ctx; + if (request.kind === "confirm") { + for (;;) { + const key = await reader.nextKey(); + if (key === CANCEL || key === "q" || key === "Q") { + return CANCEL; + } + if (key === "y" || key === "Y") { + return { value: true }; + } + if (key === "n" || key === "N") { + return { value: false }; + } + emit("Press y for yes, n for no, or q to quit.\n"); + } + } + if (request.kind === "select") { + const compact = options.length > 0 && options.length <= 9; + for (;;) { + if (compact) { + const key = await reader.nextKey(); + if (key === CANCEL || key === "q" || key === "Q") { + return CANCEL; + } + const index = typeof key === "string" && /^[1-9]$/.test(key) ? Number(key) - 1 : -1; + if (index >= 0 && index < options.length) { + return { value: options[index].value }; + } + emit(`Press a digit 1-${options.length}, or q to quit.\n`); + continue; + } + const line = await reader.nextLine(); + if (line === CANCEL) { + return CANCEL; + } + const trimmed = typeof line === "string" ? line.trim() : ""; + if (trimmed === "q" || trimmed === "Q") { + return CANCEL; + } + const index = Number.parseInt(trimmed, 10) - 1; + if (Number.isInteger(index) && index >= 0 && index < options.length) { + return { value: options[index].value }; + } + emit(`Enter a number 1-${options.length}, or q to quit.\n`); + } + } + // ask / json: a free-text line. + for (;;) { + const line = await reader.nextLine(); + if (line === CANCEL) { + return CANCEL; + } + const text = typeof line === "string" ? line : ""; + const trimmed = text.trim(); + if (trimmed === "q" || trimmed === "Q") { + return CANCEL; + } + if (request.kind === "json") { + try { + return { value: JSON.parse(text) }; + } catch (err) { + emit(`Not valid JSON (${err?.message ?? String(err)}). Try again, or Ctrl-C to quit.\n`); + continue; + } + } + return { value: text }; + } +} + +/** + * Prompt for and commit an answer to one pending human request, through the SAME + * path the `smithers human answer` command uses: validate the value against the + * request's stored schema, re-check the timeout, resolve the backing approval + * (approveNode) when one is `requested`, then persist the answer. Returns + * {@link CANCEL} if the operator quit, else a short outcome string. + * + * @param {any} request pending human-request row (carries kind/prompt/options/schema/iteration) + * @param {{ adapter: any, reader: ReturnType, emit: (t: string) => void, decidedBy?: string, now: () => number }} ctx + * @returns {Promise} + */ +async function resolveHumanRequest(request, ctx) { + const { adapter, reader, emit, decidedBy, now } = ctx; + const options = parseSelectOptions(request.optionsJson); + reader.drain(); + emit(renderHumanRequestPrompt(request, options)); + for (;;) { + const answer = await readHumanValue(request, options, { reader, emit }); + if (answer === CANCEL) { + return CANCEL; + } + const validation = validateHumanRequestValue(request, answer.value); + if (!validation.ok) { + emit(`${validation.message}\nTry again, or Ctrl-C to quit.\n`); + continue; + } + // Re-fetch to confirm it is still pending and not past its timeout — the + // human may have taken a while to type. Mirrors the `human answer` command. + const fresh = await adapter.getHumanRequest(request.requestId); + if (!fresh || fresh.status !== "pending") { + emit(`Request ${request.requestId} is no longer pending; skipping.\n`); + return "gone"; + } + const answeredAtMs = now(); + if (isHumanRequestPastTimeout(fresh, answeredAtMs)) { + await adapter.expireStaleHumanRequests(answeredAtMs); + emit(`Request ${request.requestId} expired before it could be answered.\n`); + return "expired"; + } + const responseJson = JSON.stringify(answer.value); + try { + const approval = await adapter.getApproval(request.runId, request.nodeId, request.iteration); + if (approval?.status === "requested") { + await Effect.runPromise( + approveNode(adapter, request.runId, request.nodeId, request.iteration, responseJson, decidedBy), + ); + } + await adapter.answerHumanRequest(request.requestId, responseJson, answeredAtMs, decidedBy ?? null); + emit(`✓ answered ${request.nodeId}\n`); + } catch (err) { + emit(`✗ could not answer ${request.nodeId}: ${err?.message ?? String(err)}\n`); + return COMMIT_FAILED; + } + return "answered"; + } +} + +/** + * Run the interactive approve/answer watch loop for a run until the run is + * terminal or the operator quits. Fully injectable for tests (real DB, real + * engine commits; only stdin/emit/timers are seams). + * + * @param {{ + * adapter: any, + * runId: string, + * node?: string, + * stdin?: NodeJS.ReadStream | import("node:stream").Readable, + * emit?: (text: string) => void, + * decidedBy?: string, + * pollIntervalMs?: number, + * now?: () => number, + * sleep?: (ms: number) => Promise, + * linger?: (options: { stdin?: any, emit?: (text: string) => void }) => Promise, + * resumeDetached?: (adapter: any, run: any, runId: string) => Promise<{ resumed: boolean, pid?: number | null }>, + * }} params + * @returns {Promise<{ status: string | undefined, cancelled: boolean }>} + */ +export async function runApproveWatch(params) { + const adapter = params.adapter; + const runId = params.runId; + const node = params.node; + const stdin = params.stdin ?? process.stdin; + const emit = params.emit ?? ((text) => process.stdout.write(text)); + const pollIntervalMs = params.pollIntervalMs ?? APPROVE_WATCH_POLL_INTERVAL_MS; + const now = params.now ?? Date.now; + const sleep = params.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const lingerFn = params.linger ?? lingerUntilClosed; + const resumeDetached = params.resumeDetached; + + const reader = createKeyReader(stdin, emit); + let cancelled = false; + /** @type {string | undefined} */ + let status; + try { + for (;;) { + if (reader.cancelled()) { + cancelled = true; + break; + } + const pending = await findNextPending(adapter, runId, node); + if (pending) { + const outcome = + pending.kind === "approval" + ? await resolveApproval(pending.item, { adapter, reader, emit, decidedBy: params.decidedBy }) + : await resolveHumanRequest(pending.item, { adapter, reader, emit, decidedBy: params.decidedBy, now }); + if (outcome === CANCEL) { + cancelled = true; + break; + } + // A committed approve/deny/human decision on a detached run leaves it + // parked (waiting-approval → waiting-event) with no live engine. Auto- + // resume it, exactly as the non-watch approve/deny commands do, so the + // re-armed node (or the on-deny path) actually runs instead of stranding. + // Skip when the commit threw (COMMIT_FAILED): nothing was decided, so + // resuming would print a misleading "↻ resuming" for a still-pending gate. + if (resumeDetached && outcome !== COMMIT_FAILED) { + try { + const decidedRunId = pending.item.runId ?? runId; + const decided = await adapter.getRun(decidedRunId); + if (decided) { + const res = await resumeDetached(adapter, decided, decidedRunId); + if (res && res.resumed) emit(`↻ resuming ${decidedRunId}\n`); + } + } catch { + // Best-effort: a resume failure must never crash the pane. + } + } + continue; + } + // Nothing actionable right now: stop if the run is terminal, else wait. + const run = await adapter.getRun(runId); + status = run ? deriveTailStatus(await computeRunStateFromRow(adapter, run)) : status; + if (!run || !isTailActiveState(status)) { + emit(`${formatTailFinalStatusLine(runId, status)}\n`); + break; + } + await Promise.race([sleep(pollIntervalMs), reader.waitCancel()]); + } + } finally { + reader.close(); + } + if (!cancelled) { + // Terminal reached naturally: hold the pane open so the operator can read + // the final state, exactly as `tail --linger` does. + await lingerFn({ stdin, emit }); + } + return { status, cancelled }; +} diff --git a/apps/cli/src/argv-utils.js b/apps/cli/src/argv-utils.js index f2f0e1103a..4af0a83d15 100644 --- a/apps/cli/src/argv-utils.js +++ b/apps/cli/src/argv-utils.js @@ -137,3 +137,18 @@ export function rewriteBareResumeFlagArgv(argv) { arg === "--resume" && (argv[index + 1] === undefined || argv[index + 1]?.startsWith("-")) ? "--resume=true" : arg, ); } + +/** + * Same fix as {@link rewriteBareResumeFlagArgv} for the union-typed `--herdr` + * flag: a bare `--herdr --run-id value` would otherwise consume `--run-id` as the + * herdr session value. Rewriting a bare `--herdr` (nothing or another flag next) + * to `--herdr=true` keeps `--herdr=` working while making the bare form a + * clean boolean. + * + * @param {string[]} argv + */ +export function rewriteBareHerdrFlagArgv(argv) { + return argv.map((arg, index) => + arg === "--herdr" && (argv[index + 1] === undefined || argv[index + 1]?.startsWith("-")) ? "--herdr=true" : arg, + ); +} diff --git a/apps/cli/src/classifyTerminalCause.js b/apps/cli/src/classifyTerminalCause.js new file mode 100644 index 0000000000..2098695e26 --- /dev/null +++ b/apps/cli/src/classifyTerminalCause.js @@ -0,0 +1,83 @@ +/** @typedef {import("@smthrs/db/adapter").SmithersDb} SmithersDb */ + +/** + * The engine records a cancellation by writing a *denial* approval whose author + * is this sentinel (engine.js sets `decidedBy: "smithers:cancel"` with + * `decisionJson: { cancelled: true }`). A genuine operator `smithers deny` + * never uses it, so the sentinel is what tells a human-denied gate apart from a + * cancel-driven one. + */ +export const CANCEL_APPROVAL_AUTHOR = "smithers:cancel"; + +/** + * @typedef {"human-denied" | "cancelled" | "quota-parked" | "task-error"} TerminalCause + */ + +/** + * Classify WHY a run reached its failed/terminal state, reading the ledger the + * cause is already recorded in. Only a genuine, unexpected task error + * (`"task-error"`) warrants a post-failure autopsy; a human-denied gate, an + * operator cancel, or a quota park all already have their cause recorded, so + * autopsying them just burns agent tokens investigating a decision. + * + * Reads are defensive: any ledger lookup that throws degrades to the + * autopsy-worthy `"task-error"` so a genuine failure is never silently + * swallowed by a classification error. + * + * @param {SmithersDb} adapter + * @param {string} runId + * @param {{ status?: string | null }} [result] + * @returns {Promise} + */ +export async function classifyTerminalCause(adapter, runId, result) { + const run = await Promise.resolve(adapter.getRun(runId)).catch(() => undefined); + const status = run?.status ?? result?.status ?? null; + // A pending cancel request (`cancelRequestedAtMs` set but the run not yet + // flipped to 'cancelled') suppresses the autopsy INTENTIONALLY: the operator + // is already tearing the run down, so a task error that races the not-yet- + // effected cancel is not worth spending agent tokens to investigate. This is + // a deliberate suppression, unlike the human-denied path below which is + // scoped to the actual terminal-cause node. + if (status === "cancelled" || status === "canceled" || run?.cancelRequestedAtMs != null) { + return "cancelled"; + } + if (status === "waiting-quota" || result?.status === "waiting-quota") { + return "quota-parked"; + } + // listAllDecidedApprovals (NOT listDecidedApprovals): a human-denied gate + // leaves its node in state "failed", which the node-state='pending' filter + // of listDecidedApprovals would exclude. why-diagnosis reads the all-variant + // for the same reason. + const decided = await Promise.resolve(adapter.listAllDecidedApprovals(runId)).catch(() => []); + const humanDenials = decided.filter((row) => row.status === "denied" && row.decidedBy !== CANCEL_APPROVAL_AUTHOR); + // Scope the denial to the run's TERMINAL CAUSE, not its whole history. A + // denial only failed the run when its own gate node is in state "failed" + // (the default onDeny:'fail'). A denial with onDeny:'continue'/'skip' + // (engine.js shouldExecuteDeniedApprovalTask) lets the run continue past the + // gate — its node ends 'finished'/'skipped', so a LATER genuine task error + // is the real terminal cause and must still be autopsied. A run-global + // "any historical denial" check would wrongly suppress that autopsy. + for (const denial of humanDenials) { + let targetNodeId = denial.nodeId; + let targetIteration = denial.iteration; + try { + const request = JSON.parse(denial.requestJson ?? "null"); + if ( + request?.kind === "ReplayUnsafeApproval" && + request.runId === runId && + typeof request.nodeId === "string" && + Number.isSafeInteger(request.iteration) + ) { + targetNodeId = request.nodeId; + targetIteration = request.iteration; + } + } catch { + // Malformed request metadata falls back to the approval row target. + } + const node = await Promise.resolve(adapter.getNode(runId, targetNodeId, targetIteration)).catch(() => undefined); + if (node?.state === "failed") { + return "human-denied"; + } + } + return "task-error"; +} diff --git a/apps/cli/src/cockpit-activity.js b/apps/cli/src/cockpit-activity.js new file mode 100644 index 0000000000..396395b9e3 --- /dev/null +++ b/apps/cli/src/cockpit-activity.js @@ -0,0 +1,349 @@ +/** + * Selected-agent activity strip for the workflow supervisor. + * + * Builds a short sliding window (last N tool/actions) from durable run events + * so the outline can show what the focused agent is doing without opening a + * detail tab. + */ + +import { parseAgentEvent } from "./chat.js"; +import { sanitizeTerminalText } from "@smthrs/tui/src/sanitizeTerminalText.ts"; + +/** Fixed height of the activity body (excluding separator / label). */ +export const ACTIVITY_STRIP_LINES = 4; + +/** + * The run-event types the activity strip renders. Both read paths window over + * exactly these: the direct-db path pre-filters them in SQL (loadNodeActivity), + * and the gateway path pre-filters them before pushing into its activity ring + * (createGatewayObservationSource) — so a busy run's non-activity events never + * dilute/evict the focused node's rows from the bounded ring. + */ +export const ACTIVITY_EVENT_TYPES = ["AgentEvent", "ToolCallStarted", "ToolCallFinished"]; + +/** + * @typedef {{ + * id: string, + * kind: string, + * title: string, + * status: "running" | "done" | "error" | "info", + * detail: string, + * seq: number, + * }} ActivityLine + */ + +/** + * @param {unknown} value + * @param {number} max + */ +function truncate(value, max) { + const s = value == null ? "" : String(value).replace(/\s+/g, " ").trim(); + if (s.length <= max) return s; + return `${s.slice(0, Math.max(0, max - 1))}…`; +} + +/** + * @param {unknown} payloadJson + */ +function parsePayload(payloadJson) { + if (typeof payloadJson !== "string" || payloadJson === "") return null; + try { + const parsed = JSON.parse(payloadJson); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** + * @param {unknown} input + */ +/** + * @param {unknown} input + * @param {number} [max] + */ +function summarizeInput(input, max = 48) { + const cap = Math.max(16, Math.floor(max)); + if (input == null) return ""; + if (typeof input === "string") return truncate(input, cap); + if (typeof input === "object") { + const rec = /** @type {Record} */ (input); + const cmd = rec.command ?? rec.cmd ?? rec.path ?? rec.file ?? rec.query ?? rec.pattern; + if (typeof cmd === "string" && cmd !== "") return truncate(cmd, cap); + try { + return truncate(JSON.stringify(input), cap); + } catch { + return ""; + } + } + return truncate(input, cap); +} + +/** + * Collapse ordered event rows into last-N activity lines for one node. + * + * @param {Array<{ type?: string, seq?: number, payloadJson?: string, timestampMs?: number }>} rows + * @param {string} nodeId + * @param {{ limit?: number, detailMax?: number }} [opts] + * @returns {ActivityLine[]} + */ +export function buildActivityLinesFromEvents(rows, nodeId, opts = {}) { + const limit = Math.max(1, Math.min(80, Math.floor(opts.limit ?? ACTIVITY_STRIP_LINES))); + const detailMax = Math.max(16, Math.floor(opts.detailMax ?? 48)); + if (!nodeId || !Array.isArray(rows) || rows.length === 0) return []; + + /** @type {Map} */ + const byId = new Map(); + /** @type {string[]} */ + const order = []; + + /** + * @param {ActivityLine} line + */ + const upsert = (line) => { + if (!byId.has(line.id)) order.push(line.id); + byId.set(line.id, line); + }; + + for (const row of rows) { + const seq = typeof row.seq === "number" ? row.seq : 0; + const type = String(row.type ?? ""); + + if (type === "AgentEvent") { + const payload = parsePayload(row.payloadJson); + const rowNode = payload && typeof payload.nodeId === "string" ? payload.nodeId : ""; + if (rowNode && rowNode !== nodeId) continue; + + const agentEvent = + payload && typeof payload.event === "object" && payload.event + ? /** @type {Record} */ (payload.event) + : null; + if (!agentEvent) continue; + + // Real CLI agents: type "action" with kind tool/command/… + if (agentEvent.type === "action") { + const action = + agentEvent.action && typeof agentEvent.action === "object" + ? /** @type {Record} */ (agentEvent.action) + : {}; + const kind = String(action.kind ?? "action"); + if ( + kind !== "tool" && + kind !== "command" && + kind !== "file_change" && + kind !== "web_search" && + kind !== "reasoning" + ) { + // Still accept via parseAgentEvent for thought/note edge cases we care about + const parsed = parseAgentEvent({ + type: "AgentEvent", + payloadJson: row.payloadJson ?? "", + seq, + timestampMs: typeof row.timestampMs === "number" ? row.timestampMs : 0, + }); + if (!parsed) continue; + const id = String(action.id ?? `agent-${seq}`); + const title = String(action.title ?? kind); + const phase = String(agentEvent.phase ?? ""); + const ok = agentEvent.ok !== false; + upsert({ + id, + kind, + title, + status: phase === "started" ? "running" : phase === "completed" ? (ok ? "done" : "error") : "info", + detail: + summarizeInput(action.detail, detailMax) || + truncate(parsed.text.replace(/^\[[^\]]+\]\s*/, ""), detailMax), + seq, + }); + continue; + } + + const id = String(action.id ?? `${kind}-${seq}`); + const title = String(action.title ?? kind); + const phase = String(agentEvent.phase ?? ""); + const detailObj = + action.detail && typeof action.detail === "object" + ? /** @type {Record} */ (action.detail) + : {}; + const detail = + summarizeInput(detailObj.input, detailMax) || + summarizeInput(detailObj.output, detailMax) || + truncate(agentEvent.message, detailMax); + const ok = agentEvent.ok !== false; + const prev = byId.get(id); + if (phase === "started") { + upsert({ + id, + kind, + title, + status: "running", + detail: detail || prev?.detail || "", + seq, + }); + } else if (phase === "completed") { + upsert({ + id, + kind, + title, + status: ok ? "done" : "error", + detail: detail || prev?.detail || "", + seq, + }); + } else { + upsert({ + id, + kind, + title, + status: "info", + detail: detail || prev?.detail || "", + seq, + }); + } + continue; + } + + // Scripted / fixture agents: tool_start / tool_end + if (agentEvent.type === "tool_start") { + const name = String(agentEvent.name ?? "tool"); + const id = `tool:${name}:${seq}`; + upsert({ + id, + kind: "tool", + title: name, + status: "running", + detail: summarizeInput(agentEvent.input, detailMax), + seq, + }); + continue; + } + if (agentEvent.type === "tool_end") { + const name = String(agentEvent.name ?? "tool"); + // Match latest open tool with same name + let targetId = null; + for (let i = order.length - 1; i >= 0; i--) { + const cand = byId.get(order[i]); + if (cand && cand.kind === "tool" && cand.title === name && cand.status === "running") { + targetId = cand.id; + break; + } + } + const id = targetId ?? `tool:${name}:${seq}`; + const prev = byId.get(id); + upsert({ + id, + kind: "tool", + title: name, + status: "done", + detail: summarizeInput(agentEvent.output, detailMax) || prev?.detail || "", + seq, + }); + continue; + } + + // progress messages as light activity + if (agentEvent.type === "progress") { + const msg = String(agentEvent.message ?? "").trim(); + if (!msg) continue; + upsert({ + id: `progress-${seq}`, + kind: "progress", + title: "progress", + status: "info", + detail: truncate(msg, Math.max(detailMax, 56)), + seq, + }); + } + continue; + } + + if (type === "ToolCallStarted" || type === "ToolCallFinished") { + const payload = parsePayload(row.payloadJson); + if (!payload) continue; + const rowNode = typeof payload.nodeId === "string" ? payload.nodeId : ""; + if (rowNode && rowNode !== nodeId) continue; + const name = String(payload.toolName ?? payload.name ?? "tool"); + const id = String(payload.seq != null ? `tc-${payload.seq}` : `tc-${name}-${seq}`); + const prev = byId.get(id); + if (type === "ToolCallStarted") { + upsert({ + id, + kind: "tool", + title: name, + status: "running", + detail: summarizeInput(payload.input, detailMax), + seq, + }); + } else { + const st = String(payload.status ?? "ok"); + upsert({ + id, + kind: "tool", + title: name, + status: st === "error" || st === "failed" ? "error" : "done", + detail: summarizeInput(payload.output, detailMax) || prev?.detail || "", + seq, + }); + } + } + } + + const lines = order.map((id) => byId.get(id)).filter(Boolean); + return lines.slice(-limit); +} + +/** + * Format one activity line for the strip (no ANSI — paint applies color). + * + * @param {ActivityLine} line + */ +export function formatActivityPlain(line) { + const glyph = line.status === "running" ? "▸" : line.status === "done" ? "✓" : line.status === "error" ? "✗" : "·"; + const title = line.title || line.kind || "action"; + const detail = line.detail ? ` ${line.detail}` : ""; + return sanitizeTerminalText(`${glyph} ${title}${detail}`); +} + +/** + * Load last-N activity lines for a node from the store. + * + * Event history is ASC + limit, so we window near the run's latest seq to get + * recent activity rather than the oldest page. + * + * @param {any} adapter + * @param {string} runId + * @param {string} nodeId + * @param {{ limit?: number, detailMax?: number }} [opts] + * @returns {Promise} + */ +export async function loadNodeActivity(adapter, runId, nodeId, opts = {}) { + const limit = Math.max(1, Math.min(80, Math.floor(opts.limit ?? ACTIVITY_STRIP_LINES))); + const detailMax = Math.max(16, Math.floor(opts.detailMax ?? 48)); + if (!adapter || !runId || !nodeId) return []; + try { + const lastSeqRaw = await adapter.getLastEventSeq(runId); + const lastSeq = typeof lastSeqRaw === "number" && Number.isFinite(lastSeqRaw) ? lastSeqRaw : -1; + const afterSeq = Math.max(-1, lastSeq - 500); + const rows = + (await adapter.listEventHistory(runId, { + afterSeq, + nodeId, + types: ACTIVITY_EVENT_TYPES, + limit: 500, + })) ?? []; + return buildActivityLinesFromEvents(rows, nodeId, { limit, detailMax }); + } catch { + return []; + } +} + +/** + * Fit a plain activity line to a terminal width (full-width detail panes). + * @param {ActivityLine} line + * @param {number} cols + */ +export function formatActivityPlainWidth(line, cols) { + const max = Math.max(24, Math.floor(cols) - 2); + const plain = formatActivityPlain(line); + return truncate(plain, max); +} diff --git a/apps/cli/src/cockpit-outline-graph.js b/apps/cli/src/cockpit-outline-graph.js new file mode 100644 index 0000000000..b3ab6419fa --- /dev/null +++ b/apps/cli/src/cockpit-outline-graph.js @@ -0,0 +1,367 @@ +/** + * Graph-primary outline: rebuild hierarchy from the last workflow frame + * (DevTools snapshot) and join live node/attempt state. + * + * Fallback remains flat listNodes heuristics in cockpit-outline.js. + */ + +import { getDevToolsSnapshotRoute } from "@smthrs/server/gatewayRoutes/getDevToolsSnapshot"; + +/** + * Local identity helpers (avoid circular import with cockpit-outline.js). + * @param {Record | null | undefined} meta + */ +function identityFromMeta(meta) { + if (!meta || typeof meta !== "object") { + return { backend: "", modelLine: "", identity: "" }; + } + const modelRaw = + (typeof meta.agentModel === "string" && meta.agentModel) || (typeof meta.model === "string" && meta.model) || ""; + const engine = + (typeof meta.agentEngine === "string" && meta.agentEngine) || + (typeof meta.cliEngine === "string" && meta.cliEngine) || + ""; + const effort = + (typeof meta.effort === "string" && meta.effort) || + (typeof meta.reasoningEffort === "string" && meta.reasoningEffort) || + (typeof meta.variant === "string" && meta.variant) || + ""; + let backend = ""; + if (/opencode/i.test(engine)) backend = "opencode"; + else if (/claude/i.test(engine)) backend = "claude-code"; + else if (/codex/i.test(engine)) backend = "codex"; + else if (/pi/i.test(engine)) backend = "pi"; + else if (engine) + backend = String(engine) + .replace(/Agent$/i, "") + .toLowerCase(); + // Match cockpit-outline shortModelId: drop claude- when backend is claude-code + let model = modelRaw; + if ((backend === "claude-code" || backend === "claude") && /^claude[-_]/i.test(model)) { + model = model.replace(/^claude[-_]/i, ""); + } + const modelLine = [model, effort].filter(Boolean).join(" "); + const identity = [backend, modelLine].filter(Boolean).join(" "); + return { backend, modelLine, identity }; +} + +/** + * @typedef {{ + * key: string, + * kind: "task" | "group", + * groupType?: string, + * label: string, + * nodeId?: string | null, + * state: string, + * attempt: number, + * iteration?: number, + * backend?: string, + * modelLine?: string, + * identity?: string, + * expanded?: boolean, + * children: OutlineTreeNode[], + * }} OutlineTreeNode + */ + +/** + * @param {import("@smthrs/protocol/devtools").DevToolsNode | null | undefined} node + * @param {Record>} metaByNode + * @param {string} path + * @returns {OutlineTreeNode | null} + */ +export function mapDevToolsNodeToOutline(node, metaByNode = {}, path = "root") { + if (!node || typeof node !== "object") return null; + const type = String(node.type ?? "unknown"); + const childrenIn = Array.isArray(node.children) ? node.children : []; + + // Container types that should appear as expandable groups when they have structure. + const groupTypes = new Set([ + "parallel", + "sequence", + "loop", + "merge-queue", + "branch", + "worktree", + "saga", + "try-catch", + "subflow", + ]); + + if (node.task) { + const nodeId = + (node.task && typeof node.task.nodeId === "string" && node.task.nodeId) || + (typeof node.props?.id === "string" && node.props.id) || + (typeof node.props?.nodeId === "string" && node.props.nodeId) || + ""; + if (!nodeId) return null; + const meta = metaByNode[nodeId] ?? {}; + const id = identityFromMeta(meta); + const state = (node.task && typeof node.task.state === "string" && node.task.state) || "pending"; + const attempt = (node.task && typeof node.task.attempt === "number" && node.task.attempt) || 0; + const label = + (typeof node.task?.label === "string" && node.task.label) || + (typeof meta.label === "string" && meta.label) || + (typeof node.name === "string" && node.name !== "task" ? node.name : nodeId); + return { + key: nodeId, + kind: "task", + label, + nodeId, + state, + attempt, + iteration: typeof node.task?.iteration === "number" ? node.task.iteration : 0, + backend: id.backend, + modelLine: id.modelLine, + identity: id.identity, + children: [], + }; + } + + // Workflow root: promote children to top-level list (no extra "workflow" chrome). + if (type === "workflow" || type === "unknown") { + const kids = childrenIn.map((c, i) => mapDevToolsNodeToOutline(c, metaByNode, `${path}/${i}`)).filter(Boolean); + if (kids.length === 1) return kids[0]; + if (kids.length === 0) return null; + return { + key: `group:${node.id ?? path}`, + kind: "group", + groupType: type === "workflow" ? "sequence" : "group", + label: type === "workflow" ? String(node.name || "workflow") : "group", + state: aggregateChildState(kids), + attempt: 0, + expanded: true, + children: kids, + }; + } + + if (groupTypes.has(type)) { + const kids = childrenIn + .map((c, i) => mapDevToolsNodeToOutline(c, metaByNode, `${path}/${type}/${i}`)) + .filter(Boolean); + // Flatten empty or single-child sequences to reduce chrome noise. + if (type === "sequence") { + if (kids.length === 0) return null; + if (kids.length === 1) return kids[0]; + } + const label = + type === "parallel" ? "parallel" : type === "loop" ? "loop" : type === "merge-queue" ? "merge-queue" : type; + const key = `group:${type}:${node.id ?? path}`; + return { + key, + kind: "group", + groupType: type, + label, + state: aggregateChildState(kids), + attempt: 0, + expanded: true, + children: kids, + }; + } + + // Unknown structural node: still try children. + const kids = childrenIn.map((c, i) => mapDevToolsNodeToOutline(c, metaByNode, `${path}/x/${i}`)).filter(Boolean); + if (kids.length === 1) return kids[0]; + if (kids.length === 0) return null; + return { + key: `group:${node.id ?? path}:misc`, + kind: "group", + groupType: type, + label: String(node.name || type), + state: aggregateChildState(kids), + attempt: 0, + expanded: true, + children: kids, + }; +} + +/** + * @param {OutlineTreeNode[]} kids + */ +function aggregateChildState(kids) { + if ( + kids.some((k) => + ["in-progress", "running", "waiting-approval", "waiting-event", "waiting-timer", "waiting-quota"].includes( + k.state, + ), + ) + ) + return "in-progress"; + if (kids.some((k) => k.state === "failed")) return "failed"; + if (kids.length > 0 && kids.every((k) => k.state === "finished" || k.state === "skipped")) return "finished"; + if (kids.some((k) => k.state === "pending")) return "pending"; + return kids[0]?.state ?? "pending"; +} + +/** + * Apply expandOverrides to a tree (mutates expanded flags via new nodes). + * @param {OutlineTreeNode | null} node + * @param {Record} overrides + * @returns {OutlineTreeNode | null} + */ +export function applyExpandOverrides(node, overrides = {}) { + if (!node) return null; + if (node.kind === "task") return { ...node, children: [] }; + const bareKey = node.key.startsWith("phase:") ? node.key.slice("phase:".length) : node.key; + let expanded = node.expanded !== false; + if (Object.prototype.hasOwnProperty.call(overrides, node.key)) { + expanded = overrides[node.key] === true; + } else if (Object.prototype.hasOwnProperty.call(overrides, bareKey)) { + expanded = overrides[bareKey] === true; + } + const children = (node.children ?? []).map((c) => applyExpandOverrides(c, overrides)).filter(Boolean); + return { ...node, expanded, children }; +} + +/** + * Flatten tree into paint/select order. + * @param {OutlineTreeNode[]} roots + * @param {Record} [overrides] + * @returns {{ + * selectables: Array<{ key: string, phaseId: string, nodeId: string | null, label: string, state: string, attempt: number, kind: "agent" | "phase", identity?: string, steerable?: boolean }>, + * rows: Array<{ key: string, depth: number, isLast: boolean[], node: OutlineTreeNode }>, + * }} + */ +export function flattenOutlineTree(roots, overrides = {}) { + /** @type {ReturnType["selectables"]} */ + const selectables = []; + /** @type {ReturnType["rows"]} */ + const rows = []; + + /** + * @param {OutlineTreeNode[]} nodes + * @param {boolean[]} isLastStack + */ + const walk = (nodes, isLastStack) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const isLast = i === nodes.length - 1; + const stack = [...isLastStack, isLast]; + rows.push({ key: node.key, depth: isLastStack.length, isLast: stack, node }); + + if (node.kind === "task") { + selectables.push({ + key: node.key, + phaseId: node.key, + nodeId: node.nodeId ?? node.key, + label: node.label, + state: node.state, + attempt: node.attempt, + kind: "agent", + identity: node.identity, + }); + } else { + selectables.push({ + key: node.key, + phaseId: node.key, + nodeId: null, + label: node.label, + state: node.state, + attempt: 0, + kind: "phase", + }); + // Prefer expanded flag already applied via applyExpandOverrides. + if (node.expanded !== false && node.children?.length) { + walk(node.children, stack); + } + } + } + }; + + const applied = roots.map((r) => applyExpandOverrides(r, overrides)).filter(Boolean); + walk(applied, []); + return { selectables, rows }; +} + +/** + * Load hierarchical outline from last frame; null if no frame / parse fail. + * + * @param {any} adapter + * @param {string} runId + * @param {Record>} [metaByNode] + * @returns {Promise<{ roots: OutlineTreeNode[], frameNo: number, source: "graph" } | null>} + */ +export async function loadOutlineTreeFromAdapter(adapter, runId, metaByNode = {}) { + if (!adapter || !runId) return null; + try { + const snapshot = await getDevToolsSnapshotRoute({ adapter, runId }); + if (!snapshot?.root) return null; + // States already attached by getDevToolsSnapshotRoute; re-join meta for identity. + const roots = []; + const mapped = mapDevToolsNodeToOutline(snapshot.root, metaByNode, "root"); + if (!mapped) return null; + // Promote workflow/sequence root children as top-level roots for a cleaner outline. + if ( + mapped.kind === "group" && + (mapped.groupType === "sequence" || mapped.groupType === "workflow") && + mapped.children.length > 0 + ) { + roots.push(...mapped.children); + } else { + roots.push(mapped); + } + if (roots.length === 0) return null; + return { roots, frameNo: snapshot.frameNo ?? 0, source: "graph" }; + } catch { + return null; + } +} + +/** + * Convert legacy flat phases into tree roots (fallback path). + * @param {Array<{ id: string, kind: string, title: string, agents: any[], expanded?: boolean }>} phases + * @returns {OutlineTreeNode[]} + */ +export function outlinePhasesToTree(phases) { + /** @type {OutlineTreeNode[]} */ + const roots = []; + for (const p of phases ?? []) { + if (p.kind === "single") { + const a = p.agents?.[0]; + if (!a) continue; + const loopBit = typeof p.loopLabel === "string" && p.loopLabel ? ` · ${p.loopLabel}` : ""; + roots.push({ + key: a.nodeId, + kind: "task", + label: `${a.displayName || a.nodeId}${loopBit}`, + nodeId: a.nodeId, + state: a.state ?? "pending", + attempt: a.attempt ?? 0, + iteration: a.iteration ?? 0, + backend: a.backend, + modelLine: a.modelLine, + identity: a.identity, + children: [], + }); + } else { + const children = (p.agents ?? []).map((a) => ({ + key: a.nodeId, + kind: /** @type {"task"} */ ("task"), + label: a.displayName || a.nodeId, + nodeId: a.nodeId, + state: a.state ?? "pending", + attempt: a.attempt ?? 0, + iteration: a.iteration ?? 0, + backend: a.backend, + modelLine: a.modelLine, + identity: a.identity, + children: [], + })); + const loopBit = typeof p.loopLabel === "string" && p.loopLabel ? ` · ${p.loopLabel}` : ""; + roots.push({ + key: `phase:${p.id}`, + kind: "group", + groupType: "parallel", + label: `${p.title || "parallel"}${loopBit}`, + state: children.some((c) => c.state === "in-progress") + ? "in-progress" + : children.every((c) => c.state === "finished") + ? "finished" + : "pending", + attempt: 0, + expanded: p.expanded !== false, + children, + }); + } + } + return roots; +} diff --git a/apps/cli/src/cockpit-outline.js b/apps/cli/src/cockpit-outline.js new file mode 100644 index 0000000000..04ac54025b --- /dev/null +++ b/apps/cli/src/cockpit-outline.js @@ -0,0 +1,1623 @@ +/** + * Cockpit outline model + paint — herdr-first workflow overview. + * + * Layout: + * - Vertical spine of phases (full separator between phases) + * - Single-agent phase = one selectable row + * - Multi-agent phase = phase header + vertical nested agent rows + * + * Structure without stored graph: consecutive worker-like node ids form a + * parallel phase; other nodes are single-agent phases (DB insertion order). + */ + +import pc from "picocolors"; +import { buildDigestBlock, formatElapsed, isLikelyWorkerNodeId } from "@smthrs/herdr"; +import { sanitizeTerminalText } from "@smthrs/tui/src/sanitizeTerminalText.ts"; +import { ACTIVITY_STRIP_LINES, formatActivityPlain } from "./cockpit-activity.js"; +import { flattenOutlineTree, outlinePhasesToTree } from "./cockpit-outline-graph.js"; +import { buildDigestInputFromOverview, overviewStateLabel } from "./tail-overview.js"; + +/** + * Outline fan-out: workers plus smithering-style multi-agent prefixes + * (research:*, probe:*, review:* except synthesis). + * @param {string} nodeId + */ +export function isOutlineFanoutNodeId(nodeId) { + if (isLikelyWorkerNodeId(nodeId)) return true; + const id = String(nodeId ?? ""); + if (id === "") return false; + if (/:synthesis$/i.test(id)) return false; + if (/^(?:research|probe|review):/i.test(id)) return true; + return false; +} + +/** + * Parallel phase title from first agent id prefix (research / probe / review / parallel). + * @param {string[]} nodeIds + */ +export function fanoutPhaseTitle(nodeIds) { + const first = nodeIds[0] ?? ""; + const m = String(first).match(/^(research|probe|review)(?=:)/i); + if (m) return m[1].toLowerCase(); + if (nodeIds.every((id) => isLikelyWorkerNodeId(id))) return "parallel"; + return "parallel"; +} + +const ESC = "\x1b"; +const CLEAR_HOME = `${ESC}[H${ESC}[2J`; +const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +const brand = { + mark: (s) => pc.bold(pc.cyan(s)), + title: (s) => pc.bold(s), + dim: (s) => pc.dim(s), + muted: (s) => pc.gray(s), + ok: (s) => pc.green(s), + warn: (s) => pc.yellow(s), + err: (s) => pc.red(s), + live: (s) => pc.cyan(s), + bar: (s) => pc.dim(s), + sel: (s) => pc.inverse(s), + // Live/working agent name (not selection). + hot: (s) => pc.bold(s), + // Selection highlight — distinct from LIVE cyan. + pick: (s) => pc.bold(pc.yellow(s)), + // Soft success — done work recedes so live work wins the eye. + okDim: (s) => pc.dim(pc.green(s)), +}; + +/** + * Fixed outline columns: + * [caret][ ][tree…][name ……][ ][status][ ][attempts][ ][backend][ ][model…] + * + * Tree is spine-only (├─ │ └─). Name absorbs leftover tree budget so right + * columns share one edge. + */ +const TREE_BUDGET = 6; // max tree prefix width (nested "│ ├─ ") +const STATE_W = 8; // "canceled" / "status " +const ATT_W = 8; // "attempts" +const BACKEND_W = 11; // "backend" / claude-code / opencode + +/** + * Terminal display columns (fullwidth / wide CJK ≈ 2). + * @param {string} s + */ +export function displayWidth(s) { + const plain = String(s).replace(/\x1b\[[0-9;]*m/g, ""); + let w = 0; + for (const ch of plain) { + const c = ch.codePointAt(0) ?? 0; + if ( + (c >= 0xff01 && c <= 0xff60) || + (c >= 0xffe0 && c <= 0xffe6) || + (c >= 0x1100 && c <= 0x115f) || + (c >= 0x2e80 && c <= 0xa4cf) || + (c >= 0xac00 && c <= 0xd7a3) || + (c >= 0xf900 && c <= 0xfaff) || + (c >= 0xfe10 && c <= 0xfe19) || + (c >= 0xfe30 && c <= 0xfe6f) || + (c >= 0x20000 && c <= 0x3fffd) || + c === 0x3000 + ) { + w += 2; + } else { + w += 1; + } + } + return w; +} + +/** + * Pad to width without inventing an ellipsis when the line is already exact-width + * (full-width rules used to become `──…` because of `>=`). + * @param {string} s + * @param {number} width + */ +function padVis(s, width) { + const dw = displayWidth(s); + if (dw > width) { + // Truncate by display width (fullwidth-safe). + const plain = s.replace(/\x1b\[[0-9;]*m/g, ""); + let acc = ""; + let w = 0; + for (const ch of plain) { + const cw = displayWidth(ch); + if (w + cw > width - 1) break; + acc += ch; + w += cw; + } + return acc + "…"; + } + if (dw === width) return s; + return s + " ".repeat(width - dw); +} + +/** + * @param {string} s + * @param {number} width + */ +function clipLine(s, width) { + const dw = displayWidth(s); + if (dw < width) return padVis(s, width); + if (dw === width) return s; + return padVis(s, width); +} + +/** + * OpenCode-style wordmark is a custom SVG block typeface (filled rectangles per + * letter), not a system font. In a 1-row TTY we approximate with fullwidth + * Latin + bold + soft rails — fat, grey/white, no cyan. + * @param {string} s + */ +export function toFullwidthLatin(s) { + return [...String(s)] + .map((ch) => { + const c = ch.charCodeAt(0); + if (c >= 0x41 && c <= 0x5a) return String.fromCharCode(0xff21 + (c - 0x41)); + if (c >= 0x61 && c <= 0x7a) return String.fromCharCode(0xff41 + (c - 0x61)); + if (c === 0x20) return "\u3000"; + return ch; + }) + .join(""); +} + +/** Brass #cab16a (truecolor when supported). */ +function brass(s) { + if (pc.isColorSupported === false) return s; + return `\x1b[38;2;202;177;106m${s}\x1b[39m`; +} + +/** Compact wordmark for the title bar (right) — bold brass SMITHERS. */ +function smithersMark() { + return pc.bold(brass("SMITHERS")); +} + +/** + * Three-zone title: left | center | right (no overlap). + * Right mark is never clipped (SMITHERS stays intact); center shrinks first. + * @param {string} left + * @param {string} center + * @param {string} right + * @param {number} cols + */ +export function layoutTitleBar(left, center, right, cols) { + const w = Math.max(40, cols); + const lw = displayWidth(left); + const rw = displayWidth(right); + let C = center; + let cw = displayWidth(C); + // Reserve right mark + left + gaps; shrink center if needed. + const minGaps = (lw > 0 ? 1 : 0) + (rw > 0 ? 1 : 0); + const maxCenter = Math.max(0, w - lw - rw - minGaps); + if (cw > maxCenter) { + // Truncate center by display width. + const plain = String(C).replace(/\x1b\[[0-9;]*m/g, ""); + let acc = ""; + let aw = 0; + for (const ch of plain) { + const cwch = displayWidth(ch); + if (aw + cwch > Math.max(0, maxCenter - 1)) break; + acc += ch; + aw += cwch; + } + C = maxCenter > 0 ? `${acc}…` : ""; + cw = displayWidth(C); + } + let cStart = Math.floor((w - cw) / 2); + const minC = lw + (lw > 0 ? 1 : 0); + const maxC = w - rw - cw - (rw > 0 ? 1 : 0); + if (cStart < minC) cStart = minC; + if (maxC >= minC && cStart > maxC) cStart = maxC; + const leftGap = Math.max(0, cStart - lw); + const afterCenter = cStart + cw; + const rightStart = w - rw; + const midGap = Math.max(0, rightStart - afterCenter); + const line = left + " ".repeat(leftGap) + C + " ".repeat(midGap) + right; + // Exact visual width: pad if short (never clip the right mark). + const dw = displayWidth(line); + if (dw < w) return line + " ".repeat(w - dw); + return line; +} + +/** + * @param {string | null | undefined} state + */ +export function isActiveNodeState(state) { + const s = String(state ?? ""); + return s === "in-progress" || s === "waiting-approval" || s === "waiting-event" || s === "waiting-timer"; +} + +/** + * @param {string | null | undefined} state + */ +function isFailedState(state) { + return String(state ?? "") === "failed"; +} + +/** + * @param {string | null | undefined} state + */ +function isDoneState(state) { + const s = String(state ?? ""); + return s === "finished" || s === "skipped" || s === "cancelled" || s === "canceled"; +} + +/** + * Split attempt meta into harness backend vs model (+ effort). + * + * Effort only appears when the engine persisted it on the attempt + * (`effort` / `reasoningEffort` / OpenCode `variant` / Pi `thinking`). + * + * @param {Record | null | undefined} meta + * @returns {{ backend: string, model: string, effort: string, modelLine: string }} + */ +export function parseAgentIdentity(meta) { + if (!meta || typeof meta !== "object") { + return { backend: "", model: "", effort: "", modelLine: "" }; + } + const modelRaw = + (typeof meta.agentModel === "string" && meta.agentModel) || (typeof meta.model === "string" && meta.model) || ""; + const engineRaw = + (typeof meta.agentEngine === "string" && meta.agentEngine) || + (typeof meta.agentFamily === "string" && meta.agentFamily) || + (typeof meta.engine === "string" && meta.engine) || + (typeof meta.cliEngine === "string" && meta.cliEngine) || + ""; + const effortRaw = + (typeof meta.effort === "string" && meta.effort) || + (typeof meta.reasoningEffort === "string" && meta.reasoningEffort) || + (typeof meta.modelEffort === "string" && meta.modelEffort) || + (typeof meta.effortLevel === "string" && meta.effortLevel) || + (typeof meta.variant === "string" && meta.variant) || + (typeof meta.thinking === "string" && meta.thinking) || + ""; + + let backend = ""; + if (engineRaw && engineRaw !== "Object" && engineRaw !== "[object Object]") { + const eng = engineRaw.trim(); + if (/opencode/i.test(eng)) backend = "opencode"; + else if (/claude[-_ ]?code|ClaudeCode/i.test(eng) || /^claude$/i.test(eng)) backend = "claude-code"; + else if (/codex/i.test(eng)) backend = "codex"; + else if (/grok/i.test(eng)) backend = "grok"; + else if (/^pi\b|PiAgent/i.test(eng) && !/openai/i.test(eng)) backend = "pi"; + else if (/openai/i.test(eng)) backend = "openai"; + else if (/gemini|antigravity/i.test(eng)) backend = "gemini"; + else if (/hermes/i.test(eng)) backend = "hermes"; + else if (/kimi/i.test(eng)) backend = "kimi"; + else if (/amp/i.test(eng)) backend = "amp"; + else if (/scripted/i.test(eng)) backend = "scripted"; + else { + backend = eng + .replace(/Agent$/i, "") + .replace(/^Smithers/i, "") + .replace(/([a-z])([A-Z])/g, "$1-$2") + .toLowerCase() + .trim(); + } + } else if (modelRaw === "scripted-agent" || /scripted/i.test(modelRaw)) { + backend = "scripted"; + } + + // Display short model: drop vendor prefix already implied by backend + // (claude-sonnet-5 → sonnet-5 next to backend "claude-code"). + const model = shortModelId(modelRaw === "scripted-agent" ? "scripted-agent" : modelRaw, backend); + const effort = effortRaw && String(effortRaw) !== "true" && String(effortRaw) !== "false" ? String(effortRaw) : ""; + const modelLine = [model, effort].filter(Boolean).join(" ").trim(); + return { backend, model, effort, modelLine }; +} + +/** + * Shorten model id for the model column when backend already names the vendor. + * @param {string} modelRaw + * @param {string} backend + */ +export function shortModelId(modelRaw, backend) { + const m = String(modelRaw ?? "").trim(); + if (!m) return ""; + const b = String(backend ?? "").toLowerCase(); + // claude-code / claude → drop leading claude- + if ((b === "claude-code" || b === "claude" || b === "") && /^claude[-_]/i.test(m)) { + return m.replace(/^claude[-_]/i, ""); + } + // codex / openai → drop openai/ prefix if present; keep gpt-5.5 as-is (distinct id) + if ((b === "codex" || b === "openai") && /^openai\//i.test(m)) { + return m.replace(/^openai\//i, ""); + } + // opencode grok → drop grok- only when backend is already grok? keep full for clarity + if (b === "opencode" && /^opencode[-_]/i.test(m)) { + return m.replace(/^opencode[-_]/i, ""); + } + return m; +} + +/** + * Compact single-line identity (backend model effort) for tests / other surfaces. + * + * @param {Record | null | undefined} meta + * @returns {string} + */ +export function formatAgentIdentity(meta) { + const { backend, model, effort } = parseAgentIdentity(meta); + const parts = []; + if (backend) parts.push(backend); + if (model && model.toLowerCase() !== backend) parts.push(model); + if (effort) parts.push(effort); + return parts.join(" ").trim(); +} + +/** + * Human-facing title for a node: author `label` when set, else nodeId. + * @param {{ nodeId: string, label?: string | null }} n + */ +export function nodeDisplayLabel(n) { + const raw = typeof n?.label === "string" ? n.label.trim() : ""; + if (raw !== "") return raw; + return String(n?.nodeId ?? ""); +} + +/** + * @param {Array<{ nodeId: string, state?: string | null, lastAttempt?: number | null, iteration?: number | null, updatedAtMs?: number | null, label?: string | null }>} nodes + * @param {Record>} [metaByNode] + * @returns {{ phases: OutlinePhase[], selectables: OutlineSelectable[] }} + */ +export function buildOutlineFromNodes(nodes, metaByNode = {}) { + const list = Array.isArray(nodes) ? nodes : []; + // Prefer highest iteration per nodeId (current leaf view). + /** @type {Map} */ + const byId = new Map(); + for (const n of list) { + if (!n?.nodeId) continue; + const prev = byId.get(n.nodeId); + const it = typeof n.iteration === "number" ? n.iteration : 0; + const pit = prev && typeof prev.iteration === "number" ? prev.iteration : 0; + if (!prev || it >= pit) byId.set(n.nodeId, n); + } + const ordered = [...byId.values()]; + + /** @type {OutlinePhase[]} */ + const phases = []; + /** @type {typeof ordered} */ + let workerBuf = []; + + const flushWorkers = () => { + if (workerBuf.length === 0) return; + const agents = workerBuf.map((n) => toAgent(n, metaByNode[n.nodeId])); + // Lone fan-out id (e.g. research:design-art) paints as a single phase. + if (agents.length === 1) { + const a = agents[0]; + phases.push({ + id: `single:${a.nodeId}`, + kind: "single", + title: a.displayName, + agents: [a], + expanded: true, + }); + workerBuf = []; + return; + } + const title = fanoutPhaseTitle(agents.map((a) => a.nodeId)); + const id = `parallel:${agents + .map((a) => a.nodeId) + .join("+") + .slice(0, 48)}`; + phases.push({ + id, + kind: "parallel", + title, + agents, + expanded: shouldExpandPhase(agents), + }); + workerBuf = []; + }; + + for (const n of ordered) { + if (isOutlineFanoutNodeId(n.nodeId)) { + // Split fan-out groups when the prefix changes (research → probe → review). + if (workerBuf.length > 0) { + const prevTitle = fanoutPhaseTitle(workerBuf.map((x) => x.nodeId)); + const nextTitle = fanoutPhaseTitle([n.nodeId]); + if (prevTitle !== nextTitle) flushWorkers(); + } + workerBuf.push(n); + } else { + flushWorkers(); + const agent = toAgent(n, metaByNode[n.nodeId]); + phases.push({ + id: `single:${n.nodeId}`, + kind: "single", + title: agent.displayName, + agents: [agent], + expanded: true, + }); + } + } + flushWorkers(); + + /** @type {OutlineSelectable[]} */ + const selectables = []; + for (const phase of phases) { + if (phase.kind === "single") { + const a = phase.agents[0]; + if (a) { + selectables.push({ + key: a.nodeId, + phaseId: phase.id, + nodeId: a.nodeId, + label: a.displayName, + state: a.state, + attempt: a.attempt, + kind: "agent", + }); + } + } else { + // Phase header is selectable for expand/collapse; agents listed when expanded + selectables.push({ + key: `phase:${phase.id}`, + phaseId: phase.id, + nodeId: null, + label: phase.title, + state: phaseStatus(phase.agents), + attempt: 0, + kind: "phase", + }); + if (phase.expanded) { + for (const a of phase.agents) { + selectables.push({ + key: a.nodeId, + phaseId: phase.id, + nodeId: a.nodeId, + label: a.displayName, + state: a.state, + attempt: a.attempt, + kind: "agent", + }); + } + } + } + } + + return { phases, selectables }; +} + +/** + * @param {{ nodeId: string, state?: string | null, lastAttempt?: number | null, label?: string | null }} n + * @param {Record | undefined} meta + */ +function toAgent(n, meta) { + const displayName = nodeDisplayLabel(n); + const iteration = typeof n.iteration === "number" && n.iteration > 0 ? n.iteration : 0; + const id = parseAgentIdentity(meta); + return { + nodeId: n.nodeId, + displayName, + state: n.state ?? "pending", + attempt: typeof n.lastAttempt === "number" ? n.lastAttempt : 0, + iteration, + stateLabel: overviewStateLabel(n.state), + backend: id.backend, + modelLine: id.modelLine, + // Compact identity kept for callers/tests that still read one string. + identity: formatAgentIdentity(meta), + }; +} + +/** + * 1-based loop iteration label for a phase (empty when still on first pass). + * Unbounded loops only show current iter — never a fake `/max`. + * @param {OutlineAgent[]} agents + */ +export function phaseLoopLabel(agents) { + let maxIt = 0; + for (const a of agents) { + const it = typeof a.iteration === "number" ? a.iteration : 0; + if (it > maxIt) maxIt = it; + } + if (maxIt <= 0) return ""; + return `iter ${maxIt + 1}`; +} + +/** + * Only live in-progress agents are steerable (steer). Others may still be opened for inspect. + * @param {string} state + * @param {boolean} runLive + */ +export function isSteerableAgent(state, runLive) { + return runLive === true && String(state ?? "") === "in-progress"; +} + +/** + * Default expand policy for parallel phases. + * Always expanded so large pending fan-outs (probe/workers) match research UX; + * user can still collapse with Enter / expandOverrides. + * @param {OutlineAgent[]} _agents + */ +function shouldExpandPhase(_agents) { + return true; +} + +/** + * @param {OutlineAgent[]} agents + */ +function phaseStatus(agents) { + if (agents.some((a) => isActiveNodeState(a.state))) return "in-progress"; + if (agents.some((a) => isFailedState(a.state))) return "failed"; + if (agents.length > 0 && agents.every((a) => isDoneState(a.state))) return "finished"; + if (agents.some((a) => String(a.state) === "pending")) return "pending"; + return agents[0]?.state ?? "pending"; +} + +/** + * @param {OutlineAgent[]} agents + */ +function phaseTallies(agents) { + let w = 0; + let b = 0; + let f = 0; + let d = 0; + for (const a of agents) { + const s = String(a.state); + if (s === "in-progress") w += 1; + else if (s === "waiting-approval" || s === "waiting-event" || s === "waiting-timer") b += 1; + else if (s === "failed") f += 1; + else if (isDoneState(s)) d += 1; + } + return { w, b, f, d, n: agents.length }; +} + +/** + * @typedef {{ nodeId: string, displayName: string, state: string, attempt: number, iteration: number, stateLabel: string, identity?: string }} OutlineAgent + * @typedef {{ id: string, kind: "single" | "parallel", title: string, agents: OutlineAgent[], expanded: boolean, loopLabel?: string }} OutlinePhase + * @typedef {{ key: string, phaseId: string, nodeId: string | null, label: string, state: string, attempt: number, kind: "agent" | "phase", identity?: string, steerable?: boolean }} OutlineSelectable + */ + +/** + * Compact deterministic digest lines for the supervisor (no LLM). + * Omits run/status/elapsed (already on the run strip); keeps tallies, + * active nodes, attention, and queued steers. + * + * @param {Parameters[0]} input + * @returns {string[]} plain lines (no ANSI); empty when nothing useful + */ +export function formatSupervisorDigestLines(input) { + const dig = buildDigestInputFromOverview(input); + /** @type {string[]} */ + const lines = []; + const w = dig.working ?? 0; + const b = dig.blocked ?? 0; + const f = dig.failed ?? 0; + const d = dig.done ?? 0; + const hasNodes = w + b + f + d > 0 || (Array.isArray(input.nodes) && input.nodes.length > 0); + if (hasNodes) { + lines.push(`${w} working · ${b} blocked · ${f} failed · ${d} done`); + } + const active = Array.isArray(dig.activeNodeIds) ? dig.activeNodeIds.filter(Boolean) : []; + if (active.length > 0) { + lines.push(`active: ${active.slice(0, 8).join(", ")}${active.length > 8 ? "…" : ""}`); + } + const attention = Array.isArray(dig.attentionLines) ? dig.attentionLines.filter(Boolean) : []; + const parts = []; + if (attention.length > 0) { + parts.push(attention.slice(0, 4).join(" · ")); + } + if (typeof dig.queuedSteerCount === "number" && dig.queuedSteerCount > 0) { + parts.push(`steers: ${dig.queuedSteerCount} queued`); + } + if (typeof dig.lastEventSummary === "string" && dig.lastEventSummary !== "") { + parts.push(`last: ${dig.lastEventSummary}`); + } + if (parts.length > 0) { + lines.push(parts.join(" · ")); + } + // Fallback: full block if we somehow have digest text but no compact lines + if (lines.length === 0) { + const full = buildDigestBlock(dig).trim(); + if (full) { + // drop header + run line; keep remaining body lines + const body = full.split("\n").filter((ln) => !ln.startsWith("── digest") && !ln.startsWith("run ")); + return body; + } + } + return lines; +} + +/** + * Run-level tallies across all outline agents. + * @param {OutlinePhase[]} phases + */ +export function outlineTallies(phases) { + let w = 0; + let b = 0; + let f = 0; + let d = 0; + let p = 0; + let n = 0; + for (const phase of phases) { + for (const a of phase.agents) { + n += 1; + const s = String(a.state); + if (s === "in-progress") w += 1; + else if (s === "waiting-approval" || s === "waiting-event" || s === "waiting-timer") b += 1; + else if (s === "failed") f += 1; + else if (isDoneState(s)) d += 1; + else if (s === "pending") p += 1; + } + } + return { w, b, f, d, p, n }; +} + +/** + * Pending nodes after a hard fail (or terminal cancel) were never reached. + * @param {string} state + * @param {string} runStatus + * @param {boolean} runLive + */ +export function effectiveDisplayState(state, runStatus, runLive) { + const s = String(state ?? ""); + if (!runLive && isActiveNodeState(s)) return "stale"; + if ( + s === "pending" && + !runLive && + (runStatus === "failed" || runStatus === "cancelled" || runStatus === "canceled") + ) { + return "not-reached"; + } + return s; +} + +/** + * @param {string} state + */ +export function outlineStateLabel(state) { + if (state === "stale") return "stale"; + if (state === "not-reached") return "not reached"; + return overviewStateLabel(state); +} + +/** + * @param {{ + * runId: string, + * workflowName?: string, + * status?: string, + * nodes?: any[], + * startedAtMs?: number, + * finishedAtMs?: number | null, + * nowMs?: number, + * live?: boolean, + * tick?: number, + * lastPollAtMs?: number, + * selectedKey?: string, + * expandOverrides?: Record, + * agentMetaByNode?: Record>, + * liveElsewhere?: boolean, + * herdrAvailable?: boolean, + * sourceKind?: "direct-db" | "gateway", + * footer?: string, + * statusBanner?: string, + * scrollOffset?: number, + * selectedActivity?: { + * nodeId?: string, + * label?: string, + * lines?: Array<{ + * id?: string, + * kind?: string, + * title?: string, + * status?: "running" | "done" | "error" | "info", + * detail?: string, + * seq?: number, + * }>, + * } | null, + * freeScroll?: boolean, + * outlineRoots?: import("./cockpit-outline-graph.js").OutlineTreeNode[], + * outlineSource?: "graph" | "flat", + * queuedSteers?: Array<{ nodeId: string, status?: string }>, + * lastEventSummary?: string, + * }} input + */ +export function buildCockpitOutlineModel(input) { + const nowMs = input.nowMs ?? Date.now(); + const startedAtMs = input.startedAtMs ?? nowMs; + const statusEarly = input.status ?? "unknown"; + const terminal = + input.live === false || + ["finished", "failed", "cancelled", "canceled", "stale", "orphaned", "succeeded"].includes(statusEarly); + const endMs = + terminal && typeof input.finishedAtMs === "number" && input.finishedAtMs > 0 + ? input.finishedAtMs + : terminal && typeof input.lastPollAtMs === "number" + ? // fallback: freeze at last known time if finishedAt missing (stale kills) + Math.min(nowMs, input.lastPollAtMs) + : nowMs; + // Prefer finishedAtMs for true terminal runs; for stale use max node update if available + const elapsedEnd = (() => { + if (!terminal) return nowMs; + if (typeof input.finishedAtMs === "number" && input.finishedAtMs > startedAtMs) { + return input.finishedAtMs; + } + // Stale / aborted: freeze using latest node update from model nodes + const nodes = Array.isArray(input.nodes) ? input.nodes : []; + let maxU = 0; + for (const n of nodes) { + if (typeof n?.updatedAtMs === "number" && n.updatedAtMs > maxU) maxU = n.updatedAtMs; + } + if (maxU > startedAtMs) return maxU; + return endMs; + })(); + const elapsedMs = Math.max(0, elapsedEnd - startedAtMs); + const metaByNode = input.agentMetaByNode && typeof input.agentMetaByNode === "object" ? input.agentMetaByNode : {}; + /** @type {Record} */ + const overrides = { + ...(input.expandOverrides && typeof input.expandOverrides === "object" ? input.expandOverrides : {}), + }; + const selectedKey = input.selectedKey; + const status = input.status ?? "unknown"; + // Prefer explicit `live` from top (derived engine state). Never treat stale/orphaned as live. + const liveFromStatus = + ["running", "waiting-approval", "waiting-event", "waiting-timer", "paused", "continued"].includes(status) && + status !== "stale" && + status !== "orphaned"; + const live = typeof input.live === "boolean" ? input.live : liveFromStatus; + + // Graph-primary outline when roots provided; else flat listNodes heuristic. + const outlineSource = Array.isArray(input.outlineRoots) && input.outlineRoots.length > 0 ? "graph" : "flat"; + const { phases: rawPhases } = buildOutlineFromNodes(input.nodes ?? [], metaByNode); + const phasesForTree = rawPhases.map((p) => ({ + ...p, + loopLabel: phaseLoopLabel(p.agents), + })); + /** @type {import("./cockpit-outline-graph.js").OutlineTreeNode[]} */ + let outlineRoots = outlineSource === "graph" ? input.outlineRoots : outlinePhasesToTree(phasesForTree); + + // Force-expand ancestors of the selection so the focused node is always listed. + if (selectedKey) { + const forceKeys = new Set(); + const findPath = (nodes, trail) => { + for (const n of nodes ?? []) { + const next = [...trail, n.key]; + if (n.key === selectedKey || n.nodeId === selectedKey) { + for (const k of trail) forceKeys.add(k); + return true; + } + if (n.children?.length && findPath(n.children, next)) return true; + } + return false; + }; + findPath(outlineRoots, []); + if (forceKeys.size > 0) { + for (const k of forceKeys) { + if (overrides[k] !== false) overrides[k] = true; + } + } + } + + const flatTree = flattenOutlineTree(outlineRoots, overrides); + /** @type {OutlineSelectable[]} */ + const selectables = flatTree.selectables.map((s) => ({ + ...s, + steerable: s.kind === "agent" ? isSteerableAgent(s.state, live) : false, + })); + + // Legacy phases view kept for tallies / tests that still read model.phases + const phases = phasesForTree.map((p) => { + const loopLabel = p.loopLabel ?? phaseLoopLabel(p.agents); + if (p.kind !== "parallel") { + return { ...p, loopLabel }; + } + let expanded = p.expanded; + const phaseKey = `phase:${p.id}`; + if (Object.prototype.hasOwnProperty.call(overrides, phaseKey)) { + expanded = overrides[phaseKey] === true; + } else if (Object.prototype.hasOwnProperty.call(overrides, p.id)) { + expanded = overrides[p.id] === true; + } + return { ...p, expanded, loopLabel }; + }); + + let selectedIndex = selectables.findIndex((s) => s.key === selectedKey); + if (selectedIndex < 0) { + // Prefer first steerable (running) agent, else any active, else first agent + selectedIndex = selectables.findIndex((s) => s.kind === "agent" && s.steerable); + if (selectedIndex < 0) { + selectedIndex = selectables.findIndex((s) => s.kind === "agent" && isActiveNodeState(s.state)); + } + if (selectedIndex < 0) { + selectedIndex = selectables.findIndex((s) => s.kind === "agent"); + } + if (selectedIndex < 0) selectedIndex = 0; + } + + const selected = selectables[selectedIndex] ?? null; + const tallies = outlineTallies(phases); + const scrollOffset = + typeof input.scrollOffset === "number" && input.scrollOffset > 0 ? Math.floor(input.scrollOffset) : 0; + + // Deterministic digest (no LLM) — same tallies source as overview HUD. + const digestNodes = phasesForTree.flatMap((phase) => + phase.agents.map((agent) => ({ + nodeId: agent.nodeId, + state: agent.state, + lastAttempt: agent.attempt, + iteration: agent.iteration, + })), + ); + const digestLines = formatSupervisorDigestLines({ + runId: String(input.runId ?? ""), + status, + nodes: digestNodes, + queuedSteers: Array.isArray(input.queuedSteers) ? input.queuedSteers : [], + startedAtMs: input.startedAtMs, + nowMs, + lastEventSummary: typeof input.lastEventSummary === "string" ? input.lastEventSummary : undefined, + }); + + /** @type {{ nodeId: string, label: string, lines: Array<{ id: string, kind: string, title: string, status: "running"|"done"|"error"|"info", detail: string, seq: number }>, kind: "agent" | "phase" } | null} */ + let selectedActivity = null; + if (selected) { + const raw = input.selectedActivity; + const rawLines = Array.isArray(raw?.lines) ? raw.lines : []; + const lines = + selected.kind === "agent" + ? rawLines + .map((line, i) => ({ + id: String(line?.id ?? `a-${i}`), + kind: String(line?.kind ?? "action"), + title: String(line?.title ?? line?.kind ?? "action"), + status: + line?.status === "running" || + line?.status === "done" || + line?.status === "error" || + line?.status === "info" + ? line.status + : "info", + detail: typeof line?.detail === "string" ? line.detail : "", + seq: typeof line?.seq === "number" ? line.seq : i, + })) + .slice(0, ACTIVITY_STRIP_LINES) + : []; + selectedActivity = { + nodeId: selected.nodeId || selected.key || "", + label: selected.label || selected.nodeId || selected.key || "selection", + lines, + kind: selected.kind === "phase" ? "phase" : "agent", + }; + } + + return { + runId: input.runId, + workflowName: input.workflowName ?? "", + status, + elapsedLabel: formatElapsed(elapsedMs), + phases, + outlineRoots, + outlineRows: flatTree.rows, + outlineSource, + selectables, + selectedIndex: Math.max(0, selectedIndex), + selected, + selectedActivity, + tallies, + digestLines, + tick: typeof input.tick === "number" ? input.tick : 0, + pollAgeMs: Math.max(0, nowMs - (input.lastPollAtMs ?? nowMs)), + live, + liveElsewhere: input.liveElsewhere === true, + herdrAvailable: input.herdrAvailable === true, + // The read path answering the supervisor: rendered as a small header tag. + sourceKind: input.sourceKind === "gateway" || input.sourceKind === "direct-db" ? input.sourceKind : undefined, + statusBanner: typeof input.statusBanner === "string" ? input.statusBanner : "", + scrollOffset, + // freeScroll: wheel/page keys move the viewport without yanking back to selection + freeScroll: input.freeScroll === true, + footer: + input.footer ?? + (input.herdrAvailable + ? "j/k select · Enter tab · g/G top/end · [ ] runs · q quit" + : "j/k select · Enter · g/G · [ ] runs · q quit"), + }; +} + +/** + * Clamp scroll so selection stays in the body viewport. + * @param {number} scrollOffset + * @param {number} selectedBodyIndex index in body rows (-1 if unknown) + * @param {number} bodyLen + * @param {number} bodyBudget + */ +export function clampScrollToSelection(scrollOffset, selectedBodyIndex, bodyLen, bodyBudget) { + const maxScroll = Math.max(0, bodyLen - bodyBudget); + let s = Math.max(0, Math.min(maxScroll, Math.floor(scrollOffset))); + if (selectedBodyIndex < 0 || bodyBudget <= 0) return s; + if (selectedBodyIndex < s) s = selectedBodyIndex; + if (selectedBodyIndex >= s + bodyBudget) s = selectedBodyIndex - bodyBudget + 1; + return Math.max(0, Math.min(maxScroll, s)); +} + +/** + * Compact run-level status for the supervisor strip (not raw engine enum). + * Display set: running | waiting | finished | failed | stopped + * + * @param {string | null | undefined} status + * @param {boolean} [live] + */ +export function supervisorRunStatus(status, live) { + const s = String(status ?? ""); + if (s === "failed") return "failed"; + if (s === "finished" || s === "succeeded") return "finished"; + if (s === "cancelled" || s === "canceled" || s === "stale" || s === "orphaned") { + return "stopped"; + } + if ( + s === "waiting-approval" || + s === "waiting-event" || + s === "waiting-timer" || + s === "waiting-quota" || + s === "paused" + ) { + return "waiting"; + } + if (s === "running" || s === "continued" || live === true) return "running"; + if (s === "idle" || s === "") return "stopped"; + // Unknown non-terminal → running if live, else stopped + return live ? "running" : "stopped"; +} + +/** + * Short fixed-width state tokens so the state column never reflows. + * @param {string} state + */ +export function shortOutlineState(state) { + /** @type {string} */ + let tok; + switch (String(state ?? "")) { + case "in-progress": + tok = "work"; + break; + case "finished": + tok = "done"; + break; + case "failed": + tok = "fail"; + break; + case "pending": + tok = "pend"; + break; + case "stale": + tok = "stal"; + break; + case "not-reached": + tok = "skip"; + break; + case "waiting-approval": + tok = "gate"; + break; + case "waiting-event": + tok = "wait"; + break; + case "waiting-timer": + tok = "time"; + break; + case "cancelled": + case "canceled": + tok = "canceled"; + break; + case "skipped": + tok = "skip"; + break; + default: + tok = String(state ?? "?"); + } + return tok.slice(0, STATE_W); +} + +/** + * @param {string} tree + * @returns {number} + */ +export function treePlainLen(tree) { + return String(tree ?? "").replace(/\x1b\[[0-9;]*m/g, "").length; +} + +/** + * @param {ReturnType} model + * @param {{ rows: number, cols: number }} size + */ +export function renderCockpitOutlineFrame(model, size) { + const cols = Math.max(40, Math.min(size.cols || 80, 240)); + const rows = Math.max(10, Math.min(size.rows || 24, 200)); + /** @type {string[]} */ + const lines = []; + const push = (s = "") => lines.push(clipLine(sanitizeTerminalText(String(s), { preserveSgr: true }), cols)); + // Full-width rule — exact `cols` dashes (no trailing …). + const hline = () => lines.push(brand.bar("─".repeat(cols))); + + // Title bar: LIVE/IDLE far left · Workflow Supervisor centered · SMITHERS mark right. + const isStale = model.status === "stale" || model.status === "orphaned"; + const liveTag = model.live + ? brand.live("LIVE") + : isStale + ? brand.warn("STALE") + : model.liveElsewhere + ? brand.warn("LIVE elsewhere") + : brand.muted("IDLE"); + // Data-path indicator next to LIVE/IDLE so the operator knows whether reads + // come via the workspace gateway or directly from the local SQLite store. + const sourceTag = + model.sourceKind === "gateway" + ? brand.dim("via gateway") + : model.sourceKind === "direct-db" + ? brand.dim("direct") + : ""; + const leftZone = sourceTag ? `${liveTag} ${sourceTag}` : liveTag; + const centerTitle = brand.title("Workflow Supervisor"); + const rightMark = smithersMark(); + // Do not clipLine the title bar — that can eat the trailing S of SMITHERS. + // Layout into cols-1: many hosts (herdr/iTerm) clip the final cell of a + // full-width line, which was showing as "SMITHER" (missing trailing S). + const titleCols = Math.max(40, cols - 1); + const titleLine = layoutTitleBar(leftZone, centerTitle, rightMark, titleCols); + const titleDw = displayWidth(titleLine); + lines.push(titleDw < cols ? titleLine + " ".repeat(cols - titleDw) : titleLine); + hline(); + + // Single run strip: workflow · id · status · time. + // Prefer full names when the TTY is wide enough; only truncate when needed. + const wfFull = (model.workflowName || "—").replace(/\.(tsx|ts|jsx|js)$/i, ""); + const runIdFull = String(model.runId || ""); + const runStatus = supervisorRunStatus(model.status, model.live); + const stPaint = + runStatus === "running" + ? brand.live + : runStatus === "waiting" + ? brand.warn + : runStatus === "failed" + ? brand.err + : runStatus === "finished" + ? brand.ok + : brand.muted; + const sep = brand.dim(" · "); + const buildRunLine = (wfShow, runShow) => + [ + `${brand.dim("workflow:")} ${brand.title(wfShow)} ${brand.dim(`(${runShow})`)}`, + `${brand.dim("status:")} ${stPaint(runStatus)}`, + `${brand.dim("time:")} ${model.elapsedLabel}`, + ].join(sep); + let runLine = buildRunLine(wfFull, runIdFull); + // Fit into cols-1 (leading space). Truncate run id first, then workflow name. + if (displayWidth(` ${runLine}`) > cols) { + const budget = Math.max(20, cols - 1); + const fit = (wfShow, runShow) => { + const line = buildRunLine(wfShow, runShow); + return displayWidth(` ${line}`) <= budget ? line : null; + }; + let runShow = runIdFull; + let wfShow = wfFull; + // Shrink run id gradually + while (runShow.length > 8 && !fit(wfShow, runShow)) { + runShow = `${runShow.slice(0, Math.max(6, runShow.length - 4))}…`; + } + runLine = fit(wfShow, runShow) ?? buildRunLine(wfShow, runShow); + // Then shrink workflow name + while (wfShow.length > 8 && displayWidth(` ${runLine}`) > budget) { + wfShow = `${wfShow.slice(0, Math.max(6, wfShow.length - 4))}…`; + runLine = buildRunLine(wfShow, runShow); + } + } + push(` ${runLine}`); + if (isStale) { + push(brand.warn(" engine heartbeat lost — not running (cancel or start a new fixture)")); + } + if (model.liveElsewhere) { + push(brand.warn(" ● live run elsewhere — press f to follow")); + } + // Deterministic digest (tallies / active / attention). No fleet strip — + // multi-run switch stays on [ ] / f keys only. + // Shrink on short TTYs so the tree keeps at least a few rows. + const digestPlain = Array.isArray(model.digestLines) + ? model.digestLines.map((l) => String(l)).filter((l) => l.trim() !== "") + : []; + const headerSoFar = lines.length; + const footerGuess = 3; + const minTree = 3; + const digestBudget = Math.max(0, rows - headerSoFar - footerGuess - minTree); + const digestShow = digestPlain.slice(0, Math.min(3, digestBudget)); + for (const dl of digestShow) { + // First line = tallies (slightly brighter); rest dim attention detail. + const isTally = /^\d+ working/.test(dl); + push(` ${isTally ? brand.title(dl) : brand.dim(dl)}`); + } + // Transient toasts (opened tab, errors) go in the footer — never expand the header. + + hline(); + + const selectedKey = model.selected?.key; + // Footer: rule + optional full-width status/error line + key hints. + // Status never shares the keys row (right-side toast was clipped). + const hasStatus = typeof model.statusBanner === "string" && model.statusBanner.trim() !== ""; + const footerBase = hasStatus ? 3 : 2; + const headerUsed = lines.length; + // Activity strip: separator + label + up to N lines. Shrink/hide on short TTYs + // so the workflow tree keeps at least a few rows. + const wantActivity = model.selectedActivity != null; + const freeForBodyAndActivity = Math.max(0, rows - headerUsed - footerBase); + const minBody = Math.min(3, freeForBodyAndActivity); + const activityCap = wantActivity + ? Math.max(0, freeForBodyAndActivity - minBody - 2) // -2 for rule + label + : 0; + const activityLines = Math.min(ACTIVITY_STRIP_LINES, activityCap); + const showActivity = wantActivity && activityLines > 0; + const activityReserve = showActivity ? 1 + 1 + activityLines : 0; + const footerReserve = footerBase + activityReserve; + const bodyBudget = Math.max(0, rows - headerUsed - footerReserve); + + const outlineRows = Array.isArray(model.outlineRows) ? model.outlineRows : null; + const hasTree = (outlineRows && outlineRows.length > 0) || (Array.isArray(model.phases) && model.phases.length > 0); + + // Tree prefix width must fit the deepest nested group (│ │ └─ …), not a + // shallow TREE_BUDGET of 6 — otherwise deep rows shove status/model columns. + /** @param {boolean[]} isLastStack */ + const treePrefixFor = (isLastStack) => { + const stack = Array.isArray(isLastStack) ? isLastStack : [true]; + let tree = ""; + const depth = Math.max(0, stack.length - 1); + for (let d = 0; d < depth; d++) tree += stack[d] ? " " : "│ "; + tree += stack[depth] ? "└─ " : "├─ "; + return tree; + }; + let treeBudget = TREE_BUDGET; + if (outlineRows && outlineRows.length > 0) { + for (const row of outlineRows) { + const t = treePrefixFor(row.isLast); + treeBudget = Math.max(treeBudget, treePlainLen(t)); + } + } + // Cap so name/model still fit on narrow TTYs. + treeBudget = Math.min(treeBudget, Math.max(TREE_BUDGET, Math.floor(cols * 0.35))); + + // caret(1)+sp + treeBudget + nameW + sp + STATUS + sp + ATTEMPTS + sp + BACKEND + sp + model + const fixedChrome = 1 + 1 + treeBudget + 1 + STATE_W + 1 + ATT_W + 1 + BACKEND_W + 1; + // Wide TTYs: give the tree name more room; model keeps the remainder. + const nameW = Math.max(12, Math.min(40, cols - fixedChrome - 18)); + const modelW = Math.max(8, cols - fixedChrome - nameW - 1); + + /** @type {{ text: string, key: string | null }[]} */ + const body = []; + const pushBody = (text, key = null) => body.push({ text, key }); + + // Column legend (subtle, not washed-out) + if (bodyBudget > 4 && hasTree) { + const left = `${" ".repeat(2)}${" ".repeat(treeBudget)}${"workflow tree".padEnd(nameW)}`; + const right = `${"status".padEnd(STATE_W)} ${"attempts".padStart(ATT_W)} ${"backend".padEnd(BACKEND_W)} model`; + pushBody(brand.muted(padVis(`${left} ${right}`, cols))); + } + + if (!hasTree) { + pushBody(brand.dim(" (no nodes yet — waiting for workflow…)")); + } else if (outlineRows && outlineRows.length > 0) { + // Graph / unified tree paint (supports nested parallel groups). + for (const row of outlineRows) { + const n = row.node; + const tree = treePrefixFor(row.isLast); + if (n.kind === "group") { + const expand = n.expanded === false ? "▸" : "▾"; + const childCount = n.children?.length ?? 0; + const name = `${n.label || n.groupType || "group"} ${expand}`; + pushBody( + formatOutlineRow({ + sel: selectedKey === n.key, + tree, + treeBudget, + name, + state: effectiveDisplayState(n.state, model.status, model.live), + attempt: 0, + backend: "", + modelLine: childCount > 0 ? `${childCount} nodes` : "", + nameW, + modelW, + cols, + nameIsPhase: true, + tick: model.tick, + runLive: model.live, + }), + n.key, + ); + } else { + pushBody( + formatOutlineRow({ + sel: selectedKey === n.key || selectedKey === n.nodeId, + tree, + treeBudget, + name: n.label || n.nodeId || n.key, + state: effectiveDisplayState(n.state, model.status, model.live), + attempt: n.attempt ?? 0, + backend: n.backend || "", + modelLine: n.modelLine || n.identity || "", + nameW, + modelW, + cols, + tick: model.tick, + runLive: model.live, + }), + n.key, + ); + } + } + } else { + // Legacy phase paint (should be rare once outlineRows always populated). + const nPhases = model.phases.length; + for (let pi = 0; pi < nPhases; pi++) { + const phase = model.phases[pi]; + const lastPhase = pi === nPhases - 1; + const d0 = lastPhase ? "└─ " : "├─ "; + const cont = lastPhase ? " " : "│ "; + const loopBit = phase.loopLabel ? ` · ${phase.loopLabel}` : ""; + if (phase.kind === "single") { + const a = phase.agents[0]; + if (!a) continue; + pushBody( + formatOutlineRow({ + sel: selectedKey === a.nodeId, + tree: d0, + name: a.displayName || a.nodeId, + state: effectiveDisplayState(a.state, model.status, model.live), + attempt: a.attempt, + backend: a.backend || "", + modelLine: a.modelLine || a.identity || "", + nameW, + modelW, + cols, + tick: model.tick, + runLive: model.live, + }), + a.nodeId, + ); + } else { + const t = phaseTallies(phase.agents); + const phaseKey = `phase:${phase.id}`; + pushBody( + formatOutlineRow({ + sel: selectedKey === phaseKey, + tree: d0, + name: `${phase.title}${loopBit} ${phase.expanded ? "▾" : "▸"}`, + state: effectiveDisplayState(phaseStatus(phase.agents), model.status, model.live), + attempt: 0, + backend: "", + modelLine: `${t.n} agents`, + nameW, + modelW, + cols, + nameIsPhase: true, + tick: model.tick, + runLive: model.live, + }), + phaseKey, + ); + } + } + } + + const selectedBodyIndex = body.findIndex((b) => b.key && b.key === selectedKey); + const maxScroll = Math.max(0, body.length - bodyBudget); + // freeScroll (wheel/page): keep user viewport. Selection moves re-clamp. + const scroll = + model.freeScroll === true + ? Math.max(0, Math.min(maxScroll, Math.floor(model.scrollOffset ?? 0))) + : clampScrollToSelection(model.scrollOffset ?? 0, selectedBodyIndex, body.length, bodyBudget); + const clipped = body.slice(scroll, scroll + bodyBudget); + for (const row of clipped) push(row.text); + while (lines.length < rows - footerReserve) push(""); + while (lines.length > rows - footerReserve) lines.pop(); + + if (showActivity && model.selectedActivity) { + hline(); + const actLabel = model.selectedActivity.label || model.selectedActivity.nodeId || "agent"; + const actHead = brand.muted(` activity · ${actLabel}`); + push(padVis(actHead, cols)); + const actLines = model.selectedActivity.lines ?? []; + const isPhase = model.selectedActivity.kind === "phase"; + for (let i = 0; i < activityLines; i++) { + const line = actLines[i]; + if (!line) { + const empty = i === 0 ? (isPhase ? " · phase · Enter expand/collapse" : " · no recent tools") : ""; + push(padVis(brand.dim(empty), cols)); + continue; + } + const plain = formatActivityPlain(line); + const painted = + line.status === "running" + ? brand.live(` ${plain}`) + : line.status === "done" + ? brand.okDim(` ${plain}`) + : line.status === "error" + ? brand.err(` ${plain}`) + : brand.dim(` ${plain}`); + push(padVis(painted, cols)); + } + } + + hline(); + const selLabel = model.selected?.label || model.selected?.nodeId || ""; + const steerable = model.selected?.steerable === true; + const scrollHint = + body.length > bodyBudget + ? brand.dim(`${scroll + 1}–${Math.min(body.length, scroll + bodyBudget)}/${body.length}`) + : ""; + const hint = + model.selected?.kind === "agent" && model.selected.nodeId + ? model.herdrAvailable + ? steerable + ? `Enter → steer ${selLabel}` + : `Enter → inspect ${selLabel}` + : steerable + ? `Enter → ${selLabel}` + : `Enter → inspect ${selLabel}` + : model.selected?.kind === "phase" + ? "Enter expand/collapse" + : ""; + // Full-width status/error line ABOVE key guidance (never clipped on the right). + if (hasStatus) { + const raw = String(model.statusBanner).replace(/\s+/g, " ").trim(); + const isErr = /fail|error|not found|unknown flag/i.test(raw); + const painted = isErr ? brand.warn(` ${raw}`) : brand.live(` ${raw}`); + push(padVis(painted, cols)); + } + const footLeft = [brand.dim(model.footer), hint ? brand.dim(hint) : "", scrollHint] + .filter(Boolean) + .join(brand.dim(" · ")); + push(padVis(` ${footLeft}`, cols)); + + while (lines.length < rows) lines.push(clipLine("", cols)); + // Attach scroll meta for the controller (non-enumerable-safe via return object path) + const out = lines.slice(0, rows); + /** @type {any} */ + const tagged = out; + tagged.scrollOffset = scroll; + tagged.bodyLen = body.length; + tagged.bodyBudget = bodyBudget; + tagged.selectedBodyIndex = selectedBodyIndex; + // Row map for mouse click → body index (legend rows have key null). + tagged.bodyKeys = body.map((b) => b.key); + tagged.headerRows = headerUsed; + return out; +} + +/** + * Outline row — pure tree left, fixed columns right: + * [caret][ ][tree……][name…………][ ][status][ ][attempts][ ][backend][ ][model……] + * + * `treeBudget` pads every tree prefix to the same width so nested rows do not + * shift the status/attempts/backend/model columns. + * + * @param {{ + * sel: boolean, + * tree: string, + * treeBudget?: number, + * name: string, + * state: string, + * attempt: number, + * backend?: string, + * modelLine?: string, + * identity?: string, + * nameW: number, + * modelW?: number, + * cols: number, + * nameIsPhase?: boolean, + * tick?: number, + * runLive?: boolean, + * legend?: boolean, + * }} opts + */ +function formatOutlineRow(opts) { + const st = opts.state === "" ? "" : String(opts.state); + const caret = opts.sel ? brand.pick("▸") : " "; + const treeBudget = typeof opts.treeBudget === "number" && opts.treeBudget > 0 ? opts.treeBudget : TREE_BUDGET; + const treeRaw = String(opts.tree ?? ""); + const tLen = treePlainLen(treeRaw); + // Keep glyphs tight against the name (no gap after ├─). Absorb leftover + // tree-budget into the name field so status/attempts/model stay column-locked. + const tree = brand.dim(treeRaw); + const modelW = typeof opts.modelW === "number" ? opts.modelW : 24; + const nameFieldW = Math.max(4, opts.nameW + (treeBudget - Math.min(treeBudget, tLen))); + const rawName = opts.name; + const namePlain = + rawName.length > nameFieldW ? `${rawName.slice(0, Math.max(0, nameFieldW - 1))}…` : rawName.padEnd(nameFieldW); + + let name; + if (opts.legend) { + name = brand.dim(namePlain); + } else if (opts.sel) { + // Selection: bold gold (never cyan — cyan = LIVE activity). + name = brand.pick(namePlain); + } else if (opts.nameIsPhase) { + name = + st === "in-progress" + ? brand.hot(namePlain) // working phase: bold white + : isFailedState(st) + ? brand.err(namePlain) + : brand.dim(namePlain); + } else if (st === "in-progress") { + // Working agent: bold default (white/fg) — activity, not selection. + name = brand.hot(namePlain); + } else if (isFailedState(st)) { + name = brand.err(namePlain); + } else if (isDoneState(st)) { + name = brand.dim(namePlain); + } else { + name = brand.dim(namePlain); + } + + // Status column: live work uses spinner here (not in the tree). + let stTok; + if (st === "") { + stTok = "".padEnd(STATE_W); + } else if (st === "in-progress" && opts.runLive) { + const spin = SPIN[Math.abs(opts.tick ?? 0) % SPIN.length]; + stTok = spin.padEnd(STATE_W); + } else { + stTok = shortOutlineState(st).padEnd(STATE_W); + } + // Status: full semantic color (readable at full brightness — not dim grey). + const stc = + st === "" + ? stTok + : st === "in-progress" + ? brand.live(stTok) + : st === "stale" || st === "not-reached" + ? brand.warn(stTok) + : isFailedState(st) + ? brand.err(stTok) + : isDoneState(st) + ? brand.ok(stTok) + : stTok; + + // Attempts: right-aligned under "attempts" header. + const attRaw = opts.attempt > 0 ? String(opts.attempt) : "·"; + const att = attRaw.padStart(ATT_W).slice(-ATT_W); + + // Backend (harness) column — separate from model + effort. + let backendRaw = typeof opts.backend === "string" ? opts.backend : typeof opts.identity === "string" ? "" : ""; + if (backendRaw.length > BACKEND_W) { + backendRaw = `${backendRaw.slice(0, Math.max(0, BACKEND_W - 1))}…`; + } + const backendCol = backendRaw.padEnd(BACKEND_W); + + // Model column: model id + effort (e.g. "claude-sonnet-5 xhigh"). + let modelRaw = + typeof opts.modelLine === "string" && opts.modelLine !== "" + ? opts.modelLine + : typeof opts.identity === "string" + ? opts.identity + : ""; + if (modelRaw.length > modelW) { + modelRaw = `${modelRaw.slice(0, Math.max(0, modelW - 1))}…`; + } + + const core = + backendRaw || modelRaw + ? `${caret} ${tree}${name} ${stc} ${att} ${backendCol} ${modelRaw}` + : `${caret} ${tree}${name} ${stc} ${att}`; + return padVis(core, opts.cols); +} + +/** + * Toggle expand on a parallel phase by phase id. + * @param {OutlinePhase[]} phases + * @param {string} phaseId + */ +export function togglePhaseExpanded(phases, phaseId) { + return phases.map((p) => (p.id === phaseId && p.kind === "parallel" ? { ...p, expanded: !p.expanded } : p)); +} + +/** + * Create a live outline HUD controller. + * @param {{ stdout?: NodeJS.WriteStream, useAltScreen?: boolean, rows?: number, cols?: number }} [opts] + */ +export function createCockpitOutlineHud(opts = {}) { + const stdout = opts.stdout ?? process.stdout; + const useAltScreen = opts.useAltScreen !== undefined ? opts.useAltScreen : Boolean(stdout.isTTY); + let entered = false; + /** @type {ReturnType | null} */ + let lastModel = null; + /** @type {{ scrollOffset: number, bodyLen: number, bodyBudget: number, selectedBodyIndex: number, bodyKeys: (string|null)[], headerRows: number }} */ + let lastLayout = { + scrollOffset: 0, + bodyLen: 0, + bodyBudget: 1, + selectedBodyIndex: -1, + bodyKeys: [], + headerRows: 0, + }; + /** @type {string[] | null} */ + let lastFrame = null; + let lastCols = 0; + let lastRows = 0; + + function size() { + return { + rows: Math.max(10, opts.rows || stdout.rows || Number(process.env.LINES) || 24), + cols: Math.max(40, opts.cols || stdout.columns || Number(process.env.COLUMNS) || 80), + }; + } + + /** + * Write frame with minimal flicker: only rewrite changed lines when size is stable. + * @param {string[]} frame + * @param {{ rows: number, cols: number }} sz + */ + function writeFrame(frame, sz) { + const sameSize = lastFrame && lastFrame.length === frame.length && lastCols === sz.cols && lastRows === sz.rows; + if (!sameSize) { + stdout.write(CLEAR_HOME + frame.join("\n")); + } else { + // Synchronized update (no-op on terminals that ignore it) + line diffs. + let out = `${ESC}[?2026h`; + let dirty = 0; + for (let i = 0; i < frame.length; i++) { + if (frame[i] !== lastFrame[i]) { + // row is 1-based; clear to EOL so shorter lines do not leave ghosts + out += `${ESC}[${i + 1};1H${frame[i]}${ESC}[K`; + dirty += 1; + } + } + out += `${ESC}[?2026l`; + if (dirty > 0) stdout.write(out); + } + lastFrame = frame.slice(); + lastCols = sz.cols; + lastRows = sz.rows; + } + + function paint() { + if (!entered || !lastModel) return; + const sz = size(); + const frame = renderCockpitOutlineFrame(lastModel, sz); + /** @type {any} */ + const meta = frame; + lastLayout = { + scrollOffset: typeof meta.scrollOffset === "number" ? meta.scrollOffset : 0, + bodyLen: typeof meta.bodyLen === "number" ? meta.bodyLen : 0, + bodyBudget: typeof meta.bodyBudget === "number" ? meta.bodyBudget : 1, + selectedBodyIndex: typeof meta.selectedBodyIndex === "number" ? meta.selectedBodyIndex : -1, + bodyKeys: Array.isArray(meta.bodyKeys) ? meta.bodyKeys : [], + headerRows: typeof meta.headerRows === "number" ? meta.headerRows : 0, + }; + writeFrame(/** @type {string[]} */ (frame), sz); + } + + function enter() { + if (entered) return; + entered = true; + lastFrame = null; + if (useAltScreen) stdout.write(`${ESC}[?1049h`); + stdout.write(`${ESC}[?25l${CLEAR_HOME}`); + if (typeof stdout.on === "function") stdout.on("resize", paint); + } + + function exit() { + if (!entered) return; + entered = false; + lastFrame = null; + if (typeof stdout.off === "function") stdout.off("resize", paint); + else stdout.removeListener?.("resize", paint); + stdout.write(`${ESC}[?25h`); + if (useAltScreen) stdout.write(`${ESC}[?1049l`); + else stdout.write("\n"); + } + + /** @param {Parameters[0]} partial */ + function update(partial) { + lastModel = buildCockpitOutlineModel(partial); + paint(); + } + + return { + enter, + exit, + update, + paint, + get model() { + return lastModel; + }, + get layout() { + return lastLayout; + }, + }; +} diff --git a/apps/cli/src/event-categories.js b/apps/cli/src/event-categories.js index c83681cc68..f7fafcf942 100644 --- a/apps/cli/src/event-categories.js +++ b/apps/cli/src/event-categories.js @@ -54,6 +54,9 @@ const EVENT_CATEGORY_BY_TYPE = { ApprovalGranted: "approval", ApprovalAutoApproved: "approval", ApprovalDenied: "approval", + SteerQueued: "steer", + SteerConsumed: "steer", + SteerExpired: "steer", ToolCallStarted: "tool-call", ToolCallFinished: "tool-call", NodeOutput: "output", @@ -98,6 +101,7 @@ const CATEGORY_ALIASES = { frame: "frame", memory: "memory", node: "node", + steer: "steer", openapi: "openapi", output: "output", revert: "revert", @@ -127,6 +131,7 @@ const EVENT_TYPES_BY_CATEGORY = Object.entries(EVENT_CATEGORY_BY_TYPE).reduce( frame: [], memory: [], node: [], + steer: [], openapi: [], output: [], revert: [], diff --git a/apps/cli/src/find-db.js b/apps/cli/src/find-db.js index 311614a336..c43a794764 100644 --- a/apps/cli/src/find-db.js +++ b/apps/cli/src/find-db.js @@ -55,7 +55,8 @@ export function findSmithersDb(from, markerChecks = realDbMarkerChecks) { if (allCandidates.length === 0) { throw new SmithersError( "CLI_DB_NOT_FOUND", - "No smithers.db found. Run this command from a directory containing a smithers.db, or use 'smithers up ' to start a run first.", + `No smithers workspace found from ${startDir}; pass --db or run 'smithers up ' from a Smithers workspace to create smithers.db.`, + { cwd: startDir }, ); } diff --git a/apps/cli/src/format.js b/apps/cli/src/format.js index 4e42daec0d..c27b49b88d 100644 --- a/apps/cli/src/format.js +++ b/apps/cli/src/format.js @@ -3,6 +3,7 @@ // @smithers-type-exports-end import pc from "picocolors"; +import { sanitizeTerminalText } from "@smthrs/tui/src/sanitizeTerminalText.ts"; import { eventCategoryForType } from "./event-categories.js"; /** * Format a timestamp as relative age: "2m ago", "1h ago", "3d ago" @@ -259,6 +260,12 @@ export function formatEventLine(event, baseMs, options) { return `${prefix}✓ Auto-approved: ${payload?.nodeId ?? "?"}`; case "ApprovalDenied": return `${prefix}✗ Denied: ${payload?.nodeId ?? "?"}`; + case "SteerQueued": + return `${prefix}↪ steer queued: ${truncateText(sanitizeTerminalText(String(payload?.message ?? "")), 100)}`.trim(); + case "SteerConsumed": + return `${prefix}✓ steer consumed by attempt ${payload?.attempt ?? 1}`; + case "SteerExpired": + return `${prefix}✗ steer expired — node finished first; press h to hijack`; case "ToolCallStarted": return `${prefix}🔧 ${payload?.nodeId ?? "?"} → ${payload?.toolName ?? "tool"} (attempt ${payload?.attempt ?? 1})`; case "ToolCallFinished": diff --git a/apps/cli/src/herdr.js b/apps/cli/src/herdr.js new file mode 100644 index 0000000000..b476b91285 --- /dev/null +++ b/apps/cli/src/herdr.js @@ -0,0 +1,1157 @@ +import { + createHerdrClient, + createHerdrRunSurface, + HERDR_PROTOCOL, + HERDR_SURFACE_EVENT_TYPES, + launchHijackPane, + openTabPane, + sessionAttachHint, + shortNodeId, + stripOutcomeMarker, + stubWorkspaceLabel, +} from "@smthrs/herdr"; +import { computeRunStateFromRow } from "@smthrs/db/runState"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { deriveTailStatus, isTailActiveState } from "./tail.js"; + +/** + * CLI wiring for the optional herdr mirror plane (`smithers up --herdr`, + * `smithers herdr attach`, `smithers herdr status`). Everything here is + * fire-and-forget and degradable: an absent or broken herdr never fails, blocks, + * or slows a run. The run surface itself lives in `@smthrs/herdr`; + * this module supplies the CLI-specific bits (option parsing, the deterministic + * workspace label, the real `smithers tail` pane command, the agent-node filter, + * and the DB-poll follow loop that feeds an attached surface). + */ + +/** DB poll cadence for the attach follow loop (mirrors the tail/logs interval). */ +const HERDR_FOLLOW_POLL_INTERVAL_MS = 500; + +/** Page size for draining new events into an attached surface. */ +const HERDR_EVENT_PAGE_SIZE = 500; + +/** @param {string} value */ +function quotePosixShellArgument(value) { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +/** + * Close the herdr detail tab/pane this process is running in (soft). + * herdr injects HERDR_TAB_ID / HERDR_PANE_ID into agent panes; without an + * explicit close, `q` exits the process and leaves a grey dead tab behind. + * + * Prefer tab.close (one full-size pane per detail tab). Never throws. + * + * @param {NodeJS.ProcessEnv} [env] + * @param {import("@smthrs/herdr").HerdrClient} [injectedClient] + * @returns {Promise} + */ +export async function closeCurrentHerdrDetail(env = process.env, injectedClient) { + if (env.HERDR_ENV !== "1") return; + const tabId = typeof env.HERDR_TAB_ID === "string" && env.HERDR_TAB_ID !== "" ? env.HERDR_TAB_ID : undefined; + const paneId = typeof env.HERDR_PANE_ID === "string" && env.HERDR_PANE_ID !== "" ? env.HERDR_PANE_ID : undefined; + if (!tabId && !paneId) return; + try { + const client = injectedClient ?? createHerdrClient({ logger: () => {} }); + const compatibility = await probeCompatibleHerdr(client); + if (!compatibility.available) return; + if (tabId) { + await client.tryCall("tab.close", { tab_id: tabId }); + return; + } + if (paneId) { + await client.tryCall("pane.close", { pane_id: paneId }); + } + } catch { + /* soft — never block detail exit */ + } +} + +/** + * Strict compatibility probe shared by every CLI path that may issue a Herdr + * mutation. A protocol mismatch is intentionally distinct from an unreachable + * socket so explicit commands can return a structured mismatch while optional + * features retain their soft degradation contract. + * + * @param {import("@smthrs/herdr").HerdrClient} client + * @returns {Promise< + * | { available: true; pong: import("@smthrs/herdr").HerdrPong } + * | { available: false; reason: "unavailable" | "protocol_mismatch"; pong?: import("@smthrs/herdr").HerdrPong; error?: unknown } + * >} + */ +export async function probeCompatibleHerdr(client) { + try { + const pong = await client.ping({ requireProtocolMatch: true }); + if (!pong) { + return { available: false, reason: "unavailable" }; + } + // Keep the helper safe for injected/older clients that accept but ignore + // the strict option and still return an inspectable mismatched pong. + if (pong.protocol !== HERDR_PROTOCOL) { + return { + available: false, + reason: "protocol_mismatch", + pong, + error: new Error(`herdr protocol mismatch: client expects ${HERDR_PROTOCOL}, server reports ${pong.protocol}`), + }; + } + return { available: true, pong }; + } catch (error) { + const candidate = /** @type {{ code?: unknown; cause?: unknown }} */ (error); + if (candidate?.code === "protocol_mismatch") { + const pong = + candidate.cause && typeof candidate.cause === "object" + ? /** @type {import("@smthrs/herdr").HerdrPong} */ (candidate.cause) + : undefined; + return { available: false, reason: "protocol_mismatch", pong, error }; + } + return { available: false, reason: "unavailable", error }; + } +} + +/** @param {Awaited>} compatibility */ +function protocolMismatchDetail(compatibility) { + if (compatibility.available || compatibility.reason !== "protocol_mismatch") return undefined; + return compatibility.error instanceof Error ? compatibility.error.message : "herdr protocol mismatch"; +} + +// The event types the surface maps to a pane action (`HERDR_SURFACE_EVENT_TYPES`) +// are imported from `@smthrs/herdr` — the surface owns that list, +// so the follow loop can pre-filter rows against the SAME set the surface's +// `onEvent` switch handles (skipping every other row, chiefly the high-volume +// `NodeOutput` stream, BEFORE its `payloadJson` is parsed) with zero drift risk. + +/** + * @param {number} ms + * @returns {Promise} + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Resolve whether (and into which session) this run should mirror into herdr. + * The `--herdr` flag wins; otherwise the `SMITHERS_HERDR` env var is honored so a + * detached `-d` child (which inherits the parent's env) activates the mirror in + * its own process. Returns `undefined` (no mirror), `true` (default session), or + * a session name string. + * + * @param {boolean | string | undefined} flagValue the parsed `--herdr` option + * @param {NodeJS.ProcessEnv} [env] + * @returns {true | string | undefined} + */ +export function resolveHerdrOption(flagValue, env = process.env) { + if (flagValue === false || flagValue === "false") { + return undefined; + } + if (flagValue !== undefined) { + if (typeof flagValue === "string") { + // `--herdr` (bare) is rewritten to `--herdr=true` in argv preprocessing + // (rewriteBareHerdrFlagArgv) so it doesn't swallow the next flag; treat + // the boolean-ish strings as the default session, not a session named + // "true"/"false". + if (flagValue === "" || flagValue === "true") { + return true; + } + return flagValue; + } + return true; + } + const envVal = env?.SMITHERS_HERDR; + if (typeof envVal === "string" && envVal !== "" && envVal !== "0") { + return envVal === "1" || envVal === "true" ? true : envVal; + } + return undefined; +} + +/** + * The session name carried by a resolved herdr option, or `undefined` for the + * default session. + * + * @param {true | string | undefined} option + * @returns {string | undefined} + */ +export function herdrSessionOf(option) { + return typeof option === "string" ? option : undefined; +} + +/** + * The deterministic herdr workspace label for a run. This is the find-or-create + * key, so `up --herdr` and `herdr attach` MUST derive it identically. The + * versioned, encoded suffix is the ownership marker used before Smithers adopts + * or destroys a workspace; ordinary multi-word Herdr labels never qualify. + * + * @param {string} workflowId + * @param {string} runId + * @returns {string} + */ +export function herdrWorkspaceLabel(workflowId, runId) { + return `${workflowId} [smithers:v1:${encodeURIComponent(runId)}]`; +} + +/** + * The inverse of {@link herdrWorkspaceLabel}: pull the run id back out of a herdr + * workspace label, tolerating the terminal-state outcome marker prefix + * (`✓`/`✗`/`◻`) the surface prepends on finish. Only the canonical, versioned + * Smithers ownership marker is accepted. Callers performing destructive work + * must additionally compare the complete label with the identity reconstructed + * from the run row. + * + * @param {string} label + * @returns {string | undefined} + */ +export function herdrRunIdFromWorkspaceLabel(label) { + if (typeof label !== "string" || label === "") { + return undefined; + } + const base = stripOutcomeMarker(label); + const marker = " [smithers:v1:"; + const markerStart = base.lastIndexOf(marker); + if (markerStart <= 0 || !base.endsWith("]")) { + return undefined; + } + const encodedRunId = base.slice(markerStart + marker.length, -1); + if (encodedRunId === "") { + return undefined; + } + try { + const runId = decodeURIComponent(encodedRunId); + const workflowId = base.slice(0, markerStart); + return runId !== "" && herdrWorkspaceLabel(workflowId, runId) === base ? runId : undefined; + } catch { + return undefined; + } +} + +/** + * A herdr logger that writes soft-failure warnings to stderr (never stdout, so + * command output stays clean). In a detached `-d` child, stderr is the detach log + * file, so mirror warnings land there deliberately. + * + * @returns {import("@smthrs/herdr").HerdrLogger} + */ +export function makeHerdrStderrLogger() { + return (level, message, data) => { + if (level !== "warn") { + return; + } + let line = `[herdr] ${message}`; + if (data !== undefined) { + try { + line += ` ${typeof data === "string" ? data : JSON.stringify(data)}`; + } catch { + // non-serializable detail: drop it, keep the message + } + } + process.stderr.write(`${line}\n`); + }; +} + +/** + * The argv a herdr tail pane runs. Built from the CLI's own invocation (the same + * `bun ` mechanics as the detached-run spawn), so it works in a dev + * checkout — the default `["smithers", "tail", ...]` only resolves for a global + * install. Uses the absolute interpreter + entry path so it is independent of the + * herdr pane's PATH; the pane inherits the workspace cwd (where the run's DB is) + * so `smithers tail` finds the store. + * + * The pane passes `--linger` so it stays open on a terminal run state (the human + * can come back and read what happened) instead of exiting the instant the run + * finishes and letting herdr tear the pane down. This mirrors the surface's + * default tail command; a plain interactive `smithers tail` keeps the default + * exit-on-terminal behavior. + * + * @param {string} cliPath absolute path to the CLI entry (this process's entry) + * @returns {(ctx: { runId: string, nodeId: string }) => string[]} + */ +/** + * @param {string} cliPath absolute path to the CLI entry (this process's entry) + * @param {{ dbPath?: string }} [opts] optional absolute smithers.db path for detail panes + * @returns {(ctx: { runId: string, nodeId: string }) => string[]} + */ +export function buildTailCommand(cliPath, opts = {}) { + // Prefer the thin node-detail entry (fast first paint for supervisor Enter). + // Full `index.js tail` cold-starts ~1.2s; thin entry avoids the whole CLI surface. + const thinEntry = join(dirname(cliPath), "node-detail-entry.js"); + const dbPath = typeof opts.dbPath === "string" && opts.dbPath.endsWith("smithers.db") ? opts.dbPath : undefined; + if (existsSync(thinEntry)) { + return (ctx) => { + /** @type {string[]} */ + const argv = [process.execPath, thinEntry, ctx.runId, "--node", ctx.nodeId, "--linger"]; + // Pin the store the supervisor is reading so mid-run opens cannot + // resolve a different/empty smithers.db via cwd walk. + if (dbPath) argv.push("--db", dbPath); + return argv; + }; + } + + // Fallback: full CLI tail + HUD dock (s steer · h hijack · q). + // Note: `smithers tail` has no `--db` flag — relies on cwd discovery. + return (ctx) => [process.execPath, cliPath, "tail", ctx.runId, "--node", ctx.nodeId, "--hud", "--linger"]; +} + +/** + * The argv a herdr APPROVAL GATE pane runs: the interactive `approve --watch` + * loop, scoped to the gate's node, so the human answers the gate (and any human + * request on that node) directly in the pane instead of just reading a tail. Built + * from this process's own interpreter + entry path (same mechanics as + * {@link buildTailCommand}) so it resolves in a dev checkout. The pane inherits the + * workspace cwd (where the run's DB is) so the watch loop finds the store. The + * watch loop lingers on a terminal run state itself, so no `--linger` is needed. + * + * @param {string} cliPath absolute path to the CLI entry (this process's entry) + * @returns {(ctx: { runId: string, nodeId: string }) => string[]} + */ +export function buildGateCommand(cliPath) { + return (ctx) => [process.execPath, cliPath, "approve", ctx.runId, "--watch", "--node", ctx.nodeId]; +} + +/** + * Long-lived workflow supervisor for the herdr cockpit right pane. + * Uses `smithers supervisor` (not per-run `tail`) so the pane is a single process + * that discovers runs in the workspace DB as they appear. Prefer absolute `--db` + * so the pane does not depend on herdr's cwd / findSmithersDb walk. + * + * @param {string} cliPath absolute path to the CLI entry (this process's entry) + * @param {{ dbPath?: string, cwd?: string }} [opts] + * @returns {(ctx: { runId: string }) => string[]} + */ +export function buildOverviewCommand(cliPath, opts = {}) { + /** @type {string[]} */ + const argv = [process.execPath, cliPath, "supervisor"]; + if (typeof opts.dbPath === "string" && opts.dbPath !== "") { + argv.push("--db", opts.dbPath); + } + if (typeof opts.cwd === "string" && opts.cwd !== "") { + argv.push("--cwd", opts.cwd); + } + return (_ctx) => [...argv]; +} + +/** + * @param {string | null | undefined} metaJson + * @returns {Record} + */ +function parseMetaJson(metaJson) { + if (typeof metaJson !== "string" || metaJson === "") { + return {}; + } + try { + const parsed = JSON.parse(metaJson); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Build the surface `nodeFilter`: mirror only nodes whose attempt was recorded + * with `metaJson.kind === "agent"` (compute/static nodes get no pane). The + * discriminator is race-free — the engine commits the attempt row (with `kind`) + * before it emits `NodeStarted` — so the row is present by the time the surface + * asks. Cached per nodeId. Soft: a transient DB error resolves to `undefined` + * (the surface's "unknown" channel), NOT `false`, so the read is retried on the + * node's NEXT event instead of freezing the node as "not an agent" forever + * (a cached `false` would permanently suppress the node's pane — the surface + * memoizes a boolean decision but re-asks on `undefined`). Only a decision + * derived from a successful read is memoized here. + * + * @param {any} adapter SmithersDb adapter (read-only) + * @param {string} runId + * @returns {(ctx: { runId: string, nodeId: string }) => Promise} + */ +export function buildAgentNodeFilter(adapter, runId) { + /** @type {Map} */ + const cache = new Map(); + return async ({ nodeId, iteration, attempt }) => { + const cached = cache.get(nodeId); + if (cached !== undefined) { + return cached; + } + try { + const row = + Number.isInteger(iteration) && Number.isInteger(attempt) + ? await adapter.getAttempt(runId, nodeId, iteration, attempt) + : ((await adapter.listAttemptsForRun(runId)) ?? []).find((a) => a && a.nodeId === nodeId); + const isAgent = parseMetaJson(row?.metaJson).kind === "agent"; + // Only memoize a decision derived from a successful read. + cache.set(nodeId, isAgent); + return isAgent; + } catch { + // Transient DB error → the surface's "unknown" channel: return undefined + // (never cached) so the node's next event re-asks, instead of a sticky + // `false` that would suppress the pane for the rest of the run. + return undefined; + } + }; +} + +/** + * Whether a workflow entry file is marked `// smithers-system: true` (or + * frontmatter `system: true`). Used to suppress env-inherited herdr mirroring for + * internal plumbing (post-failure, init, …). Soft: unreadable files → false. + * + * @param {string | undefined} workflowPath + * @returns {boolean} + */ +export function isSystemWorkflowSource(workflowPath) { + if (typeof workflowPath !== "string" || workflowPath === "") { + return false; + } + try { + const head = readFileSync(workflowPath, "utf8").slice(0, 4000); + if (/^\s*\/\/\s*smithers-system\s*:\s*true\s*$/im.test(head)) { + return true; + } + const frontmatter = head.match(/^\uFEFF?---\s*\n([\s\S]*?)\n---(?:\n|$)/); + if (frontmatter && /^\s*system\s*:\s*true\s*$/im.test(frontmatter[1])) { + return true; + } + return false; + } catch { + return false; + } +} + +/** + * Normalize declarative / CLI herdr cockpit options (workflow.opts.herdr). + * + * @param {Record | undefined | null} raw + * @returns {{ + * pin?: string[]; + * softPinSlots?: number; + * tabCap?: number; + * autoOpen?: { stage?: boolean; workers?: boolean; gates?: boolean; failures?: boolean }; + * sessionName?: string; + * surface?: string; + * }} + */ +export function normalizeHerdrCockpitOpts(raw) { + if (!raw || typeof raw !== "object") { + return {}; + } + /** @type {ReturnType} */ + const out = {}; + if (Array.isArray(raw.pin)) { + out.pin = raw.pin.filter((p) => typeof p === "string"); + } + if (typeof raw.softPinSlots === "number" && Number.isFinite(raw.softPinSlots)) { + out.softPinSlots = raw.softPinSlots; + } + if (typeof raw.tabCap === "number" && Number.isFinite(raw.tabCap) && raw.tabCap > 0) { + out.tabCap = Math.floor(raw.tabCap); + } + if (raw.autoOpen && typeof raw.autoOpen === "object") { + out.autoOpen = /** @type {any} */ (raw.autoOpen); + } + if (typeof raw.sessionName === "string" && raw.sessionName !== "") { + out.sessionName = raw.sessionName; + } + if (typeof raw.surface === "string" && raw.surface !== "") { + out.surface = raw.surface; + } + if (raw.chrome === "split" || raw.chrome === "tabs" || raw.chrome === "auto") { + out.chrome = raw.chrome; + } + if (raw.harnessCommand !== undefined) { + out.harnessCommand = /** @type {any} */ (raw.harnessCommand); + } + if (typeof raw.dock === "boolean") { + out.dock = raw.dock; + } + return out; +} + +/** + * Build a herdr run surface for `smithers up --herdr`, wired from the CLI's + * onProgress seam. Probes the server first with a SILENT client so an absent + * herdr warns exactly once (this function's line) and returns `null` — the caller + * then runs normally with no mirror. When the server is reachable, the surface + * carries the deterministic label, real pane commands (`smithers supervisor` + * for the workflow supervisor board, `tail --node` for detail), an agent-only + * node filter, cockpit soft-pin policy, and a stderr logger. Never closes the + * workspace on finish (left for humans). + * + * The right pane of the herdr **cockpit** tab runs + * `smithers supervisor --db ` (workflow supervisor). + * + * @param {{ + * session: string | undefined; + * label: string; + * cwd?: string; + * /** Absolute path to smithers.db when known (passed to `supervisor --db`). *\/ + * dbPath?: string; + * adapter: any; + * runId: string; + * cliPath: string; + * logger?: import("@smthrs/herdr").HerdrLogger; + * cockpit?: ReturnType; + * client?: import("@smthrs/herdr").HerdrClient; + * }} params + * @returns {Promise} + */ +export async function createUpHerdrSurface(params) { + const log = params.logger ?? makeHerdrStderrLogger(); + const probe = params.client ?? createHerdrClient({ session: params.session, logger: () => {} }); + const compatibility = await probeCompatibleHerdr(probe); + if (!compatibility.available) { + const mismatch = protocolMismatchDetail(compatibility); + log( + "warn", + mismatch + ? `${mismatch}; running without the herdr mirror` + : `--herdr requested but no herdr server is reachable at ${probe.socketPath}; running without the herdr mirror`, + ); + return null; + } + const cockpit = params.cockpit ?? {}; + // Product default: auto chrome + auto harness (dock when HERDR_ENV=1, else + // spawn grok/claude/… on the left when available; overview on the right). + // Resolve dock to a boolean here so multi-run up --herdr never silently + // steals the focused workspace (was: dock:true by default). + const env = process.env; + const envWantsDock = env.HERDR_ENV === "1" || env.SMITHERS_HERDR_DOCK === "1" || env.SMITHERS_HERDR_DOCK === "true"; + const dock = cockpit.dock === true ? true : cockpit.dock === false ? false : envWantsDock; + const chrome = cockpit.chrome ?? "auto"; + const harnessCommand = cockpit.harnessCommand !== undefined ? cockpit.harnessCommand : "auto"; + return createHerdrRunSurface({ + client: probe, + session: params.session, + workspaceLabel: params.label, + cwd: params.cwd, + logger: log, + // Pin --db on detail/tail panes so herdr tabs never re-discover (or + // scaffold) a store from the tab's cwd. + tailCommand: buildTailCommand(params.cliPath, { dbPath: params.dbPath }), + gateCommand: buildGateCommand(params.cliPath), + overviewCommand: buildOverviewCommand(params.cliPath, { + dbPath: params.dbPath, + cwd: params.cwd, + }), + nodeFilter: buildAgentNodeFilter(params.adapter, params.runId), + closeWorkspaceOnFinish: false, + pin: cockpit.pin, + softPinSlots: cockpit.softPinSlots, + tabCap: cockpit.tabCap, + autoOpen: cockpit.autoOpen, + chrome, + harnessCommand, + dock, + }); +} + +/** + * Best-effort daily-session stub when using session-per-run: a pointer workspace + * in the default session so the operator is not blind. Soft-fails entirely. + * + * @param {{ + * workflowId: string; + * runId: string; + * sessionName: string; + * cwd?: string; + * logger?: import("@smthrs/herdr").HerdrLogger; + * client?: import("@smthrs/herdr").HerdrClient; + * }} params + * @returns {Promise} + */ +export async function ensureSessionStubWorkspace(params) { + const log = params.logger ?? makeHerdrStderrLogger(); + try { + const client = params.client ?? createHerdrClient({ logger: () => {} }); + const compatibility = await probeCompatibleHerdr(client); + if (!compatibility.available) { + const mismatch = protocolMismatchDetail(compatibility); + if (mismatch) { + log("warn", `${mismatch}; skipping the default-session mirror stub`); + } + return; + } + const label = stubWorkspaceLabel(params.workflowId, params.runId, params.sessionName); + const list = /** @type {{ workspaces?: any[] } | undefined} */ (await client.tryCall("workspace.list", {})); + const exists = list && Array.isArray(list.workspaces) && list.workspaces.some((w) => w && w.label === label); + if (exists) { + return; + } + const created = /** @type {{ root_pane?: { pane_id?: string } } | undefined} */ ( + await client.tryCall("workspace.create", { + label, + focus: false, + cwd: params.cwd, + }) + ); + const paneId = created?.root_pane?.pane_id; + if (typeof paneId === "string") { + const hint = sessionAttachHint({ + sessionName: params.sessionName, + runId: params.runId, + }); + await client.tryCall("pane.send_text", { + pane_id: paneId, + text: `printf '%s\\n' ${quotePosixShellArgument(hint)}\n`, + }); + } + } catch (err) { + log("warn", `herdr session stub failed (soft): ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** + * Reconstruct the surface event for a persisted event row. `payloadJson` is the + * full serialized `SmithersEvent` (the engine stores `JSON.stringify(event)`), so + * parsing it yields every field the surface reads (`error`, `event`, `request`, + * `failedChildren`, ...). Row columns are overlaid defensively. + * + * @param {any} row + * @returns {import("@smthrs/herdr").SmithersEventLike | undefined} + */ +function surfaceEventFromRow(row) { + const payload = parseMetaJson(row?.payloadJson); + const type = typeof payload.type === "string" ? payload.type : row?.type; + if (typeof type !== "string") { + return undefined; + } + return { + ...payload, + type, + runId: typeof payload.runId === "string" ? payload.runId : row?.runId, + nodeId: typeof payload.nodeId === "string" ? payload.nodeId : row?.nodeId, + }; +} + +/** + * Parse an approval row's `requestJson` into the `{ title, summary }` slice the + * herdr surface renders as a blocked-pane message (its `approvalMessage` reads + * exactly those two fields). Returns `undefined` when the JSON is absent / + * unparseable / carries neither, so the caller falls back to the generic text. + * + * @param {string | null | undefined} requestJson + * @returns {{ title?: string, summary?: string } | undefined} + */ +function parseApprovalRequest(requestJson) { + if (typeof requestJson !== "string" || requestJson === "") { + return undefined; + } + let parsed; + try { + parsed = JSON.parse(requestJson); + } catch { + return undefined; + } + if (!parsed || typeof parsed !== "object") { + return undefined; + } + /** @type {{ title?: string, summary?: string }} */ + const request = {}; + if (typeof parsed.title === "string" && parsed.title !== "") { + request.title = parsed.title; + } + if (typeof parsed.summary === "string" && parsed.summary !== "") { + request.summary = parsed.summary; + } + return request.title || request.summary ? request : undefined; +} + +/** + * A synthetic surface event capturing a node's CURRENT state at attach time, so + * the mirror reflects still-active nodes without replaying their whole history + * (which would spin up panes for long-finished nodes). Only nodes that have + * actually started AND are not terminal get an event; pending / finished / failed + * / cancelled / skipped nodes get none. + * + * @param {any} node NodeRow + * @param {string} runId + * @param {{ title?: string, summary?: string }} [approvalRequest] enriches a + * `waiting-approval` node's blocked message with the real gate question. + * @returns {import("@smthrs/herdr").SmithersEventLike | undefined} + */ +function synthNodeStateEvent(node, runId, approvalRequest) { + const base = { + runId, + nodeId: node.nodeId, + iteration: typeof node.iteration === "number" ? node.iteration : 0, + attempt: typeof node.lastAttempt === "number" ? node.lastAttempt : 1, + timestampMs: Date.now(), + }; + switch (node.state) { + case "in-progress": + case "waiting-timer": + case "waiting-event": + return { ...base, type: "NodeStarted" }; + case "waiting-approval": + // Carry the parsed gate request when available so the adopted pane shows + // the real approval question instead of the generic "waiting for approval". + return approvalRequest + ? { ...base, type: "NodeWaitingApproval", request: approvalRequest } + : { ...base, type: "NodeWaitingApproval" }; + default: + // pending / finished / failed / cancelled / skipped: no live pane. + return undefined; + } +} + +/** + * Reconcile a resuming `smithers up --herdr` run against the herdr state a PRIOR + * (now-exited) surface left behind. The default flow parks at a human approval + * gate by EXITING (exit 3 = awaiting a decision), so the process that reported + * the gate `blocked` is gone; the fresh surface driving the resume must re-adopt + * that pane and re-flag the gate, or the pane sits stuck "blocked" forever even + * after the run is approved and finished. + * + * Adopts every prior pane for the run (`attach`, filter-independent), then marks + * each non-terminal approval-gate node (a node still to run that carries an + * approval row) so its live resolution on this resume reads idle "approved" + * rather than the "done" an agent node reports. Best-effort and read-only: a DB + * read failure leaves the mirror as-is. Must be awaited BEFORE the resumed run + * starts, so the adoption and gate marks are enqueued ahead of the live events. + * + * @param {any} adapter SmithersDb adapter (read-only) + * @param {string} runId + * @param {import("@smthrs/herdr").HerdrRunSurface} surface + * @returns {Promise} + */ +export async function reconcileHerdrResumeGates(adapter, runId, surface) { + await surface.attach(runId); + /** @type {any[] | undefined} */ + let nodes; + try { + nodes = await adapter.listNodes(runId); + } catch { + // Soft: the mirror is optional; without the node list we simply skip + // re-flagging gates (the adopted panes still resolve, as "done"). + return; + } + if (!Array.isArray(nodes)) { + return; + } + for (const node of nodes) { + if (!node || typeof node.nodeId !== "string") { + continue; + } + // Only a node still to run can still resolve in the mirror; a terminal node + // is already settled. A re-armed (approved) gate sits in `pending`; an + // unapproved gate sits in `waiting-approval`. + if (node.state !== "pending" && node.state !== "waiting-approval") { + continue; + } + const iteration = typeof node.iteration === "number" ? node.iteration : 0; + /** @type {any} */ + let approval; + try { + approval = await adapter.getApproval(runId, node.nodeId, iteration); + } catch { + continue; + } + // Only a node with an approval row is a human gate; agent/compute nodes have none. + if (approval) { + surface.markApprovalGate(node.nodeId); + } + } +} + +/** + * Attach a surface to an existing run and follow it live via the DB poller until + * the run is terminal or the caller cancels (Ctrl-C). Reconciles against existing + * herdr state (adopts prior panes), re-flags any adopted parked approval gate (so + * a live approval resolves it idle "approved" rather than working -> done — the + * NodeWaitingApproval handler's `!entry.paneId` self-flag cannot fire once attach + * has adopted the pane, mirroring the `up --resume` reconcile path), replays the + * CURRENT node states as synthetic events (still-active nodes only), then feeds + * every new persisted event into the surface. Read-only against the store; never + * closes herdr workspaces (the caller closes the surface, which only detaches). + * + * @param {any} adapter SmithersDb adapter (read-only) + * @param {any} run run row from adapter.getRun + * @param {import("@smthrs/herdr").HerdrRunSurface} surface + * @param {{ pollIntervalMs?: number; isCancelled?: () => boolean }} [options] + * @returns {Promise} the final derived run status (or undefined if cancelled) + */ +export async function followRunIntoHerdr(adapter, run, surface, options = {}) { + const runId = run.runId; + const pollIntervalMs = options.pollIntervalMs ?? HERDR_FOLLOW_POLL_INTERVAL_MS; + // Snapshot the event cursor BEFORE synthesizing states, so any event written + // between now and the first poll is replayed (no gap, last-write-wins). + const startSeq = await adapter.getLastEventSeq(runId); + let lastSeq = typeof startSeq === "number" ? startSeq : -1; + + // Adopt existing panes (a prior surface incarnation), then replay current + // node states so the adopted panes get a fresh authoritative status. + await surface.attach(runId); + const nodes = await adapter.listNodes(runId); + // Only when the run is actually parked on a gate: pull the pending approval + // rows once (the same store the approvals CLI reads) so an adopted + // waiting-approval pane shows the real gate question, not the generic text. + // Cheap and soft - any failure leaves the generic message. + /** @type {Map} */ + const approvalRequestsByNode = new Map(); + if (Array.isArray(nodes) && nodes.some((n) => n && n.state === "waiting-approval")) { + try { + const pending = await adapter.listPendingApprovals(runId); + if (Array.isArray(pending)) { + for (const row of pending) { + if (!row || typeof row.nodeId !== "string") { + continue; + } + // A node in the pending-approvals list IS a human approval gate. Re-flag + // it here — BEFORE the synth loop below emits its NodeWaitingApproval — + // so its live resolution reports idle "approved". `attach()` above already + // adopted the parked gate pane (entry.paneId set), which is precisely why + // the NodeWaitingApproval handler's own gate-discriminator (`!entry.paneId`) + // no longer fires; without this the adopted gate would resolve + // working -> done, inconsistently with the `up --resume` path + // (reconcileHerdrResumeGates does the same re-flag). Idempotent. + surface.markApprovalGate(row.nodeId); + if (approvalRequestsByNode.has(row.nodeId)) { + continue; + } + const request = parseApprovalRequest(row.requestJson); + if (request) { + approvalRequestsByNode.set(row.nodeId, request); + } + } + } + } catch { + // Enrichment is best-effort; the generic "waiting for approval" text stands. + } + } + if (Array.isArray(nodes)) { + for (const node of nodes) { + const event = synthNodeStateEvent(node, runId, approvalRequestsByNode.get(node.nodeId)); + if (event) { + surface.onEvent(event); + } + } + } + + /** + * @returns {Promise} + */ + async function drainNewEvents() { + while (true) { + const page = await adapter.listEvents(runId, lastSeq, HERDR_EVENT_PAGE_SIZE); + if (!Array.isArray(page) || page.length === 0) { + return; + } + for (const row of page) { + // Advance the cursor for EVERY row (including skipped ones) so the + // follow loop never re-reads them. + if (typeof row.seq === "number") { + lastSeq = row.seq; + } + // Pre-filter on the row's type column before parsing: the surface maps + // only a fixed set of event types, so skip the JSON.parse for the rest + // (chiefly the high-volume NodeOutput rows). + if (!HERDR_SURFACE_EVENT_TYPES.has(row?.type)) { + continue; + } + const event = surfaceEventFromRow(row); + if (event) { + surface.onEvent(event); + } + } + if (page.length < HERDR_EVENT_PAGE_SIZE) { + return; + } + } + } + + while (true) { + if (options.isCancelled?.()) { + return undefined; + } + await sleep(pollIntervalMs); + await drainNewEvents(); + const currentRun = await adapter.getRun(runId); + const status = deriveTailStatus(await computeRunStateFromRow(adapter, currentRun ?? run)); + if (!isTailActiveState(status)) { + // Drain any events written between the last poll and the terminal + // transition, then stop so the follow session ends cleanly. + await drainNewEvents(); + return status; + } + } +} + +/** + * Whether `smithers hijack` should host the interactive session in a herdr pane + * instead of the operator's current terminal, and in which session. + * + * Pane hosting is OPT-IN via `SMITHERS_HERDR_HIJACK` (`1` | `true` | ``). + * It is deliberately NOT the default even for a mirrored run: a herdr pane's + * process is owned by herdr, not this command, and herdr's `pane_exited` event + * carries no exit code, so pane hosting cannot faithfully reproduce the current + * flow's resume-ONLY-on-clean-exit handback (auto-resuming after an aborted or + * errored session would corrupt the run). Keeping the byte-identical + * current-terminal flow as the default preserves that contract; in a pane the + * operator resumes manually with the printed `smithers up ... --resume` command. + * When the toggle carries no explicit session, `SMITHERS_HERDR`'s session is used. + * + * @param {NodeJS.ProcessEnv} [env] + * @returns {{ enabled: boolean, session: string | undefined }} + */ +export function resolveHerdrHijackOption(env = process.env) { + const raw = env?.SMITHERS_HERDR_HIJACK; + if (typeof raw !== "string" || raw === "" || raw === "0" || raw === "false") { + return { enabled: false, session: undefined }; + } + if (raw !== "1" && raw !== "true") { + return { enabled: true, session: raw }; + } + return { enabled: true, session: herdrSessionOf(resolveHerdrOption(undefined, env)) }; +} + +/** + * Single-quote a token for safe embedding inside a POSIX `sh -c` script literal. + * + * @param {string} s + * @returns {string} + */ +function shSingleQuote(s) { + return `'${String(s).replace(/'/g, "'\\''")}'`; +} + +/** + * Wrap a hijack `HijackLaunchSpec` so that, in a herdr pane, the interactive agent + * session's exit does NOT drop the pane to a bare shell (or tear the pane down): + * after the session exits, the pane prints a handback summary (how to return + * control to Smithers) and lingers for a keypress before exiting with the agent's + * own exit code. The original command runs verbatim via `sh -c '"$@"' … …` + * (argv preserved exactly), inheriting the pane's PTY as an interactive TTY, and + * the wrapper adds NO environment variables — `cwd`/`env` are passed through + * unchanged so the agent session sees the exact same context it would have + * un-wrapped. On a real terminal (TTY) it reads a single keypress via `stty`; with + * a non-TTY stdin it falls back to a line read, so the wrapper is exercisable + * without a PTY. This is applied ONLY on the herdr-pane path; the current-terminal + * hijack flow (`launchHijackSession`) is left byte-identical. + * + * @param {import("./HijackLaunchSpec.ts").HijackLaunchSpec} spec + * @param {string[]} handbackLines lines printed after the session exits (before the linger prompt) + * @returns {import("./HijackLaunchSpec.ts").HijackLaunchSpec} + */ +export function wrapHijackPaneAfterlife(spec, handbackLines) { + const lines = Array.isArray(handbackLines) ? handbackLines : []; + const handbackEcho = lines.map((line) => `printf '%s\\n' ${shSingleQuote(line)}`).join("\n"); + const prompt = "[smithers] press any key to close this pane…"; + const script = [ + // Run the real agent command with its exact argv ($@ starts at $1). + '"$@"', + "__smithers_code=$?", + "printf '\\n'", + handbackEcho, + `printf '%s' ${shSingleQuote(prompt)}`, + // Linger for a keypress: a single byte in raw mode on a TTY, else a line read. + "if [ -t 0 ]; then", + " __smithers_stty=$(stty -g 2>/dev/null || true)", + " stty -icanon -echo min 1 time 0 2>/dev/null || true", + " dd bs=1 count=1 >/dev/null 2>&1 || true", + ' if [ -n "$__smithers_stty" ]; then stty "$__smithers_stty" 2>/dev/null || true; fi', + "else", + " read __smithers_ignored", + "fi", + "printf '\\n'", + "exit $__smithers_code", + ] + .filter((part) => part !== "") + .join("\n"); + return { + command: "sh", + // `sh -c