@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,405 @@
|
|
|
1
|
+
import { unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { WorktreeError } from "../errors.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run a git command and return stdout. Throws WorktreeError on non-zero exit.
|
|
7
|
+
*/
|
|
8
|
+
async function runGit(
|
|
9
|
+
repoRoot: string,
|
|
10
|
+
args: string[],
|
|
11
|
+
context?: { worktreePath?: string; branchName?: string },
|
|
12
|
+
): Promise<string> {
|
|
13
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
14
|
+
cwd: repoRoot,
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
20
|
+
new Response(proc.stdout).text(),
|
|
21
|
+
new Response(proc.stderr).text(),
|
|
22
|
+
proc.exited,
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
if (exitCode !== 0) {
|
|
26
|
+
throw new WorktreeError(
|
|
27
|
+
`git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim() || stdout.trim()}`,
|
|
28
|
+
{
|
|
29
|
+
worktreePath: context?.worktreePath,
|
|
30
|
+
branchName: context?.branchName,
|
|
31
|
+
},
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return stdout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a new git worktree for an agent.
|
|
40
|
+
*
|
|
41
|
+
* Creates a worktree at `{baseDir}/{agentName}` with a new branch
|
|
42
|
+
* named `agentplate/{agentName}/{taskId}` based on `baseBranch`.
|
|
43
|
+
*
|
|
44
|
+
* Before running `git worktree add`, rejects when the target branch is
|
|
45
|
+
* already checked out in another worktree — this avoids the silent-overwrite
|
|
46
|
+
* class of failure entirely. After `git worktree add` returns, validates
|
|
47
|
+
* that the worktree is actually registered with git AND contains tracked
|
|
48
|
+
* files; if either check fails, rolls back and throws. sling has previously
|
|
49
|
+
* hit edge cases where the dir exists but git did not populate it
|
|
50
|
+
* (agentplate-6878), trapping the agent in a non-worktree directory.
|
|
51
|
+
*
|
|
52
|
+
* @returns The absolute worktree path and branch name.
|
|
53
|
+
*/
|
|
54
|
+
export async function createWorktree(options: {
|
|
55
|
+
repoRoot: string;
|
|
56
|
+
baseDir: string;
|
|
57
|
+
agentName: string;
|
|
58
|
+
baseBranch: string;
|
|
59
|
+
taskId: string;
|
|
60
|
+
}): Promise<{ path: string; branch: string }> {
|
|
61
|
+
const { repoRoot, baseDir, agentName, baseBranch, taskId } = options;
|
|
62
|
+
|
|
63
|
+
const worktreePath = join(baseDir, agentName);
|
|
64
|
+
const branchName = `agentplate/${agentName}/${taskId}`;
|
|
65
|
+
|
|
66
|
+
const existing = await listWorktrees(repoRoot);
|
|
67
|
+
const occupied = existing.find((entry) => entry.branch === branchName);
|
|
68
|
+
if (occupied !== undefined) {
|
|
69
|
+
throw new WorktreeError(`branch ${branchName} is already checked out at ${occupied.path}`, {
|
|
70
|
+
worktreePath,
|
|
71
|
+
branchName,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await runGit(repoRoot, ["worktree", "add", "-b", branchName, worktreePath, baseBranch], {
|
|
76
|
+
worktreePath,
|
|
77
|
+
branchName,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await validateWorktreeCreation({ repoRoot, worktreePath, branchName });
|
|
81
|
+
|
|
82
|
+
return { path: worktreePath, branch: branchName };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify that a freshly created worktree is registered with git and contains
|
|
87
|
+
* tracked files. Throws WorktreeError with a precise diagnostic on failure
|
|
88
|
+
* and rolls back the worktree + branch so callers don't leak state.
|
|
89
|
+
*
|
|
90
|
+
* Exported for direct testing of edge cases (empty base branches, racy
|
|
91
|
+
* cleanup) that are awkward to provoke through createWorktree end-to-end.
|
|
92
|
+
*/
|
|
93
|
+
export async function validateWorktreeCreation(opts: {
|
|
94
|
+
repoRoot: string;
|
|
95
|
+
worktreePath: string;
|
|
96
|
+
branchName: string;
|
|
97
|
+
}): Promise<void> {
|
|
98
|
+
const { repoRoot, worktreePath, branchName } = opts;
|
|
99
|
+
|
|
100
|
+
const entries = await listWorktrees(repoRoot);
|
|
101
|
+
const registered = entries.some((entry) => entry.path === worktreePath);
|
|
102
|
+
if (!registered) {
|
|
103
|
+
await rollbackWorktree(repoRoot, worktreePath, branchName);
|
|
104
|
+
throw new WorktreeError(
|
|
105
|
+
`Worktree creation reported success but path is not registered with git: ${worktreePath}. Possible causes: pre-existing directory, branch already checked out elsewhere, or git worktree add failed silently.`,
|
|
106
|
+
{ worktreePath, branchName },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const lsFiles = await runGit(worktreePath, ["ls-files"], { worktreePath, branchName });
|
|
111
|
+
const fileCount = lsFiles.split("\n").filter((line) => line.length > 0).length;
|
|
112
|
+
if (fileCount === 0) {
|
|
113
|
+
await rollbackWorktree(repoRoot, worktreePath, branchName);
|
|
114
|
+
throw new WorktreeError(
|
|
115
|
+
`Worktree was registered but contains zero tracked files: ${worktreePath}. The base branch may be empty or the working tree was not populated.`,
|
|
116
|
+
{ worktreePath, branchName },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Roll back a worktree and its associated branch after a failed spawn.
|
|
123
|
+
*
|
|
124
|
+
* Best-effort cleanup: errors are swallowed because the caller's original
|
|
125
|
+
* error is more important. Always call this inside a catch block.
|
|
126
|
+
*/
|
|
127
|
+
export async function rollbackWorktree(
|
|
128
|
+
repoRoot: string,
|
|
129
|
+
worktreePath: string,
|
|
130
|
+
branchName: string,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const removeProc = Bun.spawn(["git", "worktree", "remove", "--force", worktreePath], {
|
|
134
|
+
cwd: repoRoot,
|
|
135
|
+
stdout: "pipe",
|
|
136
|
+
stderr: "pipe",
|
|
137
|
+
});
|
|
138
|
+
await removeProc.exited;
|
|
139
|
+
} catch {
|
|
140
|
+
// Best-effort
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (branchName.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
const branchProc = Bun.spawn(["git", "branch", "-D", branchName], {
|
|
146
|
+
cwd: repoRoot,
|
|
147
|
+
stdout: "pipe",
|
|
148
|
+
stderr: "pipe",
|
|
149
|
+
});
|
|
150
|
+
await branchProc.exited;
|
|
151
|
+
} catch {
|
|
152
|
+
// Best-effort
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parsed representation of a single worktree entry from `git worktree list --porcelain`.
|
|
159
|
+
*/
|
|
160
|
+
interface WorktreeEntry {
|
|
161
|
+
path: string;
|
|
162
|
+
branch: string;
|
|
163
|
+
head: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse the output of `git worktree list --porcelain` into structured entries.
|
|
168
|
+
*
|
|
169
|
+
* Porcelain format example:
|
|
170
|
+
* ```
|
|
171
|
+
* worktree /path/to/main
|
|
172
|
+
* HEAD abc123
|
|
173
|
+
* branch refs/heads/main
|
|
174
|
+
*
|
|
175
|
+
* worktree /path/to/wt
|
|
176
|
+
* HEAD def456
|
|
177
|
+
* branch refs/heads/agentplate/agent/bead
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
function parseWorktreeOutput(output: string): WorktreeEntry[] {
|
|
181
|
+
const entries: WorktreeEntry[] = [];
|
|
182
|
+
const blocks = output.trim().split("\n\n");
|
|
183
|
+
|
|
184
|
+
for (const block of blocks) {
|
|
185
|
+
if (block.trim() === "") continue;
|
|
186
|
+
|
|
187
|
+
let path = "";
|
|
188
|
+
let head = "";
|
|
189
|
+
let branch = "";
|
|
190
|
+
|
|
191
|
+
const lines = block.trim().split("\n");
|
|
192
|
+
for (const line of lines) {
|
|
193
|
+
if (line.startsWith("worktree ")) {
|
|
194
|
+
path = line.slice("worktree ".length);
|
|
195
|
+
} else if (line.startsWith("HEAD ")) {
|
|
196
|
+
head = line.slice("HEAD ".length);
|
|
197
|
+
} else if (line.startsWith("branch ")) {
|
|
198
|
+
// Strip refs/heads/ prefix to get the short branch name
|
|
199
|
+
const ref = line.slice("branch ".length);
|
|
200
|
+
branch = ref.replace(/^refs\/heads\//, "");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (path.length > 0) {
|
|
205
|
+
entries.push({ path, head, branch });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return entries;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* List all git worktrees in the repository.
|
|
214
|
+
*
|
|
215
|
+
* @returns Array of worktree entries with path, branch name, and HEAD commit.
|
|
216
|
+
*/
|
|
217
|
+
export async function listWorktrees(
|
|
218
|
+
repoRoot: string,
|
|
219
|
+
): Promise<Array<{ path: string; branch: string; head: string }>> {
|
|
220
|
+
const stdout = await runGit(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
221
|
+
return parseWorktreeOutput(stdout);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if a branch has been merged into a target branch.
|
|
226
|
+
* Uses `git merge-base --is-ancestor` which returns exit 0 if merged, 1 if not.
|
|
227
|
+
*/
|
|
228
|
+
export async function isBranchMerged(
|
|
229
|
+
repoRoot: string,
|
|
230
|
+
branch: string,
|
|
231
|
+
targetBranch: string,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
const proc = Bun.spawn(["git", "merge-base", "--is-ancestor", branch, targetBranch], {
|
|
234
|
+
cwd: repoRoot,
|
|
235
|
+
stdout: "pipe",
|
|
236
|
+
stderr: "pipe",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
|
240
|
+
|
|
241
|
+
if (exitCode === 0) return true;
|
|
242
|
+
if (exitCode === 1) return false;
|
|
243
|
+
|
|
244
|
+
throw new WorktreeError(
|
|
245
|
+
`git merge-base --is-ancestor failed (exit ${exitCode}): ${stderr.trim()}`,
|
|
246
|
+
{ branchName: branch },
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Remove a git worktree and delete its associated branch.
|
|
252
|
+
*
|
|
253
|
+
* Runs `git worktree remove {path}` to remove the worktree, then
|
|
254
|
+
* deletes the branch. With `forceBranch: true`, uses `git branch -D`
|
|
255
|
+
* to force-delete even unmerged branches. Otherwise uses `git branch -d`
|
|
256
|
+
* which only deletes merged branches.
|
|
257
|
+
*/
|
|
258
|
+
export async function removeWorktree(
|
|
259
|
+
repoRoot: string,
|
|
260
|
+
path: string,
|
|
261
|
+
options?: { force?: boolean; forceBranch?: boolean },
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
// First, figure out which branch this worktree is on so we can clean it up
|
|
264
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
265
|
+
const entry = worktrees.find((wt) => wt.path === path);
|
|
266
|
+
const branchName = entry?.branch ?? "";
|
|
267
|
+
|
|
268
|
+
// Remove the worktree (--force handles untracked files and uncommitted changes)
|
|
269
|
+
const removeArgs = ["worktree", "remove", path];
|
|
270
|
+
if (options?.force) {
|
|
271
|
+
removeArgs.push("--force");
|
|
272
|
+
}
|
|
273
|
+
await runGit(repoRoot, removeArgs, {
|
|
274
|
+
worktreePath: path,
|
|
275
|
+
branchName,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Delete the associated branch after worktree removal.
|
|
279
|
+
// Use -D (force) when forceBranch is set, since the branch may not have
|
|
280
|
+
// been merged yet. Use -d (safe) otherwise, which only deletes merged branches.
|
|
281
|
+
if (branchName.length > 0) {
|
|
282
|
+
const deleteFlag = options?.forceBranch ? "-D" : "-d";
|
|
283
|
+
try {
|
|
284
|
+
await runGit(repoRoot, ["branch", deleteFlag, branchName], { branchName });
|
|
285
|
+
} catch {
|
|
286
|
+
// Branch deletion failed — may be unmerged (with -d) or checked out elsewhere.
|
|
287
|
+
// This is best-effort; the worktree itself is already removed.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Preserve .sprout/ changes from a branch into the canonical branch.
|
|
294
|
+
*
|
|
295
|
+
* Lead agent branches are never merged via the normal merge pipeline, so
|
|
296
|
+
* any .sprout/ issue files they create would be lost when the worktree is
|
|
297
|
+
* cleaned. This function extracts only the .sprout/ diff from the branch
|
|
298
|
+
* and applies it to the canonical branch via a patch.
|
|
299
|
+
*
|
|
300
|
+
* @returns `{ preserved: true }` if changes were found and committed,
|
|
301
|
+
* `{ preserved: false }` if there were no .sprout/ changes,
|
|
302
|
+
* `{ preserved: false, error: "..." }` if something went wrong.
|
|
303
|
+
*/
|
|
304
|
+
export async function preserveSproutChanges(
|
|
305
|
+
repoRoot: string,
|
|
306
|
+
branch: string,
|
|
307
|
+
canonicalBranch: string,
|
|
308
|
+
agentName: string,
|
|
309
|
+
): Promise<{ preserved: boolean; error?: string }> {
|
|
310
|
+
// Step 1: Get the .sprout/ diff between canonical and the branch (three-dot diff).
|
|
311
|
+
// Three-dot diff shows changes introduced on branch since it diverged from canonicalBranch.
|
|
312
|
+
let diff: string;
|
|
313
|
+
try {
|
|
314
|
+
diff = await runGit(repoRoot, ["diff", `${canonicalBranch}...${branch}`, "--", ".sprout/"]);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
317
|
+
return { preserved: false, error: `Failed to compute .sprout/ diff: ${msg}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (diff.trim() === "") {
|
|
321
|
+
// No .sprout/ changes on this branch
|
|
322
|
+
return { preserved: false };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Step 2: Verify the repo root is currently on canonicalBranch.
|
|
326
|
+
let currentBranch: string;
|
|
327
|
+
try {
|
|
328
|
+
currentBranch = (await runGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
|
|
329
|
+
} catch (err) {
|
|
330
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
331
|
+
return { preserved: false, error: `Failed to determine current branch: ${msg}` };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (currentBranch !== canonicalBranch) {
|
|
335
|
+
return {
|
|
336
|
+
preserved: false,
|
|
337
|
+
error: `Repo root is on '${currentBranch}', expected '${canonicalBranch}'. Cannot apply patch.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Step 3: Check that .sprout/ is clean in the canonical branch.
|
|
342
|
+
let statusOutput: string;
|
|
343
|
+
try {
|
|
344
|
+
statusOutput = await runGit(repoRoot, ["status", "--porcelain", "--", ".sprout/"]);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
347
|
+
return { preserved: false, error: `Failed to check .sprout/ status: ${msg}` };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (statusOutput.trim() !== "") {
|
|
351
|
+
return {
|
|
352
|
+
preserved: false,
|
|
353
|
+
error: `.sprout/ has uncommitted changes in canonical branch. Cannot apply patch safely.`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Step 4: Write diff to a temp file.
|
|
358
|
+
const tmpFile = join(repoRoot, ".agentplate", `_sprout-patch-${Date.now()}.diff`);
|
|
359
|
+
try {
|
|
360
|
+
await Bun.write(tmpFile, diff);
|
|
361
|
+
|
|
362
|
+
// Step 5: Apply the patch with --index (stages changes).
|
|
363
|
+
try {
|
|
364
|
+
await runGit(repoRoot, ["apply", "--index", tmpFile]);
|
|
365
|
+
} catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
// Revert any partial changes
|
|
368
|
+
try {
|
|
369
|
+
await runGit(repoRoot, ["reset", "HEAD", "--", ".sprout/"]);
|
|
370
|
+
await runGit(repoRoot, ["checkout", "--", ".sprout/"]);
|
|
371
|
+
} catch {
|
|
372
|
+
// Best-effort revert
|
|
373
|
+
}
|
|
374
|
+
return { preserved: false, error: `Failed to apply .sprout/ patch: ${msg}` };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Step 6: Commit the changes.
|
|
378
|
+
try {
|
|
379
|
+
await runGit(repoRoot, [
|
|
380
|
+
"commit",
|
|
381
|
+
"-m",
|
|
382
|
+
`chore: preserve .sprout/ changes from lead ${agentName}`,
|
|
383
|
+
]);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
386
|
+
// Revert any staged changes
|
|
387
|
+
try {
|
|
388
|
+
await runGit(repoRoot, ["reset", "HEAD", "--", ".sprout/"]);
|
|
389
|
+
await runGit(repoRoot, ["checkout", "--", ".sprout/"]);
|
|
390
|
+
} catch {
|
|
391
|
+
// Best-effort revert
|
|
392
|
+
}
|
|
393
|
+
return { preserved: false, error: `Failed to commit .sprout/ changes: ${msg}` };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { preserved: true };
|
|
397
|
+
} finally {
|
|
398
|
+
// Step 8: Always clean up the temp file.
|
|
399
|
+
try {
|
|
400
|
+
await unlink(tmpFile);
|
|
401
|
+
} catch {
|
|
402
|
+
// Ignore cleanup errors
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getConnection, removeConnection } from "../runtimes/connections.ts";
|
|
6
|
+
import { HeadlessClaudeConnection } from "../runtimes/headless-connection.ts";
|
|
7
|
+
import { spawnHeadlessAgent } from "./process.ts";
|
|
8
|
+
|
|
9
|
+
describe("spawnHeadlessAgent", () => {
|
|
10
|
+
it("spawns a command and returns a valid PID", async () => {
|
|
11
|
+
const proc = await spawnHeadlessAgent(["echo", "hello"], {
|
|
12
|
+
cwd: process.cwd(),
|
|
13
|
+
env: { ...(process.env as Record<string, string>) },
|
|
14
|
+
});
|
|
15
|
+
expect(typeof proc.pid).toBe("number");
|
|
16
|
+
expect(proc.pid).toBeGreaterThan(0);
|
|
17
|
+
expect(proc.stdout).toBeDefined();
|
|
18
|
+
expect(proc.stdin).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("throws AgentError when argv is empty", async () => {
|
|
22
|
+
await expect(spawnHeadlessAgent([], { cwd: process.cwd(), env: {} })).rejects.toThrow(
|
|
23
|
+
"empty argv",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("agentName connection registration", () => {
|
|
28
|
+
const registeredNames: string[] = [];
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
for (const name of registeredNames.splice(0)) {
|
|
32
|
+
removeConnection(name);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("registers a HeadlessClaudeConnection when agentName is provided", async () => {
|
|
37
|
+
const agentName = "test-headless-agent-xyz";
|
|
38
|
+
registeredNames.push(agentName);
|
|
39
|
+
|
|
40
|
+
const proc = await spawnHeadlessAgent(["sleep", "5"], {
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
env: { ...(process.env as Record<string, string>) },
|
|
43
|
+
agentName,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(proc.pid).toBeGreaterThan(0);
|
|
47
|
+
const conn = getConnection(agentName);
|
|
48
|
+
expect(conn).toBeDefined();
|
|
49
|
+
expect(conn).toBeInstanceOf(HeadlessClaudeConnection);
|
|
50
|
+
|
|
51
|
+
// Clean up the spawned process
|
|
52
|
+
try {
|
|
53
|
+
process.kill(proc.pid, "SIGTERM");
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not register a connection when agentName is omitted", async () => {
|
|
60
|
+
const proc = await spawnHeadlessAgent(["echo", "no-register"], {
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
env: { ...(process.env as Record<string, string>) },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Drain stdout so process exits cleanly
|
|
66
|
+
if (proc.stdout) {
|
|
67
|
+
await new Response(proc.stdout).text();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// No connection was registered (use a stable lookup key that was never set)
|
|
71
|
+
expect(getConnection("never-registered-in-this-test")).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("registered connection pid matches the spawned process pid", async () => {
|
|
75
|
+
const agentName = "test-headless-pid-check-xyz";
|
|
76
|
+
registeredNames.push(agentName);
|
|
77
|
+
|
|
78
|
+
const proc = await spawnHeadlessAgent(["sleep", "5"], {
|
|
79
|
+
cwd: process.cwd(),
|
|
80
|
+
env: { ...(process.env as Record<string, string>) },
|
|
81
|
+
agentName,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const conn = getConnection(agentName) as HeadlessClaudeConnection;
|
|
85
|
+
expect(conn).toBeDefined();
|
|
86
|
+
expect(conn.pid).toBe(proc.pid);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
process.kill(proc.pid, "SIGTERM");
|
|
90
|
+
} catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("file redirect mode", () => {
|
|
97
|
+
let tmpDir: string;
|
|
98
|
+
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
tmpDir = await mkdtemp(join(tmpdir(), "ap-process-test-"));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("redirects stdout to file when stdoutFile is provided", async () => {
|
|
108
|
+
const stdoutFile = join(tmpDir, "stdout.log");
|
|
109
|
+
const proc = await spawnHeadlessAgent(["echo", "hello from file"], {
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
env: { ...(process.env as Record<string, string>) },
|
|
112
|
+
stdoutFile,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(typeof proc.pid).toBe("number");
|
|
116
|
+
expect(proc.pid).toBeGreaterThan(0);
|
|
117
|
+
// stdout is null when redirected to file — no pipe, no backpressure
|
|
118
|
+
expect(proc.stdout).toBeNull();
|
|
119
|
+
expect(proc.stdin).toBeDefined();
|
|
120
|
+
|
|
121
|
+
// Wait for process to finish, then check file content
|
|
122
|
+
const exitProc = Bun.spawn(["sh", "-c", "true"], { stdout: "pipe" });
|
|
123
|
+
await exitProc.exited;
|
|
124
|
+
// Give echo a moment to flush
|
|
125
|
+
await Bun.sleep(100);
|
|
126
|
+
|
|
127
|
+
const content = await Bun.file(stdoutFile).text();
|
|
128
|
+
expect(content.trim()).toBe("hello from file");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("redirects stderr to file when stderrFile is provided", async () => {
|
|
132
|
+
const stderrFile = join(tmpDir, "stderr.log");
|
|
133
|
+
// Write to stderr via sh -c
|
|
134
|
+
const proc = await spawnHeadlessAgent(["sh", "-c", "echo error output >&2"], {
|
|
135
|
+
cwd: process.cwd(),
|
|
136
|
+
env: { ...(process.env as Record<string, string>) },
|
|
137
|
+
stderrFile,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(typeof proc.pid).toBe("number");
|
|
141
|
+
// stdout still piped (no stdoutFile provided)
|
|
142
|
+
expect(proc.stdout).not.toBeNull();
|
|
143
|
+
|
|
144
|
+
// Drain stdout to let process exit cleanly
|
|
145
|
+
if (proc.stdout) {
|
|
146
|
+
const reader = proc.stdout.getReader();
|
|
147
|
+
while (!(await reader.read()).done) {
|
|
148
|
+
// drain
|
|
149
|
+
}
|
|
150
|
+
reader.releaseLock();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await Bun.sleep(100);
|
|
154
|
+
const content = await Bun.file(stderrFile).text();
|
|
155
|
+
expect(content.trim()).toBe("error output");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("stdout remains a ReadableStream when no stdoutFile provided (default mode)", async () => {
|
|
159
|
+
const proc = await spawnHeadlessAgent(["echo", "piped"], {
|
|
160
|
+
cwd: process.cwd(),
|
|
161
|
+
env: { ...(process.env as Record<string, string>) },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(proc.stdout).not.toBeNull();
|
|
165
|
+
expect(proc.stdout).toBeInstanceOf(ReadableStream);
|
|
166
|
+
|
|
167
|
+
// Read the content via the stream
|
|
168
|
+
const text = await new Response(proc.stdout as ReadableStream<Uint8Array>).text();
|
|
169
|
+
expect(text.trim()).toBe("piped");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless subprocess management for non-tmux agent runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Used by long-lived headless runtimes that bypass tmux (e.g., Sapling running
|
|
5
|
+
* with --json). Provides spawnHeadlessAgent() for direct Bun.spawn() invocation.
|
|
6
|
+
*
|
|
7
|
+
* Headless Claude Code does NOT use this path — under spawn-per-turn (Phase 3),
|
|
8
|
+
* Claude agents have no persistent process; each turn spawns a fresh claude
|
|
9
|
+
* inside `runTurn` (src/agents/turn-runner.ts). This module remains for
|
|
10
|
+
* runtimes that genuinely need a long-lived RPC channel.
|
|
11
|
+
*
|
|
12
|
+
* Note: isProcessAlive() and killProcessTree() for headless process lifecycle
|
|
13
|
+
* management already exist in src/worktree/tmux.ts — not duplicated here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { AgentError } from "../errors.ts";
|
|
17
|
+
import { registerHeadlessConnection } from "../runtimes/connections.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Handle to a spawned headless agent subprocess.
|
|
21
|
+
*
|
|
22
|
+
* Provides the PID for session tracking, stdin for sending input to the
|
|
23
|
+
* agent process, and stdout for consuming NDJSON event output.
|
|
24
|
+
*
|
|
25
|
+
* stdout is null when the process was spawned with a stdoutFile redirect
|
|
26
|
+
* (file-redirect mode). In that case, stdout is written directly to the
|
|
27
|
+
* log file and no pipe backpressure can occur.
|
|
28
|
+
*/
|
|
29
|
+
export interface HeadlessProcess {
|
|
30
|
+
/** OS-level process ID. Stored in AgentSession.pid for watchdog monitoring. */
|
|
31
|
+
pid: number;
|
|
32
|
+
/** Writable sink for sending input to the process (e.g., RPC messages). */
|
|
33
|
+
stdin: { write(data: string | Uint8Array): number | Promise<number> };
|
|
34
|
+
/**
|
|
35
|
+
* Readable stream of the process stdout, or null when stdout was redirected
|
|
36
|
+
* to a file via stdoutFile. Consumed via runtime.parseEvents() when piped.
|
|
37
|
+
*/
|
|
38
|
+
stdout: ReadableStream<Uint8Array> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Options for spawning a headless agent subprocess.
|
|
43
|
+
*
|
|
44
|
+
* When stdoutFile or stderrFile are provided, the corresponding stream is
|
|
45
|
+
* redirected to the given file path instead of a pipe. This eliminates
|
|
46
|
+
* backpressure: the child process can write unlimited output without blocking.
|
|
47
|
+
*
|
|
48
|
+
* Log files are useful for post-mortem inspection and do not need to be
|
|
49
|
+
* consumed by the caller.
|
|
50
|
+
*/
|
|
51
|
+
export interface SpawnHeadlessOptions {
|
|
52
|
+
/** Working directory for the subprocess. */
|
|
53
|
+
cwd: string;
|
|
54
|
+
/** Full environment for the subprocess (no implicit merging with process.env). */
|
|
55
|
+
env: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* When set, redirect subprocess stdout to this file path instead of a pipe.
|
|
58
|
+
* HeadlessProcess.stdout will be null in this case.
|
|
59
|
+
*/
|
|
60
|
+
stdoutFile?: string;
|
|
61
|
+
/**
|
|
62
|
+
* When set, redirect subprocess stderr to this file path instead of a pipe.
|
|
63
|
+
*/
|
|
64
|
+
stderrFile?: string;
|
|
65
|
+
/**
|
|
66
|
+
* When set, registers the spawned process as a `RuntimeConnection` keyed by
|
|
67
|
+
* this agent name (sibling of Sapling's RPC connect() flow). Lets `ap nudge`,
|
|
68
|
+
* the watchdog's liveness/abort path, etc. find the live process via
|
|
69
|
+
* `getConnection(agentName)`.
|
|
70
|
+
*
|
|
71
|
+
* Same namespace as AgentSession.agentName.
|
|
72
|
+
*/
|
|
73
|
+
agentName?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Spawn a headless agent subprocess directly via Bun.spawn().
|
|
78
|
+
*
|
|
79
|
+
* Used by `ap sling` when runtime.headless === true to bypass all tmux
|
|
80
|
+
* session management.
|
|
81
|
+
*
|
|
82
|
+
* **Backpressure prevention:** Pass stdoutFile (and stderrFile) to redirect
|
|
83
|
+
* output to log files instead of pipes. This is the recommended mode for
|
|
84
|
+
* `ap sling` — it prevents the OS pipe buffer (~64 KB) from filling up and
|
|
85
|
+
* blocking the child process when the caller does not actively consume stdout.
|
|
86
|
+
*
|
|
87
|
+
* When no file paths are provided (default/legacy mode), stdout is a pipe and
|
|
88
|
+
* the caller is responsible for consuming it to prevent backpressure.
|
|
89
|
+
*
|
|
90
|
+
* The provided env is used as the full subprocess environment (no implicit
|
|
91
|
+
* merging with process.env — callers should merge explicitly if needed).
|
|
92
|
+
*
|
|
93
|
+
* @param argv - Full argv array from runtime.buildDirectSpawn(); first element is the executable
|
|
94
|
+
* @param opts - Working directory, environment, and optional log file paths
|
|
95
|
+
* @returns HeadlessProcess with pid, stdin, and stdout (null if file-redirected)
|
|
96
|
+
* @throws AgentError if argv is empty
|
|
97
|
+
*/
|
|
98
|
+
export async function spawnHeadlessAgent(
|
|
99
|
+
argv: string[],
|
|
100
|
+
opts: SpawnHeadlessOptions,
|
|
101
|
+
): Promise<HeadlessProcess> {
|
|
102
|
+
const [cmd, ...args] = argv;
|
|
103
|
+
if (!cmd) {
|
|
104
|
+
throw new AgentError("buildDirectSpawn returned empty argv array", {
|
|
105
|
+
agentName: "headless",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stdoutTarget = opts.stdoutFile ? Bun.file(opts.stdoutFile) : "pipe";
|
|
110
|
+
const stderrTarget = opts.stderrFile ? Bun.file(opts.stderrFile) : "pipe";
|
|
111
|
+
|
|
112
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
113
|
+
cwd: opts.cwd,
|
|
114
|
+
env: opts.env,
|
|
115
|
+
stdout: stdoutTarget,
|
|
116
|
+
stderr: stderrTarget,
|
|
117
|
+
stdin: "pipe",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result: HeadlessProcess = {
|
|
121
|
+
pid: proc.pid,
|
|
122
|
+
stdin: proc.stdin as HeadlessProcess["stdin"],
|
|
123
|
+
stdout: opts.stdoutFile ? null : (proc.stdout as ReadableStream<Uint8Array>),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (opts.agentName) {
|
|
127
|
+
registerHeadlessConnection(opts.agentName, result);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|