@ag-eco/agentplate-cli 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +462 -0
- package/agents/ap-co-creation.md +90 -0
- package/agents/builder.md +144 -0
- package/agents/coordinator.md +377 -0
- package/agents/lead.md +435 -0
- package/agents/merger.md +164 -0
- package/agents/monitor.md +214 -0
- package/agents/orchestrator.md +239 -0
- package/agents/reviewer.md +140 -0
- package/agents/scout.md +125 -0
- package/agents/supervisor.md +427 -0
- package/package.json +66 -0
- package/src/agents/capabilities.test.ts +85 -0
- package/src/agents/capabilities.ts +125 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +101 -0
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/guard-rules.test.ts +372 -0
- package/src/agents/guard-rules.ts +97 -0
- package/src/agents/headless-mail-injector.test.ts +709 -0
- package/src/agents/headless-mail-injector.ts +377 -0
- package/src/agents/headless-prompt.test.ts +102 -0
- package/src/agents/headless-prompt.ts +68 -0
- package/src/agents/hooks-deployer.test.ts +3119 -0
- package/src/agents/hooks-deployer.ts +804 -0
- package/src/agents/identity.test.ts +604 -0
- package/src/agents/identity.ts +384 -0
- package/src/agents/lifecycle.test.ts +196 -0
- package/src/agents/lifecycle.ts +183 -0
- package/src/agents/mail-poll-detect.test.ts +153 -0
- package/src/agents/mail-poll-detect.ts +73 -0
- package/src/agents/manifest.test.ts +1026 -0
- package/src/agents/manifest.ts +376 -0
- package/src/agents/overlay.test.ts +1058 -0
- package/src/agents/overlay.ts +490 -0
- package/src/agents/scope-detect.test.ts +190 -0
- package/src/agents/scope-detect.ts +146 -0
- package/src/agents/turn-lock.test.ts +181 -0
- package/src/agents/turn-lock.ts +235 -0
- package/src/agents/turn-runner-dispatch.test.ts +182 -0
- package/src/agents/turn-runner-dispatch.ts +105 -0
- package/src/agents/turn-runner.test.ts +2312 -0
- package/src/agents/turn-runner.ts +1383 -0
- package/src/beads/client.test.ts +217 -0
- package/src/beads/client.ts +230 -0
- package/src/beads/molecules.test.ts +338 -0
- package/src/beads/molecules.ts +198 -0
- package/src/commands/agents.test.ts +328 -0
- package/src/commands/agents.ts +299 -0
- package/src/commands/clean.test.ts +797 -0
- package/src/commands/clean.ts +791 -0
- package/src/commands/completions.test.ts +348 -0
- package/src/commands/completions.ts +981 -0
- package/src/commands/coordinator.test.ts +2975 -0
- package/src/commands/coordinator.ts +1841 -0
- package/src/commands/costs.test.ts +1183 -0
- package/src/commands/costs.ts +599 -0
- package/src/commands/dashboard.test.ts +954 -0
- package/src/commands/dashboard.ts +1212 -0
- package/src/commands/discover.test.ts +288 -0
- package/src/commands/discover.ts +202 -0
- package/src/commands/doctor.test.ts +303 -0
- package/src/commands/doctor.ts +311 -0
- package/src/commands/ecosystem.test.ts +226 -0
- package/src/commands/ecosystem.ts +248 -0
- package/src/commands/errors.test.ts +654 -0
- package/src/commands/errors.ts +197 -0
- package/src/commands/feed.test.ts +709 -0
- package/src/commands/feed.ts +260 -0
- package/src/commands/group.test.ts +475 -0
- package/src/commands/group.ts +546 -0
- package/src/commands/hooks.test.ts +458 -0
- package/src/commands/hooks.ts +263 -0
- package/src/commands/init.test.ts +1011 -0
- package/src/commands/init.ts +967 -0
- package/src/commands/inspect.test.ts +1239 -0
- package/src/commands/inspect.ts +648 -0
- package/src/commands/log.test.ts +1913 -0
- package/src/commands/log.ts +958 -0
- package/src/commands/logs.test.ts +801 -0
- package/src/commands/logs.ts +483 -0
- package/src/commands/mail.test.ts +1501 -0
- package/src/commands/mail.ts +848 -0
- package/src/commands/merge.test.ts +864 -0
- package/src/commands/merge.ts +381 -0
- package/src/commands/metrics.test.ts +458 -0
- package/src/commands/metrics.ts +129 -0
- package/src/commands/monitor.test.ts +191 -0
- package/src/commands/monitor.ts +409 -0
- package/src/commands/nudge.test.ts +579 -0
- package/src/commands/nudge.ts +646 -0
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +612 -0
- package/src/commands/prime.ts +359 -0
- package/src/commands/replay.test.ts +757 -0
- package/src/commands/replay.ts +231 -0
- package/src/commands/run.test.ts +469 -0
- package/src/commands/run.ts +353 -0
- package/src/commands/serve/agent-actions.test.ts +210 -0
- package/src/commands/serve/agent-actions.ts +192 -0
- package/src/commands/serve/build.test.ts +202 -0
- package/src/commands/serve/build.ts +206 -0
- package/src/commands/serve/coordinator-actions.test.ts +339 -0
- package/src/commands/serve/coordinator-actions.ts +410 -0
- package/src/commands/serve/dev.test.ts +168 -0
- package/src/commands/serve/dev.ts +117 -0
- package/src/commands/serve/mail-actions.test.ts +312 -0
- package/src/commands/serve/mail-actions.ts +167 -0
- package/src/commands/serve/rest.test.ts +1680 -0
- package/src/commands/serve/rest.ts +1130 -0
- package/src/commands/serve/static.ts +51 -0
- package/src/commands/serve/ws.test.ts +361 -0
- package/src/commands/serve/ws.ts +332 -0
- package/src/commands/serve.test.ts +459 -0
- package/src/commands/serve.ts +654 -0
- package/src/commands/sling.test.ts +1583 -0
- package/src/commands/sling.ts +1351 -0
- package/src/commands/spec.test.ts +179 -0
- package/src/commands/spec.ts +105 -0
- package/src/commands/status.test.ts +614 -0
- package/src/commands/status.ts +403 -0
- package/src/commands/stop.test.ts +964 -0
- package/src/commands/stop.ts +319 -0
- package/src/commands/supervisor.test.ts +185 -0
- package/src/commands/supervisor.ts +537 -0
- package/src/commands/trace.test.ts +762 -0
- package/src/commands/trace.ts +205 -0
- package/src/commands/update.test.ts +466 -0
- package/src/commands/update.ts +263 -0
- package/src/commands/upgrade.test.ts +48 -0
- package/src/commands/upgrade.ts +240 -0
- package/src/commands/watch.test.ts +257 -0
- package/src/commands/watch.ts +308 -0
- package/src/commands/worktree.test.ts +1297 -0
- package/src/commands/worktree.ts +451 -0
- package/src/config.test.ts +1535 -0
- package/src/config.ts +1064 -0
- package/src/doctor/agents.test.ts +523 -0
- package/src/doctor/agents.ts +399 -0
- package/src/doctor/config-check.test.ts +191 -0
- package/src/doctor/config-check.ts +183 -0
- package/src/doctor/consistency.test.ts +807 -0
- package/src/doctor/consistency.ts +347 -0
- package/src/doctor/databases.test.ts +350 -0
- package/src/doctor/databases.ts +243 -0
- package/src/doctor/dependencies.test.ts +296 -0
- package/src/doctor/dependencies.ts +272 -0
- package/src/doctor/ecosystem.test.ts +308 -0
- package/src/doctor/ecosystem.ts +156 -0
- package/src/doctor/logs.test.ts +253 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +315 -0
- package/src/doctor/merge-queue.ts +167 -0
- package/src/doctor/providers.test.ts +409 -0
- package/src/doctor/providers.ts +250 -0
- package/src/doctor/serve.test.ts +95 -0
- package/src/doctor/serve.ts +86 -0
- package/src/doctor/structure.test.ts +423 -0
- package/src/doctor/structure.ts +285 -0
- package/src/doctor/types.ts +43 -0
- package/src/doctor/version.test.ts +241 -0
- package/src/doctor/version.ts +132 -0
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +214 -0
- package/src/e2e/init-sling-lifecycle.test.ts +283 -0
- package/src/errors.test.ts +350 -0
- package/src/errors.ts +217 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +369 -0
- package/src/events/tailer.test.ts +719 -0
- package/src/events/tailer.ts +332 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/index.ts +533 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/insights/quality-gates.test.ts +141 -0
- package/src/insights/quality-gates.ts +156 -0
- package/src/json.test.ts +72 -0
- package/src/json.ts +53 -0
- package/src/loam/client.test.ts +752 -0
- package/src/loam/client.ts +664 -0
- package/src/logging/color.test.ts +252 -0
- package/src/logging/color.ts +105 -0
- package/src/logging/format.test.ts +110 -0
- package/src/logging/format.ts +255 -0
- package/src/logging/logger.test.ts +814 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +259 -0
- package/src/logging/reporter.ts +110 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/logging/theme.ts +140 -0
- package/src/mail/broadcast.test.ts +204 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +774 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +898 -0
- package/src/mail/store.ts +425 -0
- package/src/merge/lock.test.ts +149 -0
- package/src/merge/lock.ts +140 -0
- package/src/merge/predict.test.ts +387 -0
- package/src/merge/predict.ts +249 -0
- package/src/merge/queue.test.ts +426 -0
- package/src/merge/queue.ts +246 -0
- package/src/merge/resolver.test.ts +1993 -0
- package/src/merge/resolver.ts +926 -0
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/pricing.ts +135 -0
- package/src/metrics/store.test.ts +978 -0
- package/src/metrics/store.ts +501 -0
- package/src/metrics/summary.test.ts +398 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +483 -0
- package/src/metrics/transcript.ts +114 -0
- package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +1474 -0
- package/src/runtimes/claude.ts +579 -0
- package/src/runtimes/codex.test.ts +805 -0
- package/src/runtimes/codex.ts +273 -0
- package/src/runtimes/connections.test.ts +214 -0
- package/src/runtimes/connections.ts +103 -0
- package/src/runtimes/copilot.test.ts +707 -0
- package/src/runtimes/copilot.ts +316 -0
- package/src/runtimes/cursor.test.ts +497 -0
- package/src/runtimes/cursor.ts +205 -0
- package/src/runtimes/gemini.test.ts +537 -0
- package/src/runtimes/gemini.ts +243 -0
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/headless-connection.test.ts +264 -0
- package/src/runtimes/headless-connection.ts +158 -0
- package/src/runtimes/opencode.test.ts +325 -0
- package/src/runtimes/opencode.ts +188 -0
- package/src/runtimes/pi-guards.test.ts +486 -0
- package/src/runtimes/pi-guards.ts +367 -0
- package/src/runtimes/pi.test.ts +789 -0
- package/src/runtimes/pi.ts +305 -0
- package/src/runtimes/registry.test.ts +196 -0
- package/src/runtimes/registry.ts +99 -0
- package/src/runtimes/sapling.test.ts +1267 -0
- package/src/runtimes/sapling.ts +710 -0
- package/src/runtimes/types.ts +266 -0
- package/src/schema-consistency.test.ts +246 -0
- package/src/sessions/compat.test.ts +281 -0
- package/src/sessions/compat.ts +105 -0
- package/src/sessions/store.test.ts +1748 -0
- package/src/sessions/store.ts +858 -0
- package/src/test-helpers.test.ts +124 -0
- package/src/test-helpers.ts +145 -0
- package/src/test-setup.test.ts +31 -0
- package/src/test-setup.ts +28 -0
- package/src/tools/loam/api.ts +368 -0
- package/src/tools/loam/cli.ts +278 -0
- package/src/tools/loam/commands/add.ts +52 -0
- package/src/tools/loam/commands/archive.ts +214 -0
- package/src/tools/loam/commands/audit.ts +276 -0
- package/src/tools/loam/commands/compact.ts +1062 -0
- package/src/tools/loam/commands/completions.ts +79 -0
- package/src/tools/loam/commands/config.ts +381 -0
- package/src/tools/loam/commands/delete-domain.ts +121 -0
- package/src/tools/loam/commands/delete.ts +316 -0
- package/src/tools/loam/commands/diff.ts +200 -0
- package/src/tools/loam/commands/doctor.ts +1113 -0
- package/src/tools/loam/commands/edit.ts +226 -0
- package/src/tools/loam/commands/init.ts +31 -0
- package/src/tools/loam/commands/learn.ts +179 -0
- package/src/tools/loam/commands/move.ts +323 -0
- package/src/tools/loam/commands/onboard.ts +374 -0
- package/src/tools/loam/commands/outcome.ts +185 -0
- package/src/tools/loam/commands/prime.ts +688 -0
- package/src/tools/loam/commands/prune.ts +614 -0
- package/src/tools/loam/commands/query.ts +218 -0
- package/src/tools/loam/commands/rank.ts +180 -0
- package/src/tools/loam/commands/ready.ts +189 -0
- package/src/tools/loam/commands/record.ts +1210 -0
- package/src/tools/loam/commands/restore.ts +166 -0
- package/src/tools/loam/commands/search.ts +327 -0
- package/src/tools/loam/commands/setup.ts +887 -0
- package/src/tools/loam/commands/status.ts +103 -0
- package/src/tools/loam/commands/sync.ts +298 -0
- package/src/tools/loam/commands/update.ts +19 -0
- package/src/tools/loam/commands/upgrade.ts +93 -0
- package/src/tools/loam/commands/validate.ts +190 -0
- package/src/tools/loam/index.ts +62 -0
- package/src/tools/loam/log.ts +127 -0
- package/src/tools/loam/registry/builtins.ts +409 -0
- package/src/tools/loam/registry/custom.ts +431 -0
- package/src/tools/loam/registry/init.ts +55 -0
- package/src/tools/loam/registry/template.ts +40 -0
- package/src/tools/loam/registry/type-registry.ts +113 -0
- package/src/tools/loam/schemas/config-schema.ts +489 -0
- package/src/tools/loam/schemas/config.ts +245 -0
- package/src/tools/loam/schemas/index.ts +18 -0
- package/src/tools/loam/schemas/record-schema.ts +191 -0
- package/src/tools/loam/schemas/record.ts +115 -0
- package/src/tools/loam/utils/active-work.ts +205 -0
- package/src/tools/loam/utils/anchor-validity.ts +80 -0
- package/src/tools/loam/utils/archive.ts +146 -0
- package/src/tools/loam/utils/audit.ts +667 -0
- package/src/tools/loam/utils/bm25.ts +238 -0
- package/src/tools/loam/utils/budget.ts +142 -0
- package/src/tools/loam/utils/config.ts +344 -0
- package/src/tools/loam/utils/dir-anchors.ts +62 -0
- package/src/tools/loam/utils/domain-rules.ts +114 -0
- package/src/tools/loam/utils/expertise.ts +393 -0
- package/src/tools/loam/utils/format-helpers.ts +96 -0
- package/src/tools/loam/utils/format.ts +1234 -0
- package/src/tools/loam/utils/git-context.ts +50 -0
- package/src/tools/loam/utils/git.ts +183 -0
- package/src/tools/loam/utils/hooks.ts +299 -0
- package/src/tools/loam/utils/index.ts +52 -0
- package/src/tools/loam/utils/json-output.ts +13 -0
- package/src/tools/loam/utils/lock.ts +76 -0
- package/src/tools/loam/utils/markers.ts +48 -0
- package/src/tools/loam/utils/numeric-flags.ts +20 -0
- package/src/tools/loam/utils/palette.ts +44 -0
- package/src/tools/loam/utils/prime-ranking.ts +135 -0
- package/src/tools/loam/utils/recipe-discovery.ts +195 -0
- package/src/tools/loam/utils/runtime-flags.ts +28 -0
- package/src/tools/loam/utils/scoring.ts +94 -0
- package/src/tools/loam/utils/version.ts +116 -0
- package/src/tools/sprout/commands/block.ts +64 -0
- package/src/tools/sprout/commands/blocked.ts +86 -0
- package/src/tools/sprout/commands/close.ts +129 -0
- package/src/tools/sprout/commands/completions.ts +198 -0
- package/src/tools/sprout/commands/config.ts +238 -0
- package/src/tools/sprout/commands/create.ts +164 -0
- package/src/tools/sprout/commands/dep.ts +148 -0
- package/src/tools/sprout/commands/doctor.ts +979 -0
- package/src/tools/sprout/commands/init.ts +83 -0
- package/src/tools/sprout/commands/label.ts +178 -0
- package/src/tools/sprout/commands/list.ts +210 -0
- package/src/tools/sprout/commands/migrate.ts +133 -0
- package/src/tools/sprout/commands/onboard.ts +207 -0
- package/src/tools/sprout/commands/plan-show.ts +278 -0
- package/src/tools/sprout/commands/plan.ts +2526 -0
- package/src/tools/sprout/commands/prime.ts +399 -0
- package/src/tools/sprout/commands/ready.ts +245 -0
- package/src/tools/sprout/commands/search.ts +221 -0
- package/src/tools/sprout/commands/show.ts +277 -0
- package/src/tools/sprout/commands/stats.ts +146 -0
- package/src/tools/sprout/commands/sync.ts +134 -0
- package/src/tools/sprout/commands/tpl.ts +364 -0
- package/src/tools/sprout/commands/unblock.ts +115 -0
- package/src/tools/sprout/commands/update.ts +257 -0
- package/src/tools/sprout/commands/upgrade.ts +91 -0
- package/src/tools/sprout/config-schema.ts +152 -0
- package/src/tools/sprout/config.ts +355 -0
- package/src/tools/sprout/filter.ts +107 -0
- package/src/tools/sprout/format.ts +43 -0
- package/src/tools/sprout/id.ts +22 -0
- package/src/tools/sprout/index.ts +204 -0
- package/src/tools/sprout/log.ts +76 -0
- package/src/tools/sprout/markers.ts +22 -0
- package/src/tools/sprout/output.ts +121 -0
- package/src/tools/sprout/plan-backref.ts +93 -0
- package/src/tools/sprout/plan-context.ts +81 -0
- package/src/tools/sprout/plan-domain.ts +139 -0
- package/src/tools/sprout/plan-lifecycle.ts +65 -0
- package/src/tools/sprout/plan-loam.ts +207 -0
- package/src/tools/sprout/plan-schema.ts +209 -0
- package/src/tools/sprout/sort.ts +31 -0
- package/src/tools/sprout/store.ts +172 -0
- package/src/tools/sprout/types.ts +118 -0
- package/src/tools/sprout/validation.ts +119 -0
- package/src/tools/sprout/version.ts +1 -0
- package/src/tools/sprout/yaml.ts +387 -0
- package/src/tools/trellis/commands/archive.ts +87 -0
- package/src/tools/trellis/commands/completions.ts +610 -0
- package/src/tools/trellis/commands/config.ts +382 -0
- package/src/tools/trellis/commands/create.ts +252 -0
- package/src/tools/trellis/commands/diff.ts +150 -0
- package/src/tools/trellis/commands/doctor.ts +771 -0
- package/src/tools/trellis/commands/emit.ts +365 -0
- package/src/tools/trellis/commands/history.ts +83 -0
- package/src/tools/trellis/commands/import.ts +198 -0
- package/src/tools/trellis/commands/init.ts +81 -0
- package/src/tools/trellis/commands/list.ts +103 -0
- package/src/tools/trellis/commands/onboard.ts +156 -0
- package/src/tools/trellis/commands/pin.ts +172 -0
- package/src/tools/trellis/commands/prime.ts +193 -0
- package/src/tools/trellis/commands/render.ts +122 -0
- package/src/tools/trellis/commands/schema.ts +353 -0
- package/src/tools/trellis/commands/show.ts +115 -0
- package/src/tools/trellis/commands/stats.ts +65 -0
- package/src/tools/trellis/commands/sync.ts +112 -0
- package/src/tools/trellis/commands/tree.ts +123 -0
- package/src/tools/trellis/commands/update.ts +330 -0
- package/src/tools/trellis/commands/upgrade.ts +95 -0
- package/src/tools/trellis/commands/validate.ts +166 -0
- package/src/tools/trellis/config-schema.ts +81 -0
- package/src/tools/trellis/config.ts +108 -0
- package/src/tools/trellis/frontmatter.ts +348 -0
- package/src/tools/trellis/id.ts +24 -0
- package/src/tools/trellis/index.ts +209 -0
- package/src/tools/trellis/markers.ts +28 -0
- package/src/tools/trellis/output.ts +84 -0
- package/src/tools/trellis/render.ts +212 -0
- package/src/tools/trellis/store.ts +144 -0
- package/src/tools/trellis/types.ts +82 -0
- package/src/tools/trellis/validate.ts +199 -0
- package/src/tools/trellis/yaml.ts +309 -0
- package/src/tracker/beads.test.ts +454 -0
- package/src/tracker/beads.ts +56 -0
- package/src/tracker/factory.test.ts +90 -0
- package/src/tracker/factory.ts +65 -0
- package/src/tracker/sprout.test.ts +461 -0
- package/src/tracker/sprout.ts +182 -0
- package/src/tracker/types.ts +52 -0
- package/src/trellis/client.test.ts +107 -0
- package/src/trellis/client.ts +179 -0
- package/src/types.ts +970 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/browser.test.ts +49 -0
- package/src/utils/browser.ts +48 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +152 -0
- package/src/utils/pid.ts +130 -0
- package/src/utils/process-scan.test.ts +53 -0
- package/src/utils/process-scan.ts +76 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/version.ts +5 -0
- package/src/watchdog/daemon.test.ts +3721 -0
- package/src/watchdog/daemon.ts +1257 -0
- package/src/watchdog/health.test.ts +830 -0
- package/src/watchdog/health.ts +434 -0
- package/src/watchdog/triage.test.ts +205 -0
- package/src/watchdog/triage.ts +205 -0
- package/src/worktree/manager.test.ts +720 -0
- package/src/worktree/manager.ts +405 -0
- package/src/worktree/process.test.ts +172 -0
- package/src/worktree/process.ts +131 -0
- package/src/worktree/tmux.test.ts +1616 -0
- package/src/worktree/tmux.ts +721 -0
- package/templates/CLAUDE.md.tmpl +100 -0
- package/templates/copilot-hooks.json.tmpl +13 -0
- package/templates/hooks.json.tmpl +109 -0
- package/templates/overlay.md.tmpl +88 -0
- package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
- package/ui/dist/chunk-8s31f05k.css +1 -0
- package/ui/dist/chunk-vm5rz679.js +300 -0
- package/ui/dist/favicon-nzb39vza.svg +4 -0
- package/ui/dist/index.html +17 -0
|
@@ -0,0 +1,1841 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: ap coordinator start|stop|status
|
|
3
|
+
*
|
|
4
|
+
* Manages the persistent coordinator agent lifecycle. The coordinator runs
|
|
5
|
+
* at the project root (NOT in a worktree), receives work via mail and tasks,
|
|
6
|
+
* and dispatches agents via ap sling.
|
|
7
|
+
*
|
|
8
|
+
* Unlike regular agents spawned by sling, the coordinator:
|
|
9
|
+
* - Has no worktree (operates on the main working tree)
|
|
10
|
+
* - Has no task assignment (it creates tasks, not works on them)
|
|
11
|
+
* - Has no overlay CLAUDE.md (context comes via mail + tasks + checkpoints)
|
|
12
|
+
* - Persists across work batches
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdir, unlink } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { Command } from "commander";
|
|
18
|
+
import { buildInitialHeadlessPrompt, formatMailSection } from "../agents/headless-prompt.ts";
|
|
19
|
+
import { createIdentity, loadIdentity } from "../agents/identity.ts";
|
|
20
|
+
import { createManifestLoader, resolveModel } from "../agents/manifest.ts";
|
|
21
|
+
import { loadConfig } from "../config.ts";
|
|
22
|
+
import { AgentError, ValidationError } from "../errors.ts";
|
|
23
|
+
import { jsonOutput } from "../json.ts";
|
|
24
|
+
import { printHint, printSuccess, printWarning } from "../logging/color.ts";
|
|
25
|
+
import { createMailClient } from "../mail/client.ts";
|
|
26
|
+
import { createMailStore } from "../mail/store.ts";
|
|
27
|
+
import { getRuntime } from "../runtimes/registry.ts";
|
|
28
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
29
|
+
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
30
|
+
import { resolveBackend, trackerCliName } from "../tracker/factory.ts";
|
|
31
|
+
import type { AgentSession } from "../types.ts";
|
|
32
|
+
import { isProcessRunning } from "../watchdog/health.ts";
|
|
33
|
+
import type { SpawnHeadlessOptions } from "../worktree/process.ts";
|
|
34
|
+
import { spawnHeadlessAgent } from "../worktree/process.ts";
|
|
35
|
+
import type { SessionState } from "../worktree/tmux.ts";
|
|
36
|
+
import {
|
|
37
|
+
capturePaneContent,
|
|
38
|
+
checkSessionState,
|
|
39
|
+
createSession,
|
|
40
|
+
ensureTmuxAvailable,
|
|
41
|
+
isSessionAlive,
|
|
42
|
+
killSession,
|
|
43
|
+
sanitizeTmuxName,
|
|
44
|
+
sendKeys,
|
|
45
|
+
TMUX_SOCKET,
|
|
46
|
+
waitForTuiReady,
|
|
47
|
+
} from "../worktree/tmux.ts";
|
|
48
|
+
import { nudgeAgent } from "./nudge.ts";
|
|
49
|
+
import { isRunningAsRoot } from "./sling.ts";
|
|
50
|
+
|
|
51
|
+
/** Default coordinator agent name. */
|
|
52
|
+
export const COORDINATOR_NAME = "coordinator";
|
|
53
|
+
|
|
54
|
+
export interface PersistentAgentSpec {
|
|
55
|
+
commandName: string;
|
|
56
|
+
displayName: string;
|
|
57
|
+
agentName: string;
|
|
58
|
+
capability: string;
|
|
59
|
+
agentDefFile: string;
|
|
60
|
+
beaconBuilder: (trackerCli: string) => string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const COORDINATOR_SPEC: PersistentAgentSpec = {
|
|
64
|
+
commandName: "coordinator",
|
|
65
|
+
displayName: "Coordinator",
|
|
66
|
+
agentName: COORDINATOR_NAME,
|
|
67
|
+
capability: "coordinator",
|
|
68
|
+
agentDefFile: "coordinator.md",
|
|
69
|
+
beaconBuilder: buildCoordinatorBeacon,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Poll interval for the ask subcommand reply loop. */
|
|
73
|
+
const ASK_POLL_INTERVAL_MS = 2_000;
|
|
74
|
+
|
|
75
|
+
/** Default timeout in seconds for the ask subcommand. */
|
|
76
|
+
const ASK_DEFAULT_TIMEOUT_S = 120;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the tmux session name for the coordinator.
|
|
80
|
+
* Includes the project name to prevent cross-project collisions (agentplate-pcef).
|
|
81
|
+
*/
|
|
82
|
+
function coordinatorTmuxSession(projectName: string, name: string = COORDINATOR_NAME): string {
|
|
83
|
+
return `agentplate-${sanitizeTmuxName(projectName)}-${name}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Dependency injection for testing. Uses real implementations when omitted. */
|
|
87
|
+
export interface CoordinatorDeps {
|
|
88
|
+
_tmux?: {
|
|
89
|
+
createSession: (
|
|
90
|
+
name: string,
|
|
91
|
+
cwd: string,
|
|
92
|
+
command: string,
|
|
93
|
+
env?: Record<string, string>,
|
|
94
|
+
) => Promise<number>;
|
|
95
|
+
isSessionAlive: (name: string) => Promise<boolean>;
|
|
96
|
+
checkSessionState: (name: string) => Promise<SessionState>;
|
|
97
|
+
killSession: (name: string) => Promise<void>;
|
|
98
|
+
sendKeys: (name: string, keys: string) => Promise<void>;
|
|
99
|
+
waitForTuiReady: (
|
|
100
|
+
name: string,
|
|
101
|
+
detectReady: (paneContent: string) => import("../runtimes/types.ts").ReadyState,
|
|
102
|
+
timeoutMs?: number,
|
|
103
|
+
pollIntervalMs?: number,
|
|
104
|
+
) => Promise<boolean>;
|
|
105
|
+
ensureTmuxAvailable: () => Promise<void>;
|
|
106
|
+
};
|
|
107
|
+
_watchdog?: {
|
|
108
|
+
start: () => Promise<{ pid: number } | null>;
|
|
109
|
+
stop: () => Promise<boolean>;
|
|
110
|
+
isRunning: () => Promise<boolean>;
|
|
111
|
+
};
|
|
112
|
+
_monitor?: {
|
|
113
|
+
start: (args: string[]) => Promise<{ pid: number } | null>;
|
|
114
|
+
stop: () => Promise<boolean>;
|
|
115
|
+
isRunning: () => Promise<boolean>;
|
|
116
|
+
};
|
|
117
|
+
_nudge?: (
|
|
118
|
+
projectRoot: string,
|
|
119
|
+
agentName: string,
|
|
120
|
+
message: string,
|
|
121
|
+
force: boolean,
|
|
122
|
+
) => Promise<{ delivered: boolean; reason?: string }>;
|
|
123
|
+
_capturePaneContent?: (name: string, lines?: number) => Promise<string | null>;
|
|
124
|
+
/** Override poll interval for ask subcommand (default: ASK_POLL_INTERVAL_MS). Used in tests. */
|
|
125
|
+
_pollIntervalMs?: number;
|
|
126
|
+
/** Override headless spawn (used by tests to avoid forking real subprocesses). */
|
|
127
|
+
_spawnHeadless?: (
|
|
128
|
+
argv: string[],
|
|
129
|
+
opts: SpawnHeadlessOptions,
|
|
130
|
+
) => Promise<{
|
|
131
|
+
pid: number;
|
|
132
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
133
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
134
|
+
}>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read the PID from the watchdog PID file.
|
|
139
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
140
|
+
*/
|
|
141
|
+
async function readWatchdogPid(projectRoot: string): Promise<number | null> {
|
|
142
|
+
const pidFilePath = join(projectRoot, ".agentplate", "watchdog.pid");
|
|
143
|
+
const file = Bun.file(pidFilePath);
|
|
144
|
+
const exists = await file.exists();
|
|
145
|
+
if (!exists) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const text = await file.text();
|
|
151
|
+
const pid = Number.parseInt(text.trim(), 10);
|
|
152
|
+
if (Number.isNaN(pid) || pid <= 0) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
return pid;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove the watchdog PID file.
|
|
163
|
+
*/
|
|
164
|
+
async function removeWatchdogPid(projectRoot: string): Promise<void> {
|
|
165
|
+
const pidFilePath = join(projectRoot, ".agentplate", "watchdog.pid");
|
|
166
|
+
try {
|
|
167
|
+
await unlink(pidFilePath);
|
|
168
|
+
} catch {
|
|
169
|
+
// File may already be gone — not an error
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Default watchdog implementation for production use.
|
|
175
|
+
* Starts/stops the watchdog daemon via `ap watch --background`.
|
|
176
|
+
*/
|
|
177
|
+
function createDefaultWatchdog(projectRoot: string): NonNullable<CoordinatorDeps["_watchdog"]> {
|
|
178
|
+
return {
|
|
179
|
+
async start(): Promise<{ pid: number } | null> {
|
|
180
|
+
// Check if watchdog is already running
|
|
181
|
+
const existingPid = await readWatchdogPid(projectRoot);
|
|
182
|
+
if (existingPid !== null && isProcessRunning(existingPid)) {
|
|
183
|
+
return null; // Already running
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Clean up stale PID file
|
|
187
|
+
if (existingPid !== null) {
|
|
188
|
+
await removeWatchdogPid(projectRoot);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Start watchdog in background
|
|
192
|
+
const proc = Bun.spawn(["ap", "watch", "--background"], {
|
|
193
|
+
cwd: projectRoot,
|
|
194
|
+
stdout: "pipe",
|
|
195
|
+
stderr: "pipe",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const exitCode = await proc.exited;
|
|
199
|
+
if (exitCode !== 0) {
|
|
200
|
+
return null; // Failed to start
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Read the PID file that was written by the background process
|
|
204
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
205
|
+
if (pid === null) {
|
|
206
|
+
return null; // PID file wasn't created
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { pid };
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
async stop(): Promise<boolean> {
|
|
213
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
214
|
+
if (pid === null) {
|
|
215
|
+
return false; // No PID file
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check if process is running
|
|
219
|
+
if (!isProcessRunning(pid)) {
|
|
220
|
+
// Process is dead, clean up PID file
|
|
221
|
+
await removeWatchdogPid(projectRoot);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Kill the process
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 15); // SIGTERM
|
|
228
|
+
} catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Remove PID file
|
|
233
|
+
await removeWatchdogPid(projectRoot);
|
|
234
|
+
return true;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async isRunning(): Promise<boolean> {
|
|
238
|
+
const pid = await readWatchdogPid(projectRoot);
|
|
239
|
+
if (pid === null) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
return isProcessRunning(pid);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Default monitor implementation for production use.
|
|
249
|
+
* Starts/stops the monitor agent via `ap monitor start/stop`.
|
|
250
|
+
*/
|
|
251
|
+
function createDefaultMonitor(projectRoot: string): NonNullable<CoordinatorDeps["_monitor"]> {
|
|
252
|
+
return {
|
|
253
|
+
async start(): Promise<{ pid: number } | null> {
|
|
254
|
+
const proc = Bun.spawn(["ap", "monitor", "start", "--no-attach", "--json"], {
|
|
255
|
+
cwd: projectRoot,
|
|
256
|
+
stdout: "pipe",
|
|
257
|
+
stderr: "pipe",
|
|
258
|
+
});
|
|
259
|
+
const exitCode = await proc.exited;
|
|
260
|
+
if (exitCode !== 0) return null;
|
|
261
|
+
try {
|
|
262
|
+
const stdout = await new Response(proc.stdout).text();
|
|
263
|
+
const result = JSON.parse(stdout.trim()) as { pid?: number };
|
|
264
|
+
return result.pid ? { pid: result.pid } : null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
async stop(): Promise<boolean> {
|
|
270
|
+
const proc = Bun.spawn(["ap", "monitor", "stop", "--json"], {
|
|
271
|
+
cwd: projectRoot,
|
|
272
|
+
stdout: "pipe",
|
|
273
|
+
stderr: "pipe",
|
|
274
|
+
});
|
|
275
|
+
const exitCode = await proc.exited;
|
|
276
|
+
return exitCode === 0;
|
|
277
|
+
},
|
|
278
|
+
async isRunning(): Promise<boolean> {
|
|
279
|
+
const proc = Bun.spawn(["ap", "monitor", "status", "--json"], {
|
|
280
|
+
cwd: projectRoot,
|
|
281
|
+
stdout: "pipe",
|
|
282
|
+
stderr: "pipe",
|
|
283
|
+
});
|
|
284
|
+
const exitCode = await proc.exited;
|
|
285
|
+
if (exitCode !== 0) return false;
|
|
286
|
+
try {
|
|
287
|
+
const stdout = await new Response(proc.stdout).text();
|
|
288
|
+
const result = JSON.parse(stdout.trim()) as { running?: boolean };
|
|
289
|
+
return result.running === true;
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build the coordinator startup beacon — the first message sent to the coordinator
|
|
299
|
+
* via tmux send-keys after Claude Code initializes.
|
|
300
|
+
*
|
|
301
|
+
* @param cliName - The tracker CLI name to use in startup instructions (default: "bd")
|
|
302
|
+
*/
|
|
303
|
+
export function buildCoordinatorBeacon(cliName = "bd"): string {
|
|
304
|
+
const timestamp = new Date().toISOString();
|
|
305
|
+
const parts = [
|
|
306
|
+
`[AGENTPLATE] ${COORDINATOR_NAME} (coordinator) ${timestamp}`,
|
|
307
|
+
"Depth: 0 | Parent: none | Role: persistent orchestrator",
|
|
308
|
+
"HIERARCHY: Default to leads (ap sling --capability lead). For low-budget or very narrow work, you may spawn scout/builder directly. NEVER spawn reviewer or merger directly.",
|
|
309
|
+
"DELEGATION: For substantial work streams, spawn a lead who will handle scouts/builders/reviewers. For tight agent budgets, compress roles by using direct scout/builder fallback or --dispatch-max-agents 1/2 on the lead.",
|
|
310
|
+
`Startup: run loam prime, check mail (ap mail check --agent ${COORDINATOR_NAME}), check ${cliName} ready, check ap group status, then begin work`,
|
|
311
|
+
];
|
|
312
|
+
return parts.join(" — ");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Determine whether to auto-attach to the tmux session after starting.
|
|
317
|
+
* Exported for testing.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveAttach(args: string[], isTTY: boolean): boolean {
|
|
320
|
+
if (args.includes("--attach")) return true;
|
|
321
|
+
if (args.includes("--no-attach")) return false;
|
|
322
|
+
return isTTY;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Options for the reusable coordinator session startup core.
|
|
327
|
+
* Used by startCoordinatorSession() and consumed by commands like ap discover.
|
|
328
|
+
*/
|
|
329
|
+
export interface CoordinatorSessionOptions {
|
|
330
|
+
json: boolean;
|
|
331
|
+
attach: boolean;
|
|
332
|
+
watchdog: boolean;
|
|
333
|
+
monitor: boolean;
|
|
334
|
+
profile?: string;
|
|
335
|
+
/** Override coordinator name (default: "coordinator"). */
|
|
336
|
+
coordinatorName?: string;
|
|
337
|
+
/** Generic persistent agent name override. Preferred over coordinatorName for new callers. */
|
|
338
|
+
agentName?: string;
|
|
339
|
+
/** Capability stored in the session registry and used for manifest/runtime resolution. */
|
|
340
|
+
capability?: string;
|
|
341
|
+
/** Agent definition file to append as the system prompt. */
|
|
342
|
+
agentDefFile?: string;
|
|
343
|
+
/** Human-readable label for output. */
|
|
344
|
+
displayName?: string;
|
|
345
|
+
/** Custom beacon builder. Receives tracker CLI name, returns beacon string. */
|
|
346
|
+
beaconBuilder?: (trackerCli: string) => string;
|
|
347
|
+
/**
|
|
348
|
+
* When true, spawn the coordinator headless (no tmux pane). The runtime must
|
|
349
|
+
* implement buildDirectSpawn(). The CLI command `ap coordinator start` does
|
|
350
|
+
* not yet pass this flag — it is consumed by the headless start path used by
|
|
351
|
+
* the web UI's POST /api/coordinator/start endpoint.
|
|
352
|
+
*/
|
|
353
|
+
headless?: boolean;
|
|
354
|
+
/**
|
|
355
|
+
* Acknowledge that a watchdog daemon from a previous session may already be
|
|
356
|
+
* running and should be allowed to supervise this coordinator. Without this
|
|
357
|
+
* (or `--watchdog`), the start command refuses to spawn when a leftover
|
|
358
|
+
* daemon is detected, to surface the "watchdog persists across runs" trap
|
|
359
|
+
* that agentplate-3f0c was filed for.
|
|
360
|
+
*/
|
|
361
|
+
acceptExistingWatchdog?: boolean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Core coordinator session startup logic. Reusable by commands that need to
|
|
366
|
+
* start a coordinator-like session with a custom name or beacon
|
|
367
|
+
* (e.g., ap discover uses coordinatorName: "discover-coordinator").
|
|
368
|
+
*/
|
|
369
|
+
export async function startCoordinatorSession(
|
|
370
|
+
opts: CoordinatorSessionOptions,
|
|
371
|
+
deps: CoordinatorDeps = {},
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const tmux = deps._tmux ?? {
|
|
374
|
+
createSession,
|
|
375
|
+
isSessionAlive,
|
|
376
|
+
checkSessionState,
|
|
377
|
+
killSession,
|
|
378
|
+
sendKeys,
|
|
379
|
+
waitForTuiReady,
|
|
380
|
+
ensureTmuxAvailable,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const {
|
|
384
|
+
json,
|
|
385
|
+
attach: shouldAttach,
|
|
386
|
+
watchdog: watchdogFlag,
|
|
387
|
+
monitor: monitorFlag,
|
|
388
|
+
profile: profileFlag,
|
|
389
|
+
coordinatorName: coordinatorNameOpt,
|
|
390
|
+
agentName: agentNameOpt,
|
|
391
|
+
capability: capabilityOpt,
|
|
392
|
+
agentDefFile: agentDefFileOpt,
|
|
393
|
+
displayName: displayNameOpt,
|
|
394
|
+
beaconBuilder: beaconBuilderOpt,
|
|
395
|
+
headless: headlessFlag,
|
|
396
|
+
acceptExistingWatchdog: acceptExistingWatchdogFlag,
|
|
397
|
+
} = opts;
|
|
398
|
+
|
|
399
|
+
const coordinatorName = agentNameOpt ?? coordinatorNameOpt ?? COORDINATOR_NAME;
|
|
400
|
+
const capability = capabilityOpt ?? COORDINATOR_SPEC.capability;
|
|
401
|
+
const agentDefFile = agentDefFileOpt ?? COORDINATOR_SPEC.agentDefFile;
|
|
402
|
+
const displayName = displayNameOpt ?? COORDINATOR_SPEC.displayName;
|
|
403
|
+
const beaconBuilder = beaconBuilderOpt ?? buildCoordinatorBeacon;
|
|
404
|
+
|
|
405
|
+
if (isRunningAsRoot()) {
|
|
406
|
+
throw new AgentError(
|
|
407
|
+
"Cannot spawn agents as root (UID 0). The claude CLI rejects --permission-mode bypassPermissions when run as root, causing the tmux session to die immediately. Run agentplate as a non-root user.",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const cwd = process.cwd();
|
|
412
|
+
const config = await loadConfig(cwd);
|
|
413
|
+
const projectRoot = config.project.root;
|
|
414
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
415
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
416
|
+
const tmuxSession = coordinatorTmuxSession(config.project.name, coordinatorName);
|
|
417
|
+
|
|
418
|
+
// Detect leftover watchdog daemon from a previous session (agentplate-3f0c).
|
|
419
|
+
// If a watchdog is already running and the operator did not pass --watchdog
|
|
420
|
+
// or --accept-existing-watchdog, refuse to start: a persistent daemon will
|
|
421
|
+
// supervise this coordinator with policy decided by the original invocation,
|
|
422
|
+
// not the current one. This prevents "I didn't run --watchdog, why is the
|
|
423
|
+
// watchdog killing things?" surprises.
|
|
424
|
+
const watchdogAlreadyRunning = await watchdog.isRunning();
|
|
425
|
+
if (watchdogAlreadyRunning && !watchdogFlag && !acceptExistingWatchdogFlag) {
|
|
426
|
+
const existingPid = await readWatchdogPid(projectRoot);
|
|
427
|
+
const pidLabel = existingPid !== null ? `PID ${existingPid}` : "unknown PID";
|
|
428
|
+
throw new AgentError(
|
|
429
|
+
`Watchdog daemon (${pidLabel}) is already running from a previous session. ` +
|
|
430
|
+
`It will supervise this ${displayName.toLowerCase()} run and may take escalation actions you did not opt into. ` +
|
|
431
|
+
`To proceed: pass --watchdog to acknowledge, pass --accept-existing-watchdog to suppress this check, ` +
|
|
432
|
+
`or run 'ap watch --kill-others' (or remove .agentplate/watchdog.pid) first.`,
|
|
433
|
+
{ agentName: coordinatorName },
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Check for existing coordinator session with the same name
|
|
438
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
439
|
+
const { store } = openSessionStore(agentplateDir);
|
|
440
|
+
try {
|
|
441
|
+
const existing = store.getByName(coordinatorName);
|
|
442
|
+
|
|
443
|
+
if (
|
|
444
|
+
existing &&
|
|
445
|
+
existing.capability === capability &&
|
|
446
|
+
existing.state !== "completed" &&
|
|
447
|
+
existing.state !== "zombie"
|
|
448
|
+
) {
|
|
449
|
+
const sessionState = await tmux.checkSessionState(existing.tmuxSession);
|
|
450
|
+
|
|
451
|
+
if (sessionState === "alive") {
|
|
452
|
+
// Tmux session exists -- but is the process inside still running?
|
|
453
|
+
// A crashed Claude Code leaves a zombie tmux pane that blocks retries.
|
|
454
|
+
if (existing.pid !== null && !isProcessRunning(existing.pid)) {
|
|
455
|
+
// Zombie: tmux pane exists but agent process has exited.
|
|
456
|
+
// Kill the empty session and reclaim the slot.
|
|
457
|
+
await tmux.killSession(existing.tmuxSession);
|
|
458
|
+
store.updateState(coordinatorName, "completed");
|
|
459
|
+
} else {
|
|
460
|
+
// Either the process is genuinely running (pid alive), or pid is null
|
|
461
|
+
// (e.g. sessions migrated from an older schema). In both cases we
|
|
462
|
+
// cannot prove the session is a zombie, so treat it as active.
|
|
463
|
+
throw new AgentError(
|
|
464
|
+
`${displayName} is already running (tmux: ${existing.tmuxSession}, since: ${existing.startedAt})`,
|
|
465
|
+
{ agentName: coordinatorName },
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
// Session is dead or tmux server is not running -- clean up stale DB entry.
|
|
470
|
+
store.updateState(coordinatorName, "completed");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Resolve model and runtime early (needed for deployConfig and spawn)
|
|
475
|
+
const manifestLoader = createManifestLoader(
|
|
476
|
+
join(projectRoot, config.agents.manifestPath),
|
|
477
|
+
join(projectRoot, config.agents.baseDir),
|
|
478
|
+
);
|
|
479
|
+
const manifest = await manifestLoader.load();
|
|
480
|
+
const resolvedModel = resolveModel(config, manifest, capability, "opus");
|
|
481
|
+
const runtime = getRuntime(undefined, config, capability);
|
|
482
|
+
|
|
483
|
+
// Deploy hooks to the project root so the coordinator gets event logging,
|
|
484
|
+
// mail check --inject, and activity tracking via the standard hook pipeline.
|
|
485
|
+
// The ENV_GUARD prefix on all hooks (both template and generated guards)
|
|
486
|
+
// ensures they only activate when AGENTPLATE_AGENT_NAME is set (i.e. for
|
|
487
|
+
// the coordinator's tmux session), so the user's own Claude Code session
|
|
488
|
+
// at the project root is unaffected.
|
|
489
|
+
await runtime.deployConfig(projectRoot, undefined, {
|
|
490
|
+
agentName: coordinatorName,
|
|
491
|
+
capability,
|
|
492
|
+
worktreePath: projectRoot,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// Create coordinator identity if first run
|
|
496
|
+
const identityBaseDir = join(projectRoot, ".agentplate", "agents");
|
|
497
|
+
await mkdir(identityBaseDir, { recursive: true });
|
|
498
|
+
const existingIdentity = await loadIdentity(identityBaseDir, coordinatorName);
|
|
499
|
+
if (!existingIdentity) {
|
|
500
|
+
await createIdentity(identityBaseDir, {
|
|
501
|
+
name: coordinatorName,
|
|
502
|
+
capability,
|
|
503
|
+
created: new Date().toISOString(),
|
|
504
|
+
sessionsCompleted: 0,
|
|
505
|
+
expertiseDomains: config.loam.enabled ? config.loam.domains : [],
|
|
506
|
+
recentTasks: [],
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Headless start path: bypass tmux entirely and spawn the coordinator
|
|
511
|
+
// process directly via runtime.buildDirectSpawn(). Same hooks, identity,
|
|
512
|
+
// and run-tracking as the tmux path — only the spawn mechanism differs.
|
|
513
|
+
if (headlessFlag === true) {
|
|
514
|
+
if (!runtime.buildDirectSpawn) {
|
|
515
|
+
throw new ValidationError(
|
|
516
|
+
`Headless coordinator start requires a runtime with buildDirectSpawn (got: ${runtime.id})`,
|
|
517
|
+
{ field: "runtime", value: runtime.id },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const spawnHeadless = deps._spawnHeadless ?? spawnHeadlessAgent;
|
|
522
|
+
const directEnv: Record<string, string> = {
|
|
523
|
+
...runtime.buildEnv(resolvedModel),
|
|
524
|
+
AGENTPLATE_AGENT_NAME: coordinatorName,
|
|
525
|
+
AGENTPLATE_PROJECT_ROOT: projectRoot,
|
|
526
|
+
...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
|
|
527
|
+
};
|
|
528
|
+
const argv = runtime.buildDirectSpawn({
|
|
529
|
+
cwd: projectRoot,
|
|
530
|
+
env: directEnv,
|
|
531
|
+
...(resolvedModel.isExplicitOverride ? { model: resolvedModel.model } : {}),
|
|
532
|
+
instructionPath: runtime.instructionPath,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// Per-session log dir mirrors sling.ts headless path.
|
|
536
|
+
const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
537
|
+
const headlessLogDir = join(agentplateDir, "logs", "coordinator", logTimestamp);
|
|
538
|
+
await mkdir(headlessLogDir, { recursive: true });
|
|
539
|
+
|
|
540
|
+
const headlessProc = await spawnHeadless(argv, {
|
|
541
|
+
cwd: projectRoot,
|
|
542
|
+
env: { ...(process.env as Record<string, string>), ...directEnv },
|
|
543
|
+
stdoutFile: join(headlessLogDir, "stdout.log"),
|
|
544
|
+
stderrFile: join(headlessLogDir, "stderr.log"),
|
|
545
|
+
agentName: coordinatorName,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// Build the initial stdin prompt from agent definition + pending dispatch
|
|
549
|
+
// mail + activation beacon. Replaces SessionStart hooks (no-op headless).
|
|
550
|
+
const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
|
|
551
|
+
const agentDefHandle = Bun.file(agentDefPath);
|
|
552
|
+
const primeContext = (await agentDefHandle.exists()) ? await agentDefHandle.text() : "";
|
|
553
|
+
|
|
554
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
555
|
+
const pendingMailStore = createMailStore(mailDbPath);
|
|
556
|
+
let mailSection = "";
|
|
557
|
+
try {
|
|
558
|
+
const pendingMailClient = createMailClient(pendingMailStore);
|
|
559
|
+
const pendingMessages = pendingMailClient.check(coordinatorName);
|
|
560
|
+
mailSection = formatMailSection(pendingMessages);
|
|
561
|
+
} finally {
|
|
562
|
+
pendingMailStore.close();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
566
|
+
const trackerCli = trackerCliName(resolvedBackend);
|
|
567
|
+
const beacon = beaconBuilder(trackerCli);
|
|
568
|
+
const initialPrompt = buildInitialHeadlessPrompt(
|
|
569
|
+
primeContext || undefined,
|
|
570
|
+
mailSection || undefined,
|
|
571
|
+
beacon,
|
|
572
|
+
);
|
|
573
|
+
await headlessProc.stdin.write(initialPrompt);
|
|
574
|
+
|
|
575
|
+
// Create run record + current-run.txt + session row.
|
|
576
|
+
const sessionId = `session-${Date.now()}-${coordinatorName}`;
|
|
577
|
+
const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
578
|
+
const runStore = createRunStore(join(agentplateDir, "sessions.db"));
|
|
579
|
+
try {
|
|
580
|
+
runStore.createRun({
|
|
581
|
+
id: runId,
|
|
582
|
+
startedAt: new Date().toISOString(),
|
|
583
|
+
coordinatorSessionId: sessionId,
|
|
584
|
+
coordinatorName,
|
|
585
|
+
status: "active",
|
|
586
|
+
});
|
|
587
|
+
} finally {
|
|
588
|
+
runStore.close();
|
|
589
|
+
}
|
|
590
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
591
|
+
|
|
592
|
+
const session: AgentSession = {
|
|
593
|
+
id: sessionId,
|
|
594
|
+
agentName: coordinatorName,
|
|
595
|
+
capability,
|
|
596
|
+
worktreePath: projectRoot,
|
|
597
|
+
branchName: config.project.canonicalBranch,
|
|
598
|
+
taskId: "",
|
|
599
|
+
tmuxSession: "", // headless: no tmux pane
|
|
600
|
+
state: "booting",
|
|
601
|
+
pid: headlessProc.pid,
|
|
602
|
+
parentAgent: null,
|
|
603
|
+
depth: 0,
|
|
604
|
+
runId,
|
|
605
|
+
startedAt: new Date().toISOString(),
|
|
606
|
+
lastActivity: new Date().toISOString(),
|
|
607
|
+
escalationLevel: 0,
|
|
608
|
+
stalledSince: null,
|
|
609
|
+
transcriptPath: null,
|
|
610
|
+
};
|
|
611
|
+
store.upsert(session);
|
|
612
|
+
|
|
613
|
+
// Auto-start watchdog / monitor (same as tmux path).
|
|
614
|
+
let watchdogPid: number | undefined;
|
|
615
|
+
if (watchdogFlag) {
|
|
616
|
+
const watchdogResult = await watchdog.start();
|
|
617
|
+
if (watchdogResult) {
|
|
618
|
+
watchdogPid = watchdogResult.pid;
|
|
619
|
+
if (!json) printHint("Watchdog started");
|
|
620
|
+
} else if (watchdogAlreadyRunning) {
|
|
621
|
+
// createDefaultWatchdog.start() returns null when an existing PID
|
|
622
|
+
// is alive — that's a no-op success, not a failure. Reuse the
|
|
623
|
+
// existing daemon. Sentinel value keeps `watchdogPid !== undefined`
|
|
624
|
+
// truthy in the JSON output.
|
|
625
|
+
watchdogPid = -1;
|
|
626
|
+
if (!json) printHint("Watchdog already running, reusing existing daemon");
|
|
627
|
+
} else {
|
|
628
|
+
if (!json) printWarning("Watchdog failed to start");
|
|
629
|
+
}
|
|
630
|
+
} else if (watchdogAlreadyRunning && acceptExistingWatchdogFlag) {
|
|
631
|
+
// --accept-existing-watchdog without --watchdog: surface that an
|
|
632
|
+
// existing daemon is supervising this run, but do not call start().
|
|
633
|
+
watchdogPid = -1;
|
|
634
|
+
if (!json) printHint("Watchdog already running, reusing existing daemon");
|
|
635
|
+
}
|
|
636
|
+
let monitorPid: number | undefined;
|
|
637
|
+
if (monitorFlag) {
|
|
638
|
+
if (!config.watchdog.tier2Enabled) {
|
|
639
|
+
if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
|
|
640
|
+
} else {
|
|
641
|
+
const monitorResult = await monitor.start([]);
|
|
642
|
+
if (monitorResult) {
|
|
643
|
+
monitorPid = monitorResult.pid;
|
|
644
|
+
if (!json) printHint("Monitor started");
|
|
645
|
+
} else {
|
|
646
|
+
if (!json) printWarning("Monitor failed to start");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const output = {
|
|
652
|
+
agentName: coordinatorName,
|
|
653
|
+
capability,
|
|
654
|
+
tmuxSession: "",
|
|
655
|
+
projectRoot,
|
|
656
|
+
pid: headlessProc.pid,
|
|
657
|
+
headless: true,
|
|
658
|
+
watchdog: watchdogPid !== undefined,
|
|
659
|
+
watchdogPreexisting: watchdogAlreadyRunning,
|
|
660
|
+
monitor: monitorFlag ? monitorPid !== undefined : false,
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
if (json) {
|
|
664
|
+
jsonOutput(`${capability} start`, output);
|
|
665
|
+
} else {
|
|
666
|
+
printSuccess(`${displayName} started (headless)`);
|
|
667
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
668
|
+
process.stdout.write(` PID: ${headlessProc.pid}\n`);
|
|
669
|
+
process.stdout.write(` Logs: ${headlessLogDir}\n`);
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Preflight: verify tmux is installed before attempting to spawn.
|
|
675
|
+
// Without this check, a missing tmux leads to cryptic errors later.
|
|
676
|
+
await tmux.ensureTmuxAvailable();
|
|
677
|
+
|
|
678
|
+
// Spawn tmux session at project root with Claude Code (interactive mode).
|
|
679
|
+
// Inject the coordinator base definition via --append-system-prompt so the
|
|
680
|
+
// coordinator knows its role, hierarchy rules, and delegation patterns
|
|
681
|
+
// (agentplate-gaio, agentplate-0kwf).
|
|
682
|
+
// Pass the file path (not content) so the shell inside the tmux pane reads
|
|
683
|
+
// it via $(cat ...) — avoids tmux IPC "command too long" errors with large
|
|
684
|
+
// agent definitions (agentplate#45).
|
|
685
|
+
const agentDefPath = join(projectRoot, ".agentplate", "agent-defs", agentDefFile);
|
|
686
|
+
const agentDefHandle = Bun.file(agentDefPath);
|
|
687
|
+
let appendSystemPromptFile: string | undefined;
|
|
688
|
+
if (await agentDefHandle.exists()) {
|
|
689
|
+
appendSystemPromptFile = agentDefPath;
|
|
690
|
+
}
|
|
691
|
+
const spawnCmd = runtime.buildSpawnCommand({
|
|
692
|
+
model: resolvedModel.model,
|
|
693
|
+
permissionMode: "bypass",
|
|
694
|
+
cwd: projectRoot,
|
|
695
|
+
appendSystemPromptFile,
|
|
696
|
+
env: {
|
|
697
|
+
...runtime.buildEnv(resolvedModel),
|
|
698
|
+
AGENTPLATE_AGENT_NAME: coordinatorName,
|
|
699
|
+
...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
|
|
700
|
+
},
|
|
701
|
+
});
|
|
702
|
+
const pid = await tmux.createSession(tmuxSession, projectRoot, spawnCmd, {
|
|
703
|
+
...runtime.buildEnv(resolvedModel),
|
|
704
|
+
AGENTPLATE_AGENT_NAME: coordinatorName,
|
|
705
|
+
...(profileFlag ? { AGENTPLATE_PROFILE: profileFlag } : {}),
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Create a run for this coordinator session BEFORE recording the session,
|
|
709
|
+
// so the session can reference the run ID from the start.
|
|
710
|
+
const sessionId = `session-${Date.now()}-${coordinatorName}`;
|
|
711
|
+
const runId = `run-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
712
|
+
const runStore = createRunStore(join(agentplateDir, "sessions.db"));
|
|
713
|
+
try {
|
|
714
|
+
runStore.createRun({
|
|
715
|
+
id: runId,
|
|
716
|
+
startedAt: new Date().toISOString(),
|
|
717
|
+
coordinatorSessionId: sessionId,
|
|
718
|
+
coordinatorName,
|
|
719
|
+
status: "active",
|
|
720
|
+
});
|
|
721
|
+
} finally {
|
|
722
|
+
runStore.close();
|
|
723
|
+
}
|
|
724
|
+
// Write current-run.txt for backward compatibility with ap sling and other consumers.
|
|
725
|
+
await Bun.write(join(agentplateDir, "current-run.txt"), runId);
|
|
726
|
+
|
|
727
|
+
// Record session BEFORE sending the beacon so that hook-triggered
|
|
728
|
+
// updateLastActivity() can find the entry and transition booting->working.
|
|
729
|
+
// Without this, a race exists: hooks fire before the session is persisted,
|
|
730
|
+
// leaving the coordinator stuck in "booting" (agentplate-036f).
|
|
731
|
+
const session: AgentSession = {
|
|
732
|
+
id: sessionId,
|
|
733
|
+
agentName: coordinatorName,
|
|
734
|
+
capability,
|
|
735
|
+
worktreePath: projectRoot, // Coordinator uses project root, not a worktree
|
|
736
|
+
branchName: config.project.canonicalBranch, // Operates on canonical branch
|
|
737
|
+
taskId: "", // No specific task assignment
|
|
738
|
+
tmuxSession,
|
|
739
|
+
state: "booting",
|
|
740
|
+
pid,
|
|
741
|
+
parentAgent: null, // Top of hierarchy
|
|
742
|
+
depth: 0,
|
|
743
|
+
runId,
|
|
744
|
+
startedAt: new Date().toISOString(),
|
|
745
|
+
lastActivity: new Date().toISOString(),
|
|
746
|
+
escalationLevel: 0,
|
|
747
|
+
stalledSince: null,
|
|
748
|
+
transcriptPath: null,
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
store.upsert(session);
|
|
752
|
+
|
|
753
|
+
// Give slow shells time to finish initializing before polling for TUI readiness.
|
|
754
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
755
|
+
if (shellDelay > 0) {
|
|
756
|
+
await Bun.sleep(shellDelay);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Wait for Claude Code TUI to render before sending input
|
|
760
|
+
const tuiReady = await tmux.waitForTuiReady(tmuxSession, (content) =>
|
|
761
|
+
runtime.detectReady(content),
|
|
762
|
+
);
|
|
763
|
+
if (!tuiReady) {
|
|
764
|
+
// Session may have died — check liveness before proceeding
|
|
765
|
+
const alive = await tmux.isSessionAlive(tmuxSession);
|
|
766
|
+
if (!alive) {
|
|
767
|
+
// Clean up the stale session record
|
|
768
|
+
store.updateState(coordinatorName, "completed");
|
|
769
|
+
const sessionState = await tmux.checkSessionState(tmuxSession);
|
|
770
|
+
const detail =
|
|
771
|
+
sessionState === "no_server"
|
|
772
|
+
? "The tmux server is no longer running. It may have crashed or been killed externally."
|
|
773
|
+
: "The Claude Code process may have crashed or exited immediately. Check tmux logs or try running the claude command manually.";
|
|
774
|
+
throw new AgentError(
|
|
775
|
+
`${displayName} tmux session "${tmuxSession}" died during startup. ${detail}`,
|
|
776
|
+
{ agentName: coordinatorName },
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
await tmux.killSession(tmuxSession);
|
|
780
|
+
store.updateState(coordinatorName, "completed");
|
|
781
|
+
throw new AgentError(
|
|
782
|
+
`${displayName} tmux session "${tmuxSession}" did not become ready during startup. Claude Code may still be waiting on an interactive dialog or initializing too slowly.`,
|
|
783
|
+
{ agentName: coordinatorName },
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
await Bun.sleep(1_000);
|
|
787
|
+
|
|
788
|
+
const resolvedBackend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
789
|
+
const trackerCli = trackerCliName(resolvedBackend);
|
|
790
|
+
const beacon = beaconBuilder(trackerCli);
|
|
791
|
+
await tmux.sendKeys(tmuxSession, beacon);
|
|
792
|
+
|
|
793
|
+
// Follow-up Enters with increasing delays to ensure submission
|
|
794
|
+
for (const delay of [1_000, 2_000, 3_000, 5_000]) {
|
|
795
|
+
await Bun.sleep(delay);
|
|
796
|
+
await tmux.sendKeys(tmuxSession, "");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Auto-start watchdog if --watchdog flag is present.
|
|
800
|
+
let watchdogPid: number | undefined;
|
|
801
|
+
if (watchdogFlag) {
|
|
802
|
+
const watchdogResult = await watchdog.start();
|
|
803
|
+
if (watchdogResult) {
|
|
804
|
+
watchdogPid = watchdogResult.pid;
|
|
805
|
+
if (!json) printHint("Watchdog started");
|
|
806
|
+
} else if (watchdogAlreadyRunning) {
|
|
807
|
+
// createDefaultWatchdog.start() returns null when an existing PID
|
|
808
|
+
// is alive — that's a no-op success, not a failure. Reuse the
|
|
809
|
+
// existing daemon. Sentinel value keeps `watchdogPid !== undefined`
|
|
810
|
+
// truthy in the JSON output.
|
|
811
|
+
watchdogPid = -1;
|
|
812
|
+
if (!json) printHint("Watchdog already running, reusing existing daemon");
|
|
813
|
+
} else {
|
|
814
|
+
if (!json) printWarning("Watchdog failed to start");
|
|
815
|
+
}
|
|
816
|
+
} else if (watchdogAlreadyRunning && acceptExistingWatchdogFlag) {
|
|
817
|
+
// --accept-existing-watchdog without --watchdog: surface that an
|
|
818
|
+
// existing daemon is supervising this run, but do not call start().
|
|
819
|
+
watchdogPid = -1;
|
|
820
|
+
if (!json) printHint("Watchdog already running, reusing existing daemon");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Auto-start monitor if --monitor flag is present and tier2 is enabled
|
|
824
|
+
let monitorPid: number | undefined;
|
|
825
|
+
if (monitorFlag) {
|
|
826
|
+
if (!config.watchdog.tier2Enabled) {
|
|
827
|
+
if (!json) printWarning("Monitor skipped", "watchdog.tier2Enabled is false");
|
|
828
|
+
} else {
|
|
829
|
+
const monitorResult = await monitor.start([]);
|
|
830
|
+
if (monitorResult) {
|
|
831
|
+
monitorPid = monitorResult.pid;
|
|
832
|
+
if (!json) printHint("Monitor started");
|
|
833
|
+
} else {
|
|
834
|
+
if (!json) printWarning("Monitor failed to start");
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const output = {
|
|
840
|
+
agentName: coordinatorName,
|
|
841
|
+
capability,
|
|
842
|
+
tmuxSession,
|
|
843
|
+
projectRoot,
|
|
844
|
+
pid,
|
|
845
|
+
watchdog: watchdogPid !== undefined,
|
|
846
|
+
watchdogPreexisting: watchdogAlreadyRunning,
|
|
847
|
+
monitor: monitorFlag ? monitorPid !== undefined : false,
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
if (json) {
|
|
851
|
+
jsonOutput(`${capability} start`, output);
|
|
852
|
+
} else {
|
|
853
|
+
printSuccess(`${displayName} started`);
|
|
854
|
+
process.stdout.write(` Tmux: ${tmuxSession}\n`);
|
|
855
|
+
process.stdout.write(` Root: ${projectRoot}\n`);
|
|
856
|
+
process.stdout.write(` PID: ${pid}\n`);
|
|
857
|
+
printHint("Open the UI: `ap serve` then http://localhost:7321 — primary operator surface");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
if (shouldAttach) {
|
|
861
|
+
Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
|
|
862
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
} finally {
|
|
866
|
+
store.close();
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function startPersistentAgent(
|
|
871
|
+
spec: PersistentAgentSpec,
|
|
872
|
+
opts: {
|
|
873
|
+
json: boolean;
|
|
874
|
+
attach: boolean;
|
|
875
|
+
watchdog: boolean;
|
|
876
|
+
monitor: boolean;
|
|
877
|
+
profile?: string;
|
|
878
|
+
acceptExistingWatchdog?: boolean;
|
|
879
|
+
},
|
|
880
|
+
deps: CoordinatorDeps = {},
|
|
881
|
+
): Promise<void> {
|
|
882
|
+
await startCoordinatorSession(
|
|
883
|
+
{
|
|
884
|
+
...opts,
|
|
885
|
+
agentName: spec.agentName,
|
|
886
|
+
capability: spec.capability,
|
|
887
|
+
agentDefFile: spec.agentDefFile,
|
|
888
|
+
displayName: spec.displayName,
|
|
889
|
+
beaconBuilder: spec.beaconBuilder,
|
|
890
|
+
},
|
|
891
|
+
deps,
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isActivePersistentAgentSession(
|
|
896
|
+
session: AgentSession | null,
|
|
897
|
+
spec: PersistentAgentSpec,
|
|
898
|
+
): session is AgentSession {
|
|
899
|
+
return (
|
|
900
|
+
session !== null &&
|
|
901
|
+
session.capability === spec.capability &&
|
|
902
|
+
session.state !== "completed" &&
|
|
903
|
+
session.state !== "zombie"
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Liveness check that handles both tmux and headless persistent agents.
|
|
909
|
+
*
|
|
910
|
+
* Tmux-backed sessions check the tmux server. Headless sessions
|
|
911
|
+
* (`tmuxSession === ""`) fall back to OS-level PID liveness via
|
|
912
|
+
* `isProcessRunning(session.pid)`. Without this branch, callers that asked
|
|
913
|
+
* tmux about an empty session name got `false` and incorrectly flipped a
|
|
914
|
+
* healthy headless coordinator to `zombie` (agentplate-34a6).
|
|
915
|
+
*/
|
|
916
|
+
async function isPersistentAgentAlive(
|
|
917
|
+
session: AgentSession,
|
|
918
|
+
tmux: { isSessionAlive: (name: string) => Promise<boolean> },
|
|
919
|
+
): Promise<boolean> {
|
|
920
|
+
if (session.tmuxSession === "") {
|
|
921
|
+
return session.pid !== null && isProcessRunning(session.pid);
|
|
922
|
+
}
|
|
923
|
+
return await tmux.isSessionAlive(session.tmuxSession);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Stop the coordinator agent.
|
|
928
|
+
*
|
|
929
|
+
* 1. Find the active coordinator session
|
|
930
|
+
* 2. Kill the tmux session (with process tree cleanup)
|
|
931
|
+
* 3. Mark session as completed in SessionStore
|
|
932
|
+
* 4. Auto-complete the active run (if current-run.txt exists)
|
|
933
|
+
*/
|
|
934
|
+
/**
|
|
935
|
+
* Stop the default coordinator. Handles both tmux and headless sessions.
|
|
936
|
+
* Exposed for callers outside the CLI command surface (e.g. the web-UI POST
|
|
937
|
+
* /api/coordinator/stop endpoint, which lives in coordinator-actions.ts).
|
|
938
|
+
*/
|
|
939
|
+
export async function stopCoordinatorSession(
|
|
940
|
+
opts: { json: boolean },
|
|
941
|
+
deps: CoordinatorDeps = {},
|
|
942
|
+
): Promise<void> {
|
|
943
|
+
await stopPersistentAgent(COORDINATOR_SPEC, opts, deps);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function stopPersistentAgent(
|
|
947
|
+
spec: PersistentAgentSpec,
|
|
948
|
+
opts: { json: boolean },
|
|
949
|
+
deps: CoordinatorDeps = {},
|
|
950
|
+
): Promise<void> {
|
|
951
|
+
const tmux = deps._tmux ?? {
|
|
952
|
+
createSession,
|
|
953
|
+
isSessionAlive,
|
|
954
|
+
checkSessionState,
|
|
955
|
+
killSession,
|
|
956
|
+
sendKeys,
|
|
957
|
+
waitForTuiReady,
|
|
958
|
+
ensureTmuxAvailable,
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
const { json } = opts;
|
|
962
|
+
const cwd = process.cwd();
|
|
963
|
+
const config = await loadConfig(cwd);
|
|
964
|
+
const projectRoot = config.project.root;
|
|
965
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
966
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
967
|
+
|
|
968
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
969
|
+
const { store } = openSessionStore(agentplateDir);
|
|
970
|
+
try {
|
|
971
|
+
const session = store.getByName(spec.agentName);
|
|
972
|
+
|
|
973
|
+
if (!isActivePersistentAgentSession(session, spec)) {
|
|
974
|
+
throw new AgentError(`No active ${spec.commandName} session found`, {
|
|
975
|
+
agentName: spec.agentName,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Headless sessions have no tmux pane (tmuxSession === ""). Tear down via
|
|
980
|
+
// the connection registry (SIGTERM-with-SIGKILL-escalation) and skip tmux.
|
|
981
|
+
if (session.tmuxSession === "") {
|
|
982
|
+
const { removeConnection } = await import("../runtimes/connections.ts");
|
|
983
|
+
removeConnection(spec.agentName);
|
|
984
|
+
if (session.pid !== null && isProcessRunning(session.pid)) {
|
|
985
|
+
try {
|
|
986
|
+
process.kill(session.pid, "SIGTERM");
|
|
987
|
+
} catch {
|
|
988
|
+
// process may have exited between the check and the signal
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
} else {
|
|
992
|
+
// Kill tmux session with process tree cleanup
|
|
993
|
+
const alive = await tmux.isSessionAlive(session.tmuxSession);
|
|
994
|
+
if (alive) {
|
|
995
|
+
await tmux.killSession(session.tmuxSession);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Always attempt to stop watchdog
|
|
1000
|
+
const watchdogStopped = await watchdog.stop();
|
|
1001
|
+
|
|
1002
|
+
// Always attempt to stop monitor
|
|
1003
|
+
const monitorStopped = await monitor.stop();
|
|
1004
|
+
|
|
1005
|
+
// Update session state
|
|
1006
|
+
store.updateState(spec.agentName, "completed");
|
|
1007
|
+
store.updateLastActivity(spec.agentName);
|
|
1008
|
+
|
|
1009
|
+
// Auto-complete the current run
|
|
1010
|
+
let runCompleted = false;
|
|
1011
|
+
try {
|
|
1012
|
+
const currentRunPath = join(agentplateDir, "current-run.txt");
|
|
1013
|
+
const currentRunFile = Bun.file(currentRunPath);
|
|
1014
|
+
if (await currentRunFile.exists()) {
|
|
1015
|
+
const runId = (await currentRunFile.text()).trim();
|
|
1016
|
+
if (runId.length > 0) {
|
|
1017
|
+
const runStore = createRunStore(join(agentplateDir, "sessions.db"));
|
|
1018
|
+
try {
|
|
1019
|
+
runStore.completeRun(runId, "completed");
|
|
1020
|
+
runCompleted = true;
|
|
1021
|
+
} finally {
|
|
1022
|
+
runStore.close();
|
|
1023
|
+
}
|
|
1024
|
+
try {
|
|
1025
|
+
await unlink(currentRunPath);
|
|
1026
|
+
} catch {
|
|
1027
|
+
// File may already be gone
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
} catch {
|
|
1032
|
+
// Non-fatal: run completion should not break coordinator stop
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (json) {
|
|
1036
|
+
jsonOutput(`${spec.commandName} stop`, {
|
|
1037
|
+
stopped: true,
|
|
1038
|
+
sessionId: session.id,
|
|
1039
|
+
watchdogStopped,
|
|
1040
|
+
monitorStopped,
|
|
1041
|
+
runCompleted,
|
|
1042
|
+
});
|
|
1043
|
+
} else {
|
|
1044
|
+
printSuccess(`${spec.displayName} stopped`, session.id);
|
|
1045
|
+
if (watchdogStopped) {
|
|
1046
|
+
printHint("Watchdog stopped");
|
|
1047
|
+
} else {
|
|
1048
|
+
printHint("No watchdog running");
|
|
1049
|
+
}
|
|
1050
|
+
if (monitorStopped) {
|
|
1051
|
+
printHint("Monitor stopped");
|
|
1052
|
+
} else {
|
|
1053
|
+
printHint("No monitor running");
|
|
1054
|
+
}
|
|
1055
|
+
if (runCompleted) {
|
|
1056
|
+
printHint("Run completed");
|
|
1057
|
+
} else {
|
|
1058
|
+
printHint("No active run");
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
} finally {
|
|
1062
|
+
store.close();
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* Show coordinator status.
|
|
1068
|
+
*
|
|
1069
|
+
* Checks session registry and tmux liveness to report actual state.
|
|
1070
|
+
*/
|
|
1071
|
+
async function statusPersistentAgent(
|
|
1072
|
+
spec: PersistentAgentSpec,
|
|
1073
|
+
opts: { json: boolean },
|
|
1074
|
+
deps: CoordinatorDeps = {},
|
|
1075
|
+
): Promise<void> {
|
|
1076
|
+
const tmux = deps._tmux ?? {
|
|
1077
|
+
createSession,
|
|
1078
|
+
isSessionAlive,
|
|
1079
|
+
checkSessionState,
|
|
1080
|
+
killSession,
|
|
1081
|
+
sendKeys,
|
|
1082
|
+
waitForTuiReady,
|
|
1083
|
+
ensureTmuxAvailable,
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const { json } = opts;
|
|
1087
|
+
const cwd = process.cwd();
|
|
1088
|
+
const config = await loadConfig(cwd);
|
|
1089
|
+
const projectRoot = config.project.root;
|
|
1090
|
+
const watchdog = deps._watchdog ?? createDefaultWatchdog(projectRoot);
|
|
1091
|
+
const monitor = deps._monitor ?? createDefaultMonitor(projectRoot);
|
|
1092
|
+
|
|
1093
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
1094
|
+
const { store } = openSessionStore(agentplateDir);
|
|
1095
|
+
try {
|
|
1096
|
+
const session = store.getByName(spec.agentName);
|
|
1097
|
+
const watchdogRunning = await watchdog.isRunning();
|
|
1098
|
+
const monitorRunning = await monitor.isRunning();
|
|
1099
|
+
|
|
1100
|
+
if (!isActivePersistentAgentSession(session, spec)) {
|
|
1101
|
+
if (json) {
|
|
1102
|
+
jsonOutput(`${spec.commandName} status`, {
|
|
1103
|
+
running: false,
|
|
1104
|
+
watchdogRunning,
|
|
1105
|
+
monitorRunning,
|
|
1106
|
+
});
|
|
1107
|
+
} else {
|
|
1108
|
+
printHint(`${spec.displayName} is not running`);
|
|
1109
|
+
if (watchdogRunning) {
|
|
1110
|
+
printHint("Watchdog: running");
|
|
1111
|
+
}
|
|
1112
|
+
if (monitorRunning) {
|
|
1113
|
+
printHint("Monitor: running");
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const alive = await isPersistentAgentAlive(session, tmux);
|
|
1120
|
+
|
|
1121
|
+
// Reconcile state: if session says active but the underlying process/tmux
|
|
1122
|
+
// is dead, update. We already filtered out completed/zombie states above,
|
|
1123
|
+
// so if it's actually dead this session needs to be marked as zombie.
|
|
1124
|
+
if (!alive) {
|
|
1125
|
+
store.updateState(spec.agentName, "zombie");
|
|
1126
|
+
store.updateLastActivity(spec.agentName);
|
|
1127
|
+
session.state = "zombie";
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const status = {
|
|
1131
|
+
running: alive,
|
|
1132
|
+
sessionId: session.id,
|
|
1133
|
+
state: session.state,
|
|
1134
|
+
tmuxSession: session.tmuxSession,
|
|
1135
|
+
pid: session.pid,
|
|
1136
|
+
startedAt: session.startedAt,
|
|
1137
|
+
lastActivity: session.lastActivity,
|
|
1138
|
+
watchdogRunning,
|
|
1139
|
+
monitorRunning,
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
if (json) {
|
|
1143
|
+
jsonOutput(`${spec.commandName} status`, status);
|
|
1144
|
+
} else {
|
|
1145
|
+
const stateLabel = alive ? "running" : session.state;
|
|
1146
|
+
process.stdout.write(`${spec.displayName}: ${stateLabel}\n`);
|
|
1147
|
+
process.stdout.write(` Session: ${session.id}\n`);
|
|
1148
|
+
process.stdout.write(` Tmux: ${session.tmuxSession}\n`);
|
|
1149
|
+
process.stdout.write(` PID: ${session.pid}\n`);
|
|
1150
|
+
process.stdout.write(` Started: ${session.startedAt}\n`);
|
|
1151
|
+
process.stdout.write(` Activity: ${session.lastActivity}\n`);
|
|
1152
|
+
process.stdout.write(` Watchdog: ${watchdogRunning ? "running" : "not running"}\n`);
|
|
1153
|
+
process.stdout.write(` Monitor: ${monitorRunning ? "running" : "not running"}\n`);
|
|
1154
|
+
}
|
|
1155
|
+
} finally {
|
|
1156
|
+
store.close();
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Send a fire-and-forget message to the running coordinator.
|
|
1162
|
+
*
|
|
1163
|
+
* Sends a mail message (from: operator, type: dispatch) and auto-nudges the
|
|
1164
|
+
* coordinator via tmux sendKeys. Replaces the two-step `ap mail send + ap nudge` pattern.
|
|
1165
|
+
*/
|
|
1166
|
+
async function sendToPersistentAgent(
|
|
1167
|
+
spec: PersistentAgentSpec,
|
|
1168
|
+
body: string,
|
|
1169
|
+
opts: { subject: string; json: boolean },
|
|
1170
|
+
deps: CoordinatorDeps = {},
|
|
1171
|
+
): Promise<void> {
|
|
1172
|
+
const tmux = deps._tmux ?? {
|
|
1173
|
+
createSession,
|
|
1174
|
+
isSessionAlive,
|
|
1175
|
+
checkSessionState,
|
|
1176
|
+
killSession,
|
|
1177
|
+
sendKeys,
|
|
1178
|
+
waitForTuiReady,
|
|
1179
|
+
ensureTmuxAvailable,
|
|
1180
|
+
};
|
|
1181
|
+
const nudge = deps._nudge ?? nudgeAgent;
|
|
1182
|
+
|
|
1183
|
+
const { subject, json } = opts;
|
|
1184
|
+
const cwd = process.cwd();
|
|
1185
|
+
const config = await loadConfig(cwd);
|
|
1186
|
+
const projectRoot = config.project.root;
|
|
1187
|
+
|
|
1188
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
1189
|
+
const { store } = openSessionStore(agentplateDir);
|
|
1190
|
+
try {
|
|
1191
|
+
const session = store.getByName(spec.agentName);
|
|
1192
|
+
|
|
1193
|
+
if (!isActivePersistentAgentSession(session, spec)) {
|
|
1194
|
+
throw new AgentError(`No active ${spec.commandName} session found`, {
|
|
1195
|
+
agentName: spec.agentName,
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const alive = await isPersistentAgentAlive(session, tmux);
|
|
1200
|
+
if (!alive) {
|
|
1201
|
+
store.updateState(spec.agentName, "zombie");
|
|
1202
|
+
store.updateLastActivity(spec.agentName);
|
|
1203
|
+
const target =
|
|
1204
|
+
session.tmuxSession === ""
|
|
1205
|
+
? `process pid ${session.pid ?? "unknown"}`
|
|
1206
|
+
: `tmux session "${session.tmuxSession}"`;
|
|
1207
|
+
throw new AgentError(`${spec.displayName} ${target} is not alive`, {
|
|
1208
|
+
agentName: spec.agentName,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// Send mail
|
|
1213
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
1214
|
+
const mailStore = createMailStore(mailDbPath);
|
|
1215
|
+
const mailClient = createMailClient(mailStore);
|
|
1216
|
+
let id: string;
|
|
1217
|
+
try {
|
|
1218
|
+
id = mailClient.send({
|
|
1219
|
+
from: "operator",
|
|
1220
|
+
to: spec.agentName,
|
|
1221
|
+
subject,
|
|
1222
|
+
body,
|
|
1223
|
+
type: "dispatch",
|
|
1224
|
+
priority: "normal",
|
|
1225
|
+
});
|
|
1226
|
+
} finally {
|
|
1227
|
+
mailClient.close();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Auto-nudge (fire-and-forget)
|
|
1231
|
+
const nudgeMessage = `[DISPATCH] ${subject}: ${body.slice(0, 500)}`;
|
|
1232
|
+
let nudged = false;
|
|
1233
|
+
try {
|
|
1234
|
+
const nudgeResult = await nudge(projectRoot, spec.agentName, nudgeMessage, true);
|
|
1235
|
+
nudged = nudgeResult.delivered;
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Nudge is fire-and-forget — silently ignore errors
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
if (json) {
|
|
1241
|
+
jsonOutput(`${spec.commandName} send`, { id, nudged });
|
|
1242
|
+
} else {
|
|
1243
|
+
printSuccess(`Sent to ${spec.commandName}`, id);
|
|
1244
|
+
}
|
|
1245
|
+
} finally {
|
|
1246
|
+
store.close();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Send a synchronous request to the coordinator and wait for a reply.
|
|
1252
|
+
*
|
|
1253
|
+
* Sends a mail message (from: operator, type: dispatch) with a correlationId,
|
|
1254
|
+
* auto-nudges the coordinator via tmux, then polls mail.db for a reply in the
|
|
1255
|
+
* same thread. Prints the reply body (or structured JSON) and exits.
|
|
1256
|
+
* Throws AgentError if no reply arrives before the timeout.
|
|
1257
|
+
*/
|
|
1258
|
+
export async function askCoordinator(
|
|
1259
|
+
body: string,
|
|
1260
|
+
opts: { subject: string; timeout: number; json: boolean },
|
|
1261
|
+
deps: CoordinatorDeps = {},
|
|
1262
|
+
): Promise<void> {
|
|
1263
|
+
await askPersistentAgent(COORDINATOR_SPEC, body, opts, deps);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
export async function askPersistentAgent(
|
|
1267
|
+
spec: PersistentAgentSpec,
|
|
1268
|
+
body: string,
|
|
1269
|
+
opts: { subject: string; timeout: number; json: boolean },
|
|
1270
|
+
deps: CoordinatorDeps = {},
|
|
1271
|
+
): Promise<void> {
|
|
1272
|
+
const tmux = deps._tmux ?? {
|
|
1273
|
+
createSession,
|
|
1274
|
+
isSessionAlive,
|
|
1275
|
+
checkSessionState,
|
|
1276
|
+
killSession,
|
|
1277
|
+
sendKeys,
|
|
1278
|
+
waitForTuiReady,
|
|
1279
|
+
ensureTmuxAvailable,
|
|
1280
|
+
};
|
|
1281
|
+
const nudge = deps._nudge ?? nudgeAgent;
|
|
1282
|
+
const pollIntervalMs = deps._pollIntervalMs ?? ASK_POLL_INTERVAL_MS;
|
|
1283
|
+
|
|
1284
|
+
const { subject, timeout, json } = opts;
|
|
1285
|
+
const cwd = process.cwd();
|
|
1286
|
+
const config = await loadConfig(cwd);
|
|
1287
|
+
const projectRoot = config.project.root;
|
|
1288
|
+
|
|
1289
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
1290
|
+
const { store } = openSessionStore(agentplateDir);
|
|
1291
|
+
try {
|
|
1292
|
+
const session = store.getByName(spec.agentName);
|
|
1293
|
+
|
|
1294
|
+
if (!isActivePersistentAgentSession(session, spec)) {
|
|
1295
|
+
throw new AgentError(`No active ${spec.commandName} session found`, {
|
|
1296
|
+
agentName: spec.agentName,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
const alive = await isPersistentAgentAlive(session, tmux);
|
|
1301
|
+
if (!alive) {
|
|
1302
|
+
store.updateState(spec.agentName, "zombie");
|
|
1303
|
+
store.updateLastActivity(spec.agentName);
|
|
1304
|
+
const target =
|
|
1305
|
+
session.tmuxSession === ""
|
|
1306
|
+
? `process pid ${session.pid ?? "unknown"}`
|
|
1307
|
+
: `tmux session "${session.tmuxSession}"`;
|
|
1308
|
+
throw new AgentError(`${spec.displayName} ${target} is not alive`, {
|
|
1309
|
+
agentName: spec.agentName,
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Generate correlation ID for tracking this request/response pair
|
|
1314
|
+
const correlationId = crypto.randomUUID();
|
|
1315
|
+
|
|
1316
|
+
// Send mail with correlationId in payload
|
|
1317
|
+
const mailDbPath = join(agentplateDir, "mail.db");
|
|
1318
|
+
const mailStore = createMailStore(mailDbPath);
|
|
1319
|
+
const mailClient = createMailClient(mailStore);
|
|
1320
|
+
let sentId: string;
|
|
1321
|
+
try {
|
|
1322
|
+
sentId = mailClient.send({
|
|
1323
|
+
from: "operator",
|
|
1324
|
+
to: spec.agentName,
|
|
1325
|
+
subject,
|
|
1326
|
+
body,
|
|
1327
|
+
type: "dispatch",
|
|
1328
|
+
priority: "normal",
|
|
1329
|
+
payload: JSON.stringify({ correlationId }),
|
|
1330
|
+
});
|
|
1331
|
+
} finally {
|
|
1332
|
+
mailClient.close();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Auto-nudge (fire-and-forget)
|
|
1336
|
+
const nudgeMessage = `[ASK] ${subject}: ${body.slice(0, 500)}`;
|
|
1337
|
+
try {
|
|
1338
|
+
await nudge(projectRoot, spec.agentName, nudgeMessage, true);
|
|
1339
|
+
} catch {
|
|
1340
|
+
// Nudge is fire-and-forget — silently ignore errors
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Poll for a reply in the same thread
|
|
1344
|
+
const deadline = Date.now() + timeout * 1000;
|
|
1345
|
+
while (Date.now() < deadline) {
|
|
1346
|
+
await Bun.sleep(pollIntervalMs);
|
|
1347
|
+
// Open a fresh store connection each cycle so we see the latest committed writes
|
|
1348
|
+
const pollStore = createMailStore(mailDbPath);
|
|
1349
|
+
let reply: import("../types.ts").MailMessage | undefined;
|
|
1350
|
+
try {
|
|
1351
|
+
const replies = pollStore.getByThread(sentId);
|
|
1352
|
+
reply = replies.find((m) => m.from === spec.agentName && m.to === "operator");
|
|
1353
|
+
} finally {
|
|
1354
|
+
pollStore.close();
|
|
1355
|
+
}
|
|
1356
|
+
if (reply) {
|
|
1357
|
+
if (json) {
|
|
1358
|
+
jsonOutput(`${spec.commandName} ask`, {
|
|
1359
|
+
correlationId,
|
|
1360
|
+
sentId,
|
|
1361
|
+
replyId: reply.id,
|
|
1362
|
+
subject: reply.subject,
|
|
1363
|
+
body: reply.body,
|
|
1364
|
+
payload: reply.payload,
|
|
1365
|
+
});
|
|
1366
|
+
} else {
|
|
1367
|
+
process.stdout.write(`${reply.body}\n`);
|
|
1368
|
+
}
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
throw new AgentError(
|
|
1374
|
+
`Timed out after ${timeout}s waiting for ${spec.commandName} reply (correlationId: ${correlationId})`,
|
|
1375
|
+
{ agentName: spec.agentName },
|
|
1376
|
+
);
|
|
1377
|
+
} finally {
|
|
1378
|
+
store.close();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Show recent coordinator tmux pane content without attaching.
|
|
1384
|
+
*
|
|
1385
|
+
* Wraps capturePaneContent() from tmux.ts. Supports --follow for continuous polling.
|
|
1386
|
+
*/
|
|
1387
|
+
async function outputPersistentAgent(
|
|
1388
|
+
spec: PersistentAgentSpec,
|
|
1389
|
+
opts: { follow: boolean; lines: number; interval: number; json: boolean },
|
|
1390
|
+
deps: CoordinatorDeps = {},
|
|
1391
|
+
): Promise<void> {
|
|
1392
|
+
const tmux = deps._tmux ?? {
|
|
1393
|
+
createSession,
|
|
1394
|
+
isSessionAlive,
|
|
1395
|
+
checkSessionState,
|
|
1396
|
+
killSession,
|
|
1397
|
+
sendKeys,
|
|
1398
|
+
waitForTuiReady,
|
|
1399
|
+
ensureTmuxAvailable,
|
|
1400
|
+
};
|
|
1401
|
+
const capturePane = deps._capturePaneContent ?? capturePaneContent;
|
|
1402
|
+
|
|
1403
|
+
const { follow, lines, interval, json } = opts;
|
|
1404
|
+
const cwd = process.cwd();
|
|
1405
|
+
const config = await loadConfig(cwd);
|
|
1406
|
+
const projectRoot = config.project.root;
|
|
1407
|
+
|
|
1408
|
+
const agentplateDir = join(projectRoot, ".agentplate");
|
|
1409
|
+
const { store } = openSessionStore(agentplateDir);
|
|
1410
|
+
try {
|
|
1411
|
+
const session = store.getByName(spec.agentName);
|
|
1412
|
+
|
|
1413
|
+
if (!isActivePersistentAgentSession(session, spec)) {
|
|
1414
|
+
throw new AgentError(`No active ${spec.commandName} session found`, {
|
|
1415
|
+
agentName: spec.agentName,
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Headless sessions have no tmux pane to capture from. Surface a clear
|
|
1420
|
+
// error instead of falling through to capture-pane on an empty session
|
|
1421
|
+
// name (which would otherwise return null and confuse callers).
|
|
1422
|
+
if (session.tmuxSession === "") {
|
|
1423
|
+
throw new AgentError(
|
|
1424
|
+
`${spec.displayName} is running headless — no tmux pane to capture. Use 'ap logs --agent ${spec.agentName}' instead.`,
|
|
1425
|
+
{ agentName: spec.agentName },
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
const alive = await isPersistentAgentAlive(session, tmux);
|
|
1430
|
+
if (!alive) {
|
|
1431
|
+
store.updateState(spec.agentName, "zombie");
|
|
1432
|
+
store.updateLastActivity(spec.agentName);
|
|
1433
|
+
throw new AgentError(
|
|
1434
|
+
`${spec.displayName} tmux session "${session.tmuxSession}" is not alive`,
|
|
1435
|
+
{
|
|
1436
|
+
agentName: spec.agentName,
|
|
1437
|
+
},
|
|
1438
|
+
);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
const tmuxSession = session.tmuxSession;
|
|
1442
|
+
|
|
1443
|
+
if (follow) {
|
|
1444
|
+
// Set up SIGINT handler for clean exit
|
|
1445
|
+
let running = true;
|
|
1446
|
+
process.once("SIGINT", () => {
|
|
1447
|
+
running = false;
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
while (running) {
|
|
1451
|
+
const content = await capturePane(tmuxSession, lines);
|
|
1452
|
+
if (json) {
|
|
1453
|
+
jsonOutput(`${spec.commandName} output`, { content, lines });
|
|
1454
|
+
} else {
|
|
1455
|
+
process.stdout.write(content ?? "");
|
|
1456
|
+
}
|
|
1457
|
+
if (running) {
|
|
1458
|
+
await Bun.sleep(interval);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
} else {
|
|
1462
|
+
const content = await capturePane(tmuxSession, lines);
|
|
1463
|
+
if (json) {
|
|
1464
|
+
jsonOutput(`${spec.commandName} output`, { content, lines });
|
|
1465
|
+
} else {
|
|
1466
|
+
process.stdout.write(content ?? "");
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
} finally {
|
|
1470
|
+
store.close();
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** Per-trigger evaluation result for checkComplete. */
|
|
1475
|
+
export interface TriggerResult {
|
|
1476
|
+
enabled: boolean;
|
|
1477
|
+
met: boolean;
|
|
1478
|
+
detail: string;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** Result of `ap coordinator check-complete`. */
|
|
1482
|
+
export interface CheckCompleteResult {
|
|
1483
|
+
complete: boolean;
|
|
1484
|
+
triggers: {
|
|
1485
|
+
allAgentsDone: TriggerResult;
|
|
1486
|
+
taskTrackerEmpty: TriggerResult;
|
|
1487
|
+
onShutdownSignal: TriggerResult;
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* Evaluate configured exit triggers and return per-trigger status.
|
|
1493
|
+
*
|
|
1494
|
+
* Logic:
|
|
1495
|
+
* - complete = true only if ALL enabled triggers are met
|
|
1496
|
+
* - No enabled triggers → complete: false (safety default)
|
|
1497
|
+
*/
|
|
1498
|
+
export async function checkComplete(
|
|
1499
|
+
opts: { json: boolean },
|
|
1500
|
+
deps?: CoordinatorDeps,
|
|
1501
|
+
): Promise<CheckCompleteResult> {
|
|
1502
|
+
void deps; // reserved for future DI
|
|
1503
|
+
|
|
1504
|
+
const config = await loadConfig(process.cwd());
|
|
1505
|
+
const triggers = config.coordinator?.exitTriggers ?? {
|
|
1506
|
+
allAgentsDone: false,
|
|
1507
|
+
taskTrackerEmpty: false,
|
|
1508
|
+
onShutdownSignal: false,
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
const result: CheckCompleteResult = {
|
|
1512
|
+
complete: false,
|
|
1513
|
+
triggers: {
|
|
1514
|
+
allAgentsDone: { enabled: triggers.allAgentsDone, met: false, detail: "" },
|
|
1515
|
+
taskTrackerEmpty: { enabled: triggers.taskTrackerEmpty, met: false, detail: "" },
|
|
1516
|
+
onShutdownSignal: { enabled: triggers.onShutdownSignal, met: false, detail: "" },
|
|
1517
|
+
},
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
// allAgentsDone: read current-run.txt, query SessionStore
|
|
1521
|
+
if (triggers.allAgentsDone) {
|
|
1522
|
+
const runIdPath = join(config.project.root, ".agentplate", "current-run.txt");
|
|
1523
|
+
const runIdFile = Bun.file(runIdPath);
|
|
1524
|
+
if (await runIdFile.exists()) {
|
|
1525
|
+
const runId = (await runIdFile.text()).trim();
|
|
1526
|
+
const sessionsDb = join(config.project.root, ".agentplate", "sessions.db");
|
|
1527
|
+
const store = createSessionStore(sessionsDb);
|
|
1528
|
+
try {
|
|
1529
|
+
const sessions = store.getByRun(runId);
|
|
1530
|
+
const agentSessions = sessions.filter((s) => s.capability !== "coordinator");
|
|
1531
|
+
let allDone =
|
|
1532
|
+
agentSessions.length > 0 && agentSessions.every((s) => s.state === "completed");
|
|
1533
|
+
const states = agentSessions.map((s) => `${s.agentName}:${s.state}`);
|
|
1534
|
+
|
|
1535
|
+
// Also check the merge queue — agents may be "completed" but branches
|
|
1536
|
+
// not yet merged. This prevents premature issue closure when a builder
|
|
1537
|
+
// finishes but its lead hasn't merged yet (agentplate-5c08).
|
|
1538
|
+
if (allDone) {
|
|
1539
|
+
const mergeQueuePath = join(config.project.root, ".agentplate", "merge-queue.db");
|
|
1540
|
+
const mergeQueueFile = Bun.file(mergeQueuePath);
|
|
1541
|
+
if (await mergeQueueFile.exists()) {
|
|
1542
|
+
const { createMergeQueue } = await import("../merge/queue.ts");
|
|
1543
|
+
const queue = createMergeQueue(mergeQueuePath);
|
|
1544
|
+
try {
|
|
1545
|
+
const pending = queue.list("pending");
|
|
1546
|
+
if (pending.length > 0) {
|
|
1547
|
+
allDone = false;
|
|
1548
|
+
result.triggers.allAgentsDone.detail = `${pending.length} branch(es) pending merge: ${pending.map((e) => e.branchName).join(", ")}`;
|
|
1549
|
+
}
|
|
1550
|
+
} finally {
|
|
1551
|
+
queue.close();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
result.triggers.allAgentsDone.met = allDone;
|
|
1557
|
+
if (result.triggers.allAgentsDone.detail === "") {
|
|
1558
|
+
result.triggers.allAgentsDone.detail = allDone
|
|
1559
|
+
? `All ${agentSessions.length} agents completed`
|
|
1560
|
+
: states.join(", ");
|
|
1561
|
+
}
|
|
1562
|
+
} finally {
|
|
1563
|
+
store.close();
|
|
1564
|
+
}
|
|
1565
|
+
} else {
|
|
1566
|
+
result.triggers.allAgentsDone.detail = "No current run found";
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// taskTrackerEmpty: shell out to tracker CLI
|
|
1571
|
+
if (triggers.taskTrackerEmpty) {
|
|
1572
|
+
try {
|
|
1573
|
+
const backend = await resolveBackend(config.taskTracker.backend, config.project.root);
|
|
1574
|
+
const cliName = trackerCliName(backend);
|
|
1575
|
+
const proc = Bun.spawn([cliName, "ready", "--json"], {
|
|
1576
|
+
cwd: config.project.root,
|
|
1577
|
+
stdout: "pipe",
|
|
1578
|
+
stderr: "pipe",
|
|
1579
|
+
});
|
|
1580
|
+
const exitCode = await proc.exited;
|
|
1581
|
+
const stdout = await new Response(proc.stdout).text();
|
|
1582
|
+
if (exitCode === 0) {
|
|
1583
|
+
try {
|
|
1584
|
+
const issues = JSON.parse(stdout.trim()) as unknown;
|
|
1585
|
+
const isEmpty = Array.isArray(issues) && issues.length === 0;
|
|
1586
|
+
result.triggers.taskTrackerEmpty.met = isEmpty;
|
|
1587
|
+
result.triggers.taskTrackerEmpty.detail = isEmpty
|
|
1588
|
+
? "No unblocked issues"
|
|
1589
|
+
: `${(issues as unknown[]).length} unblocked issue(s)`;
|
|
1590
|
+
} catch {
|
|
1591
|
+
const isEmpty = stdout.trim() === "" || stdout.trim() === "[]";
|
|
1592
|
+
result.triggers.taskTrackerEmpty.met = isEmpty;
|
|
1593
|
+
result.triggers.taskTrackerEmpty.detail = isEmpty
|
|
1594
|
+
? "No unblocked issues"
|
|
1595
|
+
: "Issues found";
|
|
1596
|
+
}
|
|
1597
|
+
} else {
|
|
1598
|
+
result.triggers.taskTrackerEmpty.detail = `Tracker command failed (exit ${exitCode})`;
|
|
1599
|
+
}
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
result.triggers.taskTrackerEmpty.detail = `Tracker error: ${err instanceof Error ? err.message : String(err)}`;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// onShutdownSignal: check mail for shutdown messages to coordinator
|
|
1606
|
+
if (triggers.onShutdownSignal) {
|
|
1607
|
+
const mailDb = join(config.project.root, ".agentplate", "mail.db");
|
|
1608
|
+
const mailStore = createMailStore(mailDb);
|
|
1609
|
+
try {
|
|
1610
|
+
const unread = mailStore.getUnread("coordinator");
|
|
1611
|
+
const shutdownMsg = unread.find((m) => m.subject.toLowerCase().includes("shutdown"));
|
|
1612
|
+
result.triggers.onShutdownSignal.met = shutdownMsg !== undefined;
|
|
1613
|
+
result.triggers.onShutdownSignal.detail = shutdownMsg
|
|
1614
|
+
? `Shutdown signal from ${shutdownMsg.from}: ${shutdownMsg.subject}`
|
|
1615
|
+
: "No shutdown signal received";
|
|
1616
|
+
} finally {
|
|
1617
|
+
mailStore.close();
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Overall: complete only if ALL enabled triggers are met
|
|
1622
|
+
const enabledTriggers = Object.values(result.triggers).filter((t) => t.enabled);
|
|
1623
|
+
result.complete = enabledTriggers.length > 0 && enabledTriggers.every((t) => t.met);
|
|
1624
|
+
|
|
1625
|
+
if (opts.json) {
|
|
1626
|
+
jsonOutput("coordinator check-complete", result as unknown as Record<string, unknown>);
|
|
1627
|
+
} else {
|
|
1628
|
+
for (const [name, trigger] of Object.entries(result.triggers)) {
|
|
1629
|
+
const status = !trigger.enabled ? "disabled" : trigger.met ? "MET" : "NOT MET";
|
|
1630
|
+
process.stdout.write(` ${name}: ${status} — ${trigger.detail}\n`);
|
|
1631
|
+
}
|
|
1632
|
+
process.stdout.write(`\nComplete: ${result.complete ? "YES" : "NO"}\n`);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
return result;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
export function createPersistentAgentCommand(
|
|
1639
|
+
spec: PersistentAgentSpec,
|
|
1640
|
+
deps: CoordinatorDeps = {},
|
|
1641
|
+
): Command {
|
|
1642
|
+
const cmd = new Command(spec.commandName).description(
|
|
1643
|
+
`Manage the persistent ${spec.commandName} agent`,
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
cmd
|
|
1647
|
+
.command("start")
|
|
1648
|
+
.description(`Start the ${spec.commandName} (spawns Claude Code at project root)`)
|
|
1649
|
+
.option("--attach", "Always attach to tmux session after start")
|
|
1650
|
+
.option("--no-attach", "Never attach to tmux session after start")
|
|
1651
|
+
.option("--watchdog", `Auto-start watchdog daemon with ${spec.commandName}`)
|
|
1652
|
+
.option(
|
|
1653
|
+
"--accept-existing-watchdog",
|
|
1654
|
+
"Continue when a watchdog daemon from a previous session is already running (it will supervise this run)",
|
|
1655
|
+
)
|
|
1656
|
+
.option("--monitor", `Auto-start Tier 2 monitor agent with ${spec.commandName}`)
|
|
1657
|
+
.option("--profile <name>", "Trellis profile to apply to spawned agents")
|
|
1658
|
+
.option("--json", "Output as JSON")
|
|
1659
|
+
.action(
|
|
1660
|
+
async (opts: {
|
|
1661
|
+
attach?: boolean;
|
|
1662
|
+
watchdog?: boolean;
|
|
1663
|
+
acceptExistingWatchdog?: boolean;
|
|
1664
|
+
monitor?: boolean;
|
|
1665
|
+
json?: boolean;
|
|
1666
|
+
profile?: string;
|
|
1667
|
+
}) => {
|
|
1668
|
+
// opts.attach = true if --attach, false if --no-attach, undefined if neither
|
|
1669
|
+
const shouldAttach = opts.attach !== undefined ? opts.attach : !!process.stdout.isTTY;
|
|
1670
|
+
await startPersistentAgent(
|
|
1671
|
+
spec,
|
|
1672
|
+
{
|
|
1673
|
+
json: opts.json ?? false,
|
|
1674
|
+
attach: shouldAttach,
|
|
1675
|
+
watchdog: opts.watchdog ?? false,
|
|
1676
|
+
acceptExistingWatchdog: opts.acceptExistingWatchdog ?? false,
|
|
1677
|
+
monitor: opts.monitor ?? false,
|
|
1678
|
+
profile: opts.profile,
|
|
1679
|
+
},
|
|
1680
|
+
deps,
|
|
1681
|
+
);
|
|
1682
|
+
},
|
|
1683
|
+
);
|
|
1684
|
+
|
|
1685
|
+
cmd
|
|
1686
|
+
.command("stop")
|
|
1687
|
+
.description(`Stop the ${spec.commandName} (kills tmux session)`)
|
|
1688
|
+
.option("--json", "Output as JSON")
|
|
1689
|
+
.action(async (opts: { json?: boolean }) => {
|
|
1690
|
+
await stopPersistentAgent(spec, { json: opts.json ?? false }, deps);
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
cmd
|
|
1694
|
+
.command("status")
|
|
1695
|
+
.description(`Show ${spec.commandName} state`)
|
|
1696
|
+
.option("--json", "Output as JSON")
|
|
1697
|
+
.action(async (opts: { json?: boolean }) => {
|
|
1698
|
+
await statusPersistentAgent(spec, { json: opts.json ?? false }, deps);
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
cmd
|
|
1702
|
+
.command("send")
|
|
1703
|
+
.description(`Send a message to the ${spec.commandName} (fire-and-forget)`)
|
|
1704
|
+
.requiredOption("--body <text>", "Message body")
|
|
1705
|
+
.option("--subject <text>", "Message subject", "operator dispatch")
|
|
1706
|
+
.option("--json", "Output as JSON")
|
|
1707
|
+
.action(async (opts: { body: string; subject: string; json?: boolean }) => {
|
|
1708
|
+
await sendToPersistentAgent(
|
|
1709
|
+
spec,
|
|
1710
|
+
opts.body,
|
|
1711
|
+
{ subject: opts.subject, json: opts.json ?? false },
|
|
1712
|
+
deps,
|
|
1713
|
+
);
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
cmd
|
|
1717
|
+
.command("ask")
|
|
1718
|
+
.description(`Send a request to the ${spec.commandName} and wait for a reply`)
|
|
1719
|
+
.requiredOption("--body <text>", "Message body")
|
|
1720
|
+
.option("--subject <text>", "Message subject", "operator request")
|
|
1721
|
+
.option("--timeout <seconds>", "Timeout in seconds", String(ASK_DEFAULT_TIMEOUT_S))
|
|
1722
|
+
.option("--json", "Output as JSON")
|
|
1723
|
+
.action(async (opts: { body: string; subject: string; timeout?: string; json?: boolean }) => {
|
|
1724
|
+
await askPersistentAgent(
|
|
1725
|
+
spec,
|
|
1726
|
+
opts.body,
|
|
1727
|
+
{
|
|
1728
|
+
subject: opts.subject,
|
|
1729
|
+
timeout: Number.parseInt(opts.timeout ?? String(ASK_DEFAULT_TIMEOUT_S), 10),
|
|
1730
|
+
json: opts.json ?? false,
|
|
1731
|
+
},
|
|
1732
|
+
deps,
|
|
1733
|
+
);
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
cmd
|
|
1737
|
+
.command("output")
|
|
1738
|
+
.description(`Show recent ${spec.commandName} output (tmux pane content)`)
|
|
1739
|
+
.option("--follow, -f", "Continuously poll for new output")
|
|
1740
|
+
.option("--lines <n>", "Number of lines to capture", "50")
|
|
1741
|
+
.option("--interval <ms>", "Poll interval in milliseconds (with --follow)", "2000")
|
|
1742
|
+
.option("--json", "Output as JSON")
|
|
1743
|
+
.action(
|
|
1744
|
+
async (opts: { follow?: boolean; lines?: string; interval?: string; json?: boolean }) => {
|
|
1745
|
+
await outputPersistentAgent(
|
|
1746
|
+
spec,
|
|
1747
|
+
{
|
|
1748
|
+
follow: opts.follow ?? false,
|
|
1749
|
+
lines: Number.parseInt(opts.lines ?? "50", 10),
|
|
1750
|
+
interval: Number.parseInt(opts.interval ?? "2000", 10),
|
|
1751
|
+
json: opts.json ?? false,
|
|
1752
|
+
},
|
|
1753
|
+
deps,
|
|
1754
|
+
);
|
|
1755
|
+
},
|
|
1756
|
+
);
|
|
1757
|
+
|
|
1758
|
+
return cmd;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Create the Commander command for `ap coordinator`.
|
|
1763
|
+
*/
|
|
1764
|
+
export function createCoordinatorCommand(deps: CoordinatorDeps = {}): Command {
|
|
1765
|
+
const cmd = createPersistentAgentCommand(COORDINATOR_SPEC, deps);
|
|
1766
|
+
|
|
1767
|
+
cmd
|
|
1768
|
+
.command("check-complete")
|
|
1769
|
+
.description("Evaluate exit triggers and report completion status")
|
|
1770
|
+
.option("--json", "Output as JSON")
|
|
1771
|
+
.action(async (opts: { json?: boolean }) => {
|
|
1772
|
+
await checkComplete({ json: opts.json ?? false }, deps);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
return cmd;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
export async function persistentAgentCommand(
|
|
1779
|
+
args: string[],
|
|
1780
|
+
spec: PersistentAgentSpec,
|
|
1781
|
+
deps: CoordinatorDeps = {},
|
|
1782
|
+
): Promise<void> {
|
|
1783
|
+
const cmd = createPersistentAgentCommand(spec, deps);
|
|
1784
|
+
cmd.exitOverride();
|
|
1785
|
+
|
|
1786
|
+
if (args.length === 0) {
|
|
1787
|
+
process.stdout.write(cmd.helpInformation());
|
|
1788
|
+
return;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
try {
|
|
1792
|
+
await cmd.parseAsync(args, { from: "user" });
|
|
1793
|
+
} catch (err: unknown) {
|
|
1794
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
1795
|
+
const code = (err as { code: string }).code;
|
|
1796
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (code === "commander.unknownCommand") {
|
|
1800
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1801
|
+
throw new ValidationError(message, { field: "subcommand" });
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
throw err;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Entry point for `ap coordinator <subcommand>`.
|
|
1810
|
+
*
|
|
1811
|
+
* @param args - CLI arguments after "coordinator"
|
|
1812
|
+
* @param deps - Optional dependency injection for testing (tmux)
|
|
1813
|
+
*/
|
|
1814
|
+
export async function coordinatorCommand(
|
|
1815
|
+
args: string[],
|
|
1816
|
+
deps: CoordinatorDeps = {},
|
|
1817
|
+
): Promise<void> {
|
|
1818
|
+
const cmd = createCoordinatorCommand(deps);
|
|
1819
|
+
cmd.exitOverride();
|
|
1820
|
+
|
|
1821
|
+
if (args.length === 0) {
|
|
1822
|
+
process.stdout.write(cmd.helpInformation());
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
try {
|
|
1827
|
+
await cmd.parseAsync(args, { from: "user" });
|
|
1828
|
+
} catch (err: unknown) {
|
|
1829
|
+
if (err && typeof err === "object" && "code" in err) {
|
|
1830
|
+
const code = (err as { code: string }).code;
|
|
1831
|
+
if (code === "commander.helpDisplayed" || code === "commander.version") {
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if (code === "commander.unknownCommand") {
|
|
1835
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1836
|
+
throw new ValidationError(message, { field: "subcommand" });
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
throw err;
|
|
1840
|
+
}
|
|
1841
|
+
}
|