@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,1616 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { AgentError } from "../errors.ts";
|
|
3
|
+
import type { ReadyState } from "../runtimes/types.ts";
|
|
4
|
+
import {
|
|
5
|
+
capturePaneContent,
|
|
6
|
+
checkSessionState,
|
|
7
|
+
createSession,
|
|
8
|
+
ensureTmuxAvailable,
|
|
9
|
+
getDescendantPids,
|
|
10
|
+
getPanePid,
|
|
11
|
+
isProcessAlive,
|
|
12
|
+
isSessionAlive,
|
|
13
|
+
killProcessTree,
|
|
14
|
+
killSession,
|
|
15
|
+
listSessions,
|
|
16
|
+
sanitizeTmuxName,
|
|
17
|
+
sendKeys,
|
|
18
|
+
waitForTuiReady,
|
|
19
|
+
} from "./tmux.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* tmux tests use Bun.spawn mocks — legitimate exception to "never mock what you can use for real".
|
|
23
|
+
* Real tmux operations would hijack the developer's session and are unavailable in CI.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Helper to create a mock Bun.spawn return value.
|
|
28
|
+
*
|
|
29
|
+
* The actual code reads stdout/stderr via `new Response(proc.stdout).text()`
|
|
30
|
+
* and `new Response(proc.stderr).text()`, so we need ReadableStreams.
|
|
31
|
+
*/
|
|
32
|
+
function mockSpawnResult(
|
|
33
|
+
stdout: string,
|
|
34
|
+
stderr: string,
|
|
35
|
+
exitCode: number,
|
|
36
|
+
): {
|
|
37
|
+
stdout: ReadableStream<Uint8Array>;
|
|
38
|
+
stderr: ReadableStream<Uint8Array>;
|
|
39
|
+
exited: Promise<number>;
|
|
40
|
+
pid: number;
|
|
41
|
+
} {
|
|
42
|
+
return {
|
|
43
|
+
stdout: new Response(stdout).body as ReadableStream<Uint8Array>,
|
|
44
|
+
stderr: new Response(stderr).body as ReadableStream<Uint8Array>,
|
|
45
|
+
exited: Promise.resolve(exitCode),
|
|
46
|
+
pid: 12345,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("createSession", () => {
|
|
51
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
spawnSpy.mockRestore();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("creates session and returns pane PID", async () => {
|
|
62
|
+
let callCount = 0;
|
|
63
|
+
spawnSpy.mockImplementation(() => {
|
|
64
|
+
callCount++;
|
|
65
|
+
if (callCount === 1) {
|
|
66
|
+
// which agentplate — return a bin path
|
|
67
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
68
|
+
}
|
|
69
|
+
if (callCount === 2) {
|
|
70
|
+
// tmux new-session
|
|
71
|
+
return mockSpawnResult("", "", 0);
|
|
72
|
+
}
|
|
73
|
+
// tmux list-panes -t agentplate-auth -F '#{pane_pid}'
|
|
74
|
+
return mockSpawnResult("42\n", "", 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const pid = await createSession(
|
|
78
|
+
"agentplate-auth",
|
|
79
|
+
"/repo/worktrees/auth",
|
|
80
|
+
"claude --task 'do work'",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(pid).toBe(42);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("passes correct args to tmux new-session with PATH wrapping", async () => {
|
|
87
|
+
let callCount = 0;
|
|
88
|
+
spawnSpy.mockImplementation(() => {
|
|
89
|
+
callCount++;
|
|
90
|
+
if (callCount === 1) {
|
|
91
|
+
// which agentplate
|
|
92
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
93
|
+
}
|
|
94
|
+
if (callCount === 2) {
|
|
95
|
+
return mockSpawnResult("", "", 0);
|
|
96
|
+
}
|
|
97
|
+
return mockSpawnResult("1234\n", "", 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await createSession("my-session", "/work/dir", "echo hello");
|
|
101
|
+
|
|
102
|
+
// Call 0 is 'which agentplate', call 1 is 'tmux new-session'
|
|
103
|
+
const tmuxCallArgs = spawnSpy.mock.calls[1] as unknown[];
|
|
104
|
+
const cmd = tmuxCallArgs[0] as string[];
|
|
105
|
+
expect(cmd[0]).toBe("tmux");
|
|
106
|
+
expect(cmd[3]).toBe("new-session");
|
|
107
|
+
expect(cmd[5]).toBe("-s");
|
|
108
|
+
expect(cmd[6]).toBe("my-session");
|
|
109
|
+
expect(cmd[7]).toBe("-c");
|
|
110
|
+
expect(cmd[8]).toBe("/work/dir");
|
|
111
|
+
// The command should be wrapped with PATH export
|
|
112
|
+
const wrappedCmd = cmd[9] as string;
|
|
113
|
+
expect(wrappedCmd).toContain("echo hello");
|
|
114
|
+
expect(wrappedCmd).toContain("export PATH=");
|
|
115
|
+
// `exec` replaces the bash wrapper with the command so SIGHUP from a
|
|
116
|
+
// dying tmux server is delivered directly to claude (agentplate-505d).
|
|
117
|
+
expect(wrappedCmd).toContain("exec echo hello");
|
|
118
|
+
|
|
119
|
+
const opts = tmuxCallArgs[1] as { cwd: string };
|
|
120
|
+
expect(opts.cwd).toBe("/work/dir");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("calls list-panes after creating to get pane PID", async () => {
|
|
124
|
+
let callCount = 0;
|
|
125
|
+
spawnSpy.mockImplementation(() => {
|
|
126
|
+
callCount++;
|
|
127
|
+
if (callCount === 1) {
|
|
128
|
+
// which agentplate
|
|
129
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
130
|
+
}
|
|
131
|
+
if (callCount === 2) {
|
|
132
|
+
return mockSpawnResult("", "", 0);
|
|
133
|
+
}
|
|
134
|
+
return mockSpawnResult("7777\n", "", 0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await createSession("test-agent", "/tmp", "ls");
|
|
138
|
+
|
|
139
|
+
// 3 calls: which agentplate, tmux new-session, tmux list-panes
|
|
140
|
+
expect(spawnSpy).toHaveBeenCalledTimes(3);
|
|
141
|
+
const thirdCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
142
|
+
const cmd = thirdCallArgs[0] as string[];
|
|
143
|
+
expect(cmd).toEqual([
|
|
144
|
+
"tmux",
|
|
145
|
+
"-L",
|
|
146
|
+
"agentplate",
|
|
147
|
+
"list-panes",
|
|
148
|
+
"-t",
|
|
149
|
+
"test-agent",
|
|
150
|
+
"-F",
|
|
151
|
+
"#{pane_pid}",
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("throws AgentError if session creation fails", async () => {
|
|
156
|
+
let callCount = 0;
|
|
157
|
+
spawnSpy.mockImplementation(() => {
|
|
158
|
+
callCount++;
|
|
159
|
+
if (callCount === 1) {
|
|
160
|
+
// which agentplate
|
|
161
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
162
|
+
}
|
|
163
|
+
return mockSpawnResult("", "duplicate session: my-session", 1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("throws AgentError if list-panes fails after creation", async () => {
|
|
170
|
+
let callCount = 0;
|
|
171
|
+
spawnSpy.mockImplementation(() => {
|
|
172
|
+
callCount++;
|
|
173
|
+
if (callCount === 1) {
|
|
174
|
+
// which agentplate
|
|
175
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
176
|
+
}
|
|
177
|
+
if (callCount === 2) {
|
|
178
|
+
// new-session succeeds
|
|
179
|
+
return mockSpawnResult("", "", 0);
|
|
180
|
+
}
|
|
181
|
+
// list-panes fails
|
|
182
|
+
return mockSpawnResult("", "error listing panes", 1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("throws AgentError if pane PID output is empty", async () => {
|
|
189
|
+
let callCount = 0;
|
|
190
|
+
spawnSpy.mockImplementation(() => {
|
|
191
|
+
callCount++;
|
|
192
|
+
if (callCount === 1) {
|
|
193
|
+
// which agentplate
|
|
194
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
195
|
+
}
|
|
196
|
+
if (callCount === 2) {
|
|
197
|
+
return mockSpawnResult("", "", 0);
|
|
198
|
+
}
|
|
199
|
+
// list-panes returns empty output
|
|
200
|
+
return mockSpawnResult("", "", 0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(createSession("my-session", "/tmp", "ls")).rejects.toThrow(AgentError);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("AgentError includes session name context", async () => {
|
|
207
|
+
let callCount = 0;
|
|
208
|
+
spawnSpy.mockImplementation(() => {
|
|
209
|
+
callCount++;
|
|
210
|
+
if (callCount === 1) {
|
|
211
|
+
// which agentplate
|
|
212
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
213
|
+
}
|
|
214
|
+
return mockSpawnResult("", "duplicate session: agent-foo", 1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await createSession("agent-foo", "/tmp", "ls");
|
|
219
|
+
expect(true).toBe(false);
|
|
220
|
+
} catch (err: unknown) {
|
|
221
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
222
|
+
const agentErr = err as AgentError;
|
|
223
|
+
expect(agentErr.message).toContain("agent-foo");
|
|
224
|
+
expect(agentErr.agentName).toBe("agent-foo");
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("still creates session when which ap and which agentplate both fail (uses fallback)", async () => {
|
|
229
|
+
let callCount = 0;
|
|
230
|
+
spawnSpy.mockImplementation(() => {
|
|
231
|
+
callCount++;
|
|
232
|
+
if (callCount === 1) {
|
|
233
|
+
// which ap fails
|
|
234
|
+
return mockSpawnResult("", "ap not found", 1);
|
|
235
|
+
}
|
|
236
|
+
if (callCount === 2) {
|
|
237
|
+
// which agentplate fails
|
|
238
|
+
return mockSpawnResult("", "agentplate not found", 1);
|
|
239
|
+
}
|
|
240
|
+
if (callCount === 3) {
|
|
241
|
+
// tmux new-session
|
|
242
|
+
return mockSpawnResult("", "", 0);
|
|
243
|
+
}
|
|
244
|
+
// tmux list-panes
|
|
245
|
+
return mockSpawnResult("5555\n", "", 0);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const pid = await createSession("fallback-agent", "/tmp", "echo test");
|
|
249
|
+
expect(pid).toBe(5555);
|
|
250
|
+
|
|
251
|
+
// The tmux command should contain the original command
|
|
252
|
+
// Call 0: which ap, Call 1: which agentplate, Call 2: tmux new-session
|
|
253
|
+
const tmuxCallArgs = spawnSpy.mock.calls[2] as unknown[];
|
|
254
|
+
const cmd = tmuxCallArgs[0] as string[];
|
|
255
|
+
const tmuxCmd = cmd[9] as string;
|
|
256
|
+
expect(tmuxCmd).toContain("echo test");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("retries list-panes on transient failure", async () => {
|
|
260
|
+
let callCount = 0;
|
|
261
|
+
spawnSpy.mockImplementation(() => {
|
|
262
|
+
callCount++;
|
|
263
|
+
if (callCount === 1) {
|
|
264
|
+
// which agentplate
|
|
265
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
266
|
+
}
|
|
267
|
+
if (callCount === 2) {
|
|
268
|
+
// tmux new-session
|
|
269
|
+
return mockSpawnResult("", "", 0);
|
|
270
|
+
}
|
|
271
|
+
if (callCount === 3) {
|
|
272
|
+
// First list-panes fails (WSL2 race)
|
|
273
|
+
return mockSpawnResult("", "can't find pane\n", 1);
|
|
274
|
+
}
|
|
275
|
+
// Second list-panes succeeds
|
|
276
|
+
return mockSpawnResult("42\n", "", 0);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const pid = await createSession("retry-session", "/work/dir", "echo hello");
|
|
280
|
+
expect(pid).toBe(42);
|
|
281
|
+
// which + new-session + list-panes(fail) + list-panes(ok)
|
|
282
|
+
expect(spawnSpy).toHaveBeenCalledTimes(4);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("throws after exhausting all list-panes retries", async () => {
|
|
286
|
+
let callCount = 0;
|
|
287
|
+
spawnSpy.mockImplementation(() => {
|
|
288
|
+
callCount++;
|
|
289
|
+
if (callCount === 1) {
|
|
290
|
+
// which agentplate
|
|
291
|
+
return mockSpawnResult("/usr/local/bin/agentplate\n", "", 0);
|
|
292
|
+
}
|
|
293
|
+
if (callCount === 2) {
|
|
294
|
+
// tmux new-session
|
|
295
|
+
return mockSpawnResult("", "", 0);
|
|
296
|
+
}
|
|
297
|
+
// All list-panes attempts fail
|
|
298
|
+
return mockSpawnResult("", "can't find pane\n", 1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
await expect(
|
|
302
|
+
createSession("retry-exhaust", "/work/dir", "echo hello", undefined, 2),
|
|
303
|
+
).rejects.toThrow(/failed to retrieve PID/);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe("listSessions", () => {
|
|
308
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
309
|
+
|
|
310
|
+
beforeEach(() => {
|
|
311
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
afterEach(() => {
|
|
315
|
+
spawnSpy.mockRestore();
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("parses session list output", async () => {
|
|
319
|
+
spawnSpy.mockImplementation(() =>
|
|
320
|
+
mockSpawnResult("agentplate-auth:42\nagentplate-data:99\n", "", 0),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const sessions = await listSessions();
|
|
324
|
+
|
|
325
|
+
expect(sessions).toHaveLength(2);
|
|
326
|
+
expect(sessions[0]?.name).toBe("agentplate-auth");
|
|
327
|
+
expect(sessions[0]?.pid).toBe(42);
|
|
328
|
+
expect(sessions[1]?.name).toBe("agentplate-data");
|
|
329
|
+
expect(sessions[1]?.pid).toBe(99);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("returns empty array when no server running", async () => {
|
|
333
|
+
spawnSpy.mockImplementation(() =>
|
|
334
|
+
mockSpawnResult("", "no server running on /tmp/tmux-501/default", 1),
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
const sessions = await listSessions();
|
|
338
|
+
|
|
339
|
+
expect(sessions).toHaveLength(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("returns empty array when 'no sessions' in stderr", async () => {
|
|
343
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "no sessions", 1));
|
|
344
|
+
|
|
345
|
+
const sessions = await listSessions();
|
|
346
|
+
|
|
347
|
+
expect(sessions).toHaveLength(0);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("throws AgentError on other tmux failures", async () => {
|
|
351
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "protocol version mismatch", 1));
|
|
352
|
+
|
|
353
|
+
await expect(listSessions()).rejects.toThrow(AgentError);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("skips malformed lines", async () => {
|
|
357
|
+
spawnSpy.mockImplementation(() =>
|
|
358
|
+
mockSpawnResult("valid-session:123\nmalformed-no-colon\n:no-name\n\n", "", 0),
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
const sessions = await listSessions();
|
|
362
|
+
|
|
363
|
+
expect(sessions).toHaveLength(1);
|
|
364
|
+
expect(sessions[0]?.name).toBe("valid-session");
|
|
365
|
+
expect(sessions[0]?.pid).toBe(123);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("passes correct args to tmux", async () => {
|
|
369
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
370
|
+
|
|
371
|
+
await listSessions();
|
|
372
|
+
|
|
373
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
374
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
375
|
+
const cmd = callArgs[0] as string[];
|
|
376
|
+
expect(cmd).toEqual([
|
|
377
|
+
"tmux",
|
|
378
|
+
"-L",
|
|
379
|
+
"agentplate",
|
|
380
|
+
"list-sessions",
|
|
381
|
+
"-F",
|
|
382
|
+
"#{session_name}:#{pid}",
|
|
383
|
+
]);
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("getPanePid", () => {
|
|
388
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
389
|
+
|
|
390
|
+
beforeEach(() => {
|
|
391
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
afterEach(() => {
|
|
395
|
+
spawnSpy.mockRestore();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("returns PID from tmux display-message", async () => {
|
|
399
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("42\n", "", 0));
|
|
400
|
+
|
|
401
|
+
const pid = await getPanePid("agentplate-auth");
|
|
402
|
+
|
|
403
|
+
expect(pid).toBe(42);
|
|
404
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
405
|
+
const cmd = callArgs[0] as string[];
|
|
406
|
+
expect(cmd).toEqual([
|
|
407
|
+
"tmux",
|
|
408
|
+
"-L",
|
|
409
|
+
"agentplate",
|
|
410
|
+
"display-message",
|
|
411
|
+
"-p",
|
|
412
|
+
"-t",
|
|
413
|
+
"agentplate-auth",
|
|
414
|
+
"#{pane_pid}",
|
|
415
|
+
]);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("returns null when session does not exist", async () => {
|
|
419
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
|
|
420
|
+
|
|
421
|
+
const pid = await getPanePid("gone");
|
|
422
|
+
|
|
423
|
+
expect(pid).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("returns null when output is empty", async () => {
|
|
427
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
428
|
+
|
|
429
|
+
const pid = await getPanePid("empty-output");
|
|
430
|
+
|
|
431
|
+
expect(pid).toBeNull();
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("returns null when output is not a number", async () => {
|
|
435
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("not-a-pid\n", "", 0));
|
|
436
|
+
|
|
437
|
+
const pid = await getPanePid("bad-output");
|
|
438
|
+
|
|
439
|
+
expect(pid).toBeNull();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("getDescendantPids", () => {
|
|
444
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
445
|
+
|
|
446
|
+
beforeEach(() => {
|
|
447
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
afterEach(() => {
|
|
451
|
+
spawnSpy.mockRestore();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("returns empty array when process has no children", async () => {
|
|
455
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
|
|
456
|
+
|
|
457
|
+
const pids = await getDescendantPids(100);
|
|
458
|
+
|
|
459
|
+
expect(pids).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("returns direct children when they have no grandchildren", async () => {
|
|
463
|
+
let callCount = 0;
|
|
464
|
+
spawnSpy.mockImplementation(() => {
|
|
465
|
+
callCount++;
|
|
466
|
+
if (callCount === 1) {
|
|
467
|
+
// pgrep -P 100 → children 200, 300
|
|
468
|
+
return mockSpawnResult("200\n300\n", "", 0);
|
|
469
|
+
}
|
|
470
|
+
// pgrep -P 200 and pgrep -P 300 → no grandchildren
|
|
471
|
+
return mockSpawnResult("", "", 1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const pids = await getDescendantPids(100);
|
|
475
|
+
|
|
476
|
+
expect(pids).toEqual([200, 300]);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("returns descendants in depth-first order (deepest first)", async () => {
|
|
480
|
+
// Tree: 100 → 200 → 400
|
|
481
|
+
// → 300
|
|
482
|
+
let callCount = 0;
|
|
483
|
+
spawnSpy.mockImplementation(() => {
|
|
484
|
+
callCount++;
|
|
485
|
+
if (callCount === 1) {
|
|
486
|
+
// pgrep -P 100 → children 200, 300
|
|
487
|
+
return mockSpawnResult("200\n300\n", "", 0);
|
|
488
|
+
}
|
|
489
|
+
if (callCount === 2) {
|
|
490
|
+
// pgrep -P 200 → child 400
|
|
491
|
+
return mockSpawnResult("400\n", "", 0);
|
|
492
|
+
}
|
|
493
|
+
if (callCount === 3) {
|
|
494
|
+
// pgrep -P 400 → no children
|
|
495
|
+
return mockSpawnResult("", "", 1);
|
|
496
|
+
}
|
|
497
|
+
// pgrep -P 300 → no children
|
|
498
|
+
return mockSpawnResult("", "", 1);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const pids = await getDescendantPids(100);
|
|
502
|
+
|
|
503
|
+
// Deepest-first: 400 (grandchild), then 200, 300 (direct children)
|
|
504
|
+
expect(pids).toEqual([400, 200, 300]);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("handles deeply nested tree", async () => {
|
|
508
|
+
// Tree: 1 → 2 → 3 → 4
|
|
509
|
+
let callCount = 0;
|
|
510
|
+
spawnSpy.mockImplementation(() => {
|
|
511
|
+
callCount++;
|
|
512
|
+
if (callCount === 1) {
|
|
513
|
+
// pgrep -P 1 → 2
|
|
514
|
+
return mockSpawnResult("2\n", "", 0);
|
|
515
|
+
}
|
|
516
|
+
if (callCount === 2) {
|
|
517
|
+
// pgrep -P 2 → 3
|
|
518
|
+
return mockSpawnResult("3\n", "", 0);
|
|
519
|
+
}
|
|
520
|
+
if (callCount === 3) {
|
|
521
|
+
// pgrep -P 3 → 4
|
|
522
|
+
return mockSpawnResult("4\n", "", 0);
|
|
523
|
+
}
|
|
524
|
+
// pgrep -P 4 → no children
|
|
525
|
+
return mockSpawnResult("", "", 1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const pids = await getDescendantPids(1);
|
|
529
|
+
|
|
530
|
+
// Deepest-first: 4, 3, 2
|
|
531
|
+
expect(pids).toEqual([4, 3, 2]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("skips non-numeric pgrep output lines", async () => {
|
|
535
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
536
|
+
const cmd = (args[0] as string[])[2];
|
|
537
|
+
if (cmd === "100") {
|
|
538
|
+
return mockSpawnResult("200\nnot-a-pid\n300\n", "", 0);
|
|
539
|
+
}
|
|
540
|
+
return mockSpawnResult("", "", 1);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const pids = await getDescendantPids(100);
|
|
544
|
+
|
|
545
|
+
expect(pids).toEqual([200, 300]);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe("isProcessAlive", () => {
|
|
550
|
+
test("returns true for current process (self-check)", () => {
|
|
551
|
+
// process.pid is always alive
|
|
552
|
+
expect(isProcessAlive(process.pid)).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test("returns false for a non-existent PID", () => {
|
|
556
|
+
// PID 2147483647 (max int32) is extremely unlikely to exist
|
|
557
|
+
expect(isProcessAlive(2147483647)).toBe(false);
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
describe("killProcessTree", () => {
|
|
562
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
563
|
+
let killSpy: ReturnType<typeof spyOn>;
|
|
564
|
+
|
|
565
|
+
beforeEach(() => {
|
|
566
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
567
|
+
killSpy = spyOn(process, "kill");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
afterEach(() => {
|
|
571
|
+
spawnSpy.mockRestore();
|
|
572
|
+
killSpy.mockRestore();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("sends SIGTERM to root when no descendants", async () => {
|
|
576
|
+
// pgrep -P 100 → no children
|
|
577
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
|
|
578
|
+
killSpy.mockImplementation(() => true);
|
|
579
|
+
|
|
580
|
+
await killProcessTree(100, 0);
|
|
581
|
+
|
|
582
|
+
expect(killSpy).toHaveBeenCalledWith(100, "SIGTERM");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
test("sends SIGTERM deepest-first then SIGKILL survivors", async () => {
|
|
586
|
+
// Tree: 100 → 200 → 300
|
|
587
|
+
let pgrepCallCount = 0;
|
|
588
|
+
spawnSpy.mockImplementation(() => {
|
|
589
|
+
pgrepCallCount++;
|
|
590
|
+
if (pgrepCallCount === 1) {
|
|
591
|
+
// pgrep -P 100 → 200
|
|
592
|
+
return mockSpawnResult("200\n", "", 0);
|
|
593
|
+
}
|
|
594
|
+
if (pgrepCallCount === 2) {
|
|
595
|
+
// pgrep -P 200 → 300
|
|
596
|
+
return mockSpawnResult("300\n", "", 0);
|
|
597
|
+
}
|
|
598
|
+
// pgrep -P 300 → no children
|
|
599
|
+
return mockSpawnResult("", "", 1);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const signals: Array<{ pid: number; signal: string }> = [];
|
|
603
|
+
killSpy.mockImplementation((pid: number, signal: string | number) => {
|
|
604
|
+
signals.push({ pid, signal: String(signal) });
|
|
605
|
+
return true;
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
await killProcessTree(100, 0);
|
|
609
|
+
|
|
610
|
+
// Phase 1 (SIGTERM): deepest-first → 300, 200, then root 100
|
|
611
|
+
// Phase 2 (SIGKILL): isProcessAlive check (signal 0), then SIGKILL for survivors
|
|
612
|
+
const sigterms = signals.filter((s) => s.signal === "SIGTERM");
|
|
613
|
+
expect(sigterms).toEqual([
|
|
614
|
+
{ pid: 300, signal: "SIGTERM" },
|
|
615
|
+
{ pid: 200, signal: "SIGTERM" },
|
|
616
|
+
{ pid: 100, signal: "SIGTERM" },
|
|
617
|
+
]);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("sends SIGKILL to survivors after grace period", async () => {
|
|
621
|
+
// Tree: 100 → 200 (no grandchildren)
|
|
622
|
+
let pgrepCallCount = 0;
|
|
623
|
+
spawnSpy.mockImplementation(() => {
|
|
624
|
+
pgrepCallCount++;
|
|
625
|
+
if (pgrepCallCount === 1) {
|
|
626
|
+
return mockSpawnResult("200\n", "", 0);
|
|
627
|
+
}
|
|
628
|
+
return mockSpawnResult("", "", 1);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const signals: Array<{ pid: number; signal: string | number }> = [];
|
|
632
|
+
killSpy.mockImplementation((pid: number, signal: string | number) => {
|
|
633
|
+
signals.push({ pid, signal });
|
|
634
|
+
// signal 0 is the isProcessAlive check — simulate processes still alive
|
|
635
|
+
return true;
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
await killProcessTree(100, 10); // 10ms grace period for test speed
|
|
639
|
+
|
|
640
|
+
// Should have: SIGTERM(200), SIGTERM(100), alive-check(200), SIGKILL(200),
|
|
641
|
+
// alive-check(100), SIGKILL(100)
|
|
642
|
+
const sigkills = signals.filter((s) => s.signal === "SIGKILL");
|
|
643
|
+
expect(sigkills.length).toBe(2);
|
|
644
|
+
expect(sigkills[0]).toEqual({ pid: 200, signal: "SIGKILL" });
|
|
645
|
+
expect(sigkills[1]).toEqual({ pid: 100, signal: "SIGKILL" });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("skips SIGKILL for processes that died during grace period", async () => {
|
|
649
|
+
// No children
|
|
650
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("200\n", "", 0));
|
|
651
|
+
// First call for pgrep children of 200
|
|
652
|
+
let pgrepCallCount = 0;
|
|
653
|
+
spawnSpy.mockImplementation(() => {
|
|
654
|
+
pgrepCallCount++;
|
|
655
|
+
if (pgrepCallCount === 1) {
|
|
656
|
+
return mockSpawnResult("200\n", "", 0);
|
|
657
|
+
}
|
|
658
|
+
return mockSpawnResult("", "", 1);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const signals: Array<{ pid: number; signal: string | number }> = [];
|
|
662
|
+
killSpy.mockImplementation((pid: number, signal: string | number) => {
|
|
663
|
+
signals.push({ pid, signal });
|
|
664
|
+
// signal 0 (isProcessAlive) — processes are dead
|
|
665
|
+
if (signal === 0) {
|
|
666
|
+
throw new Error("ESRCH");
|
|
667
|
+
}
|
|
668
|
+
return true;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await killProcessTree(100, 10);
|
|
672
|
+
|
|
673
|
+
// Should have SIGTERM calls but no SIGKILL (processes died)
|
|
674
|
+
const sigkills = signals.filter((s) => s.signal === "SIGKILL");
|
|
675
|
+
expect(sigkills).toEqual([]);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("silently handles SIGTERM errors for already-dead processes", async () => {
|
|
679
|
+
// No children
|
|
680
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 1));
|
|
681
|
+
|
|
682
|
+
killSpy.mockImplementation(() => {
|
|
683
|
+
throw new Error("ESRCH: No such process");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// Should not throw
|
|
687
|
+
await killProcessTree(100, 0);
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
describe("killSession", () => {
|
|
692
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
693
|
+
let killSpy: ReturnType<typeof spyOn>;
|
|
694
|
+
|
|
695
|
+
beforeEach(() => {
|
|
696
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
697
|
+
killSpy = spyOn(process, "kill");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
afterEach(() => {
|
|
701
|
+
spawnSpy.mockRestore();
|
|
702
|
+
killSpy.mockRestore();
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
test("gets pane PID, kills process tree, then kills tmux session", async () => {
|
|
706
|
+
const cmds: string[][] = [];
|
|
707
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
708
|
+
const cmd = args[0] as string[];
|
|
709
|
+
cmds.push(cmd);
|
|
710
|
+
|
|
711
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
712
|
+
// getPanePid → returns PID 500
|
|
713
|
+
return mockSpawnResult("500\n", "", 0);
|
|
714
|
+
}
|
|
715
|
+
if (cmd[0] === "pgrep") {
|
|
716
|
+
// getDescendantPids → no children
|
|
717
|
+
return mockSpawnResult("", "", 1);
|
|
718
|
+
}
|
|
719
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
720
|
+
return mockSpawnResult("", "", 0);
|
|
721
|
+
}
|
|
722
|
+
return mockSpawnResult("", "", 0);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
killSpy.mockImplementation(() => true);
|
|
726
|
+
|
|
727
|
+
await killSession("agentplate-auth");
|
|
728
|
+
|
|
729
|
+
// Should have called: tmux display-message, pgrep, tmux kill-session
|
|
730
|
+
expect(cmds[0]).toEqual([
|
|
731
|
+
"tmux",
|
|
732
|
+
"-L",
|
|
733
|
+
"agentplate",
|
|
734
|
+
"display-message",
|
|
735
|
+
"-p",
|
|
736
|
+
"-t",
|
|
737
|
+
"agentplate-auth",
|
|
738
|
+
"#{pane_pid}",
|
|
739
|
+
]);
|
|
740
|
+
expect(cmds[1]).toEqual(["pgrep", "-P", "500"]);
|
|
741
|
+
const lastCmd = cmds[cmds.length - 1];
|
|
742
|
+
expect(lastCmd).toEqual(["tmux", "-L", "agentplate", "kill-session", "-t", "agentplate-auth"]);
|
|
743
|
+
|
|
744
|
+
// Should have sent SIGTERM to root PID 500
|
|
745
|
+
expect(killSpy).toHaveBeenCalledWith(500, "SIGTERM");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("skips process cleanup when pane PID is not available", async () => {
|
|
749
|
+
const cmds: string[][] = [];
|
|
750
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
751
|
+
const cmd = args[0] as string[];
|
|
752
|
+
cmds.push(cmd);
|
|
753
|
+
|
|
754
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
755
|
+
// getPanePid → session not found
|
|
756
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
757
|
+
}
|
|
758
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
759
|
+
return mockSpawnResult("", "", 0);
|
|
760
|
+
}
|
|
761
|
+
return mockSpawnResult("", "", 0);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
await killSession("agentplate-auth");
|
|
765
|
+
|
|
766
|
+
// Should go straight to tmux kill-session (no pgrep calls)
|
|
767
|
+
expect(cmds).toHaveLength(2);
|
|
768
|
+
expect(cmds[0]?.[3]).toBe("display-message");
|
|
769
|
+
expect(cmds[1]?.[3]).toBe("kill-session");
|
|
770
|
+
// No process.kill calls since we had no PID
|
|
771
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("succeeds silently when session is already gone after process cleanup", async () => {
|
|
775
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
776
|
+
const cmd = args[0] as string[];
|
|
777
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
778
|
+
return mockSpawnResult("500\n", "", 0);
|
|
779
|
+
}
|
|
780
|
+
if (cmd[0] === "pgrep") {
|
|
781
|
+
return mockSpawnResult("", "", 1);
|
|
782
|
+
}
|
|
783
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
784
|
+
// Session already gone after process cleanup
|
|
785
|
+
return mockSpawnResult("", "can't find session: agentplate-auth", 1);
|
|
786
|
+
}
|
|
787
|
+
return mockSpawnResult("", "", 0);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
killSpy.mockImplementation(() => true);
|
|
791
|
+
|
|
792
|
+
// Should not throw — session disappearing is expected
|
|
793
|
+
await killSession("agentplate-auth");
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
test("throws AgentError on unexpected tmux kill-session failure", async () => {
|
|
797
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
798
|
+
const cmd = args[0] as string[];
|
|
799
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
800
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
801
|
+
}
|
|
802
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
803
|
+
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
804
|
+
}
|
|
805
|
+
return mockSpawnResult("", "", 0);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
await expect(killSession("broken-session")).rejects.toThrow(AgentError);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test("AgentError contains session name on failure", async () => {
|
|
812
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
813
|
+
const cmd = args[0] as string[];
|
|
814
|
+
if (cmd[0] === "tmux" && cmd[3] === "display-message") {
|
|
815
|
+
return mockSpawnResult("", "error", 1);
|
|
816
|
+
}
|
|
817
|
+
if (cmd[0] === "tmux" && cmd[3] === "kill-session") {
|
|
818
|
+
return mockSpawnResult("", "server exited unexpectedly", 1);
|
|
819
|
+
}
|
|
820
|
+
return mockSpawnResult("", "", 0);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
try {
|
|
824
|
+
await killSession("ghost-agent");
|
|
825
|
+
expect(true).toBe(false);
|
|
826
|
+
} catch (err: unknown) {
|
|
827
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
828
|
+
const agentErr = err as AgentError;
|
|
829
|
+
expect(agentErr.message).toContain("ghost-agent");
|
|
830
|
+
expect(agentErr.agentName).toBe("ghost-agent");
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test("throws AgentError when called with empty session name", async () => {
|
|
835
|
+
// Defense in depth (agentplate-74ce): tmux's `-t` argument prefix-matches
|
|
836
|
+
// every session in the server when given an empty string. Without this
|
|
837
|
+
// guard a regression in any caller would wildcard-kill the entire
|
|
838
|
+
// agentplate swarm. spawn must NOT be invoked.
|
|
839
|
+
await expect(killSession("")).rejects.toThrow(AgentError);
|
|
840
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
841
|
+
|
|
842
|
+
try {
|
|
843
|
+
await killSession("");
|
|
844
|
+
} catch (err: unknown) {
|
|
845
|
+
const agentErr = err as AgentError;
|
|
846
|
+
expect(agentErr.message).toContain("wildcard");
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe("isSessionAlive", () => {
|
|
852
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
853
|
+
|
|
854
|
+
beforeEach(() => {
|
|
855
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
afterEach(() => {
|
|
859
|
+
spawnSpy.mockRestore();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test("returns true when session exists (exit 0)", async () => {
|
|
863
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
864
|
+
|
|
865
|
+
const alive = await isSessionAlive("agentplate-auth");
|
|
866
|
+
|
|
867
|
+
expect(alive).toBe(true);
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("returns false when session does not exist (non-zero exit)", async () => {
|
|
871
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: nonexistent", 1));
|
|
872
|
+
|
|
873
|
+
const alive = await isSessionAlive("nonexistent");
|
|
874
|
+
|
|
875
|
+
expect(alive).toBe(false);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test("passes correct args to tmux has-session", async () => {
|
|
879
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
880
|
+
|
|
881
|
+
await isSessionAlive("my-agent");
|
|
882
|
+
|
|
883
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
884
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
885
|
+
const cmd = callArgs[0] as string[];
|
|
886
|
+
expect(cmd).toEqual(["tmux", "-L", "agentplate", "has-session", "-t", "my-agent"]);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("returns false for empty session name without calling tmux", async () => {
|
|
890
|
+
// Defense in depth (agentplate-74ce): an empty `-t` argument prefix-matches
|
|
891
|
+
// every agentplate session, so `has-session` would falsely report alive
|
|
892
|
+
// whenever any agent is running. Short-circuit to false without invoking tmux.
|
|
893
|
+
const alive = await isSessionAlive("");
|
|
894
|
+
expect(alive).toBe(false);
|
|
895
|
+
expect(spawnSpy).not.toHaveBeenCalled();
|
|
896
|
+
});
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
describe("checkSessionState", () => {
|
|
900
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
901
|
+
|
|
902
|
+
beforeEach(() => {
|
|
903
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
afterEach(() => {
|
|
907
|
+
spawnSpy.mockRestore();
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
test("returns alive when tmux has-session succeeds", async () => {
|
|
911
|
+
spawnSpy.mockReturnValue(mockSpawnResult("", "", 0));
|
|
912
|
+
const state = await checkSessionState("agentplate-test-coordinator");
|
|
913
|
+
expect(state).toBe("alive");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
test("returns no_server when tmux reports no server running", async () => {
|
|
917
|
+
spawnSpy.mockReturnValue(
|
|
918
|
+
mockSpawnResult("", "no server running on /tmp/tmux-1000/default\n", 1),
|
|
919
|
+
);
|
|
920
|
+
const state = await checkSessionState("agentplate-test-coordinator");
|
|
921
|
+
expect(state).toBe("no_server");
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("returns no_server when tmux reports no sessions", async () => {
|
|
925
|
+
spawnSpy.mockReturnValue(mockSpawnResult("", "no sessions\n", 1));
|
|
926
|
+
const state = await checkSessionState("agentplate-test-coordinator");
|
|
927
|
+
expect(state).toBe("no_server");
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test("returns dead when session not found", async () => {
|
|
931
|
+
spawnSpy.mockReturnValue(
|
|
932
|
+
mockSpawnResult("", "can't find session: agentplate-test-coordinator\n", 1),
|
|
933
|
+
);
|
|
934
|
+
const state = await checkSessionState("agentplate-test-coordinator");
|
|
935
|
+
expect(state).toBe("dead");
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
test("returns dead for generic tmux failure", async () => {
|
|
939
|
+
spawnSpy.mockReturnValue(
|
|
940
|
+
mockSpawnResult("", "error connecting to /tmp/tmux-1000/default\n", 1),
|
|
941
|
+
);
|
|
942
|
+
const state = await checkSessionState("agentplate-test-coordinator");
|
|
943
|
+
expect(state).toBe("dead");
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
describe("sendKeys", () => {
|
|
948
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
949
|
+
|
|
950
|
+
beforeEach(() => {
|
|
951
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
afterEach(() => {
|
|
955
|
+
spawnSpy.mockRestore();
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
test("passes correct args to tmux send-keys", async () => {
|
|
959
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
960
|
+
|
|
961
|
+
await sendKeys("agentplate-auth", "echo hello world");
|
|
962
|
+
|
|
963
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
964
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
965
|
+
const cmd = callArgs[0] as string[];
|
|
966
|
+
expect(cmd).toEqual([
|
|
967
|
+
"tmux",
|
|
968
|
+
"-L",
|
|
969
|
+
"agentplate",
|
|
970
|
+
"send-keys",
|
|
971
|
+
"-t",
|
|
972
|
+
"agentplate-auth",
|
|
973
|
+
"echo hello world",
|
|
974
|
+
"Enter",
|
|
975
|
+
]);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("flattens newlines in keys to spaces", async () => {
|
|
979
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
980
|
+
|
|
981
|
+
await sendKeys("agentplate-agent", "line1\nline2\nline3");
|
|
982
|
+
|
|
983
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
984
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
985
|
+
const cmd = callArgs[0] as string[];
|
|
986
|
+
expect(cmd).toEqual([
|
|
987
|
+
"tmux",
|
|
988
|
+
"-L",
|
|
989
|
+
"agentplate",
|
|
990
|
+
"send-keys",
|
|
991
|
+
"-t",
|
|
992
|
+
"agentplate-agent",
|
|
993
|
+
"line1 line2 line3",
|
|
994
|
+
"Enter",
|
|
995
|
+
]);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
test("throws AgentError on failure", async () => {
|
|
999
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: dead-agent", 1));
|
|
1000
|
+
|
|
1001
|
+
await expect(sendKeys("dead-agent", "echo test")).rejects.toThrow(AgentError);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test("AgentError contains session name on failure", async () => {
|
|
1005
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found: my-agent", 1));
|
|
1006
|
+
|
|
1007
|
+
try {
|
|
1008
|
+
await sendKeys("my-agent", "test command");
|
|
1009
|
+
expect(true).toBe(false);
|
|
1010
|
+
} catch (err: unknown) {
|
|
1011
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1012
|
+
const agentErr = err as AgentError;
|
|
1013
|
+
expect(agentErr.message).toContain("my-agent");
|
|
1014
|
+
expect(agentErr.agentName).toBe("my-agent");
|
|
1015
|
+
}
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
test("sends Enter with empty string (follow-up submission)", async () => {
|
|
1019
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
1020
|
+
|
|
1021
|
+
await sendKeys("agentplate-agent", "");
|
|
1022
|
+
|
|
1023
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
1024
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1025
|
+
const cmd = callArgs[0] as string[];
|
|
1026
|
+
expect(cmd).toEqual([
|
|
1027
|
+
"tmux",
|
|
1028
|
+
"-L",
|
|
1029
|
+
"agentplate",
|
|
1030
|
+
"send-keys",
|
|
1031
|
+
"-t",
|
|
1032
|
+
"agentplate-agent",
|
|
1033
|
+
"",
|
|
1034
|
+
"Enter",
|
|
1035
|
+
]);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("throws descriptive error when tmux server is not running", async () => {
|
|
1039
|
+
spawnSpy.mockImplementation(() =>
|
|
1040
|
+
mockSpawnResult("", "no server running on /tmp/tmux-0/default\n", 1),
|
|
1041
|
+
);
|
|
1042
|
+
await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(
|
|
1043
|
+
/Tmux server is not running/,
|
|
1044
|
+
);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("throws descriptive error when session not found", async () => {
|
|
1048
|
+
spawnSpy.mockImplementation(() =>
|
|
1049
|
+
mockSpawnResult("", "cant find session: agentplate-agent-fake\n", 1),
|
|
1050
|
+
);
|
|
1051
|
+
await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(/does not exist/);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("throws generic error for other failures", async () => {
|
|
1055
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "some other error\n", 1));
|
|
1056
|
+
await expect(sendKeys("agentplate-agent-fake", "hello")).rejects.toThrow(/Failed to send keys/);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
test("retries on transient 'can't find pane' error", async () => {
|
|
1060
|
+
let callCount = 0;
|
|
1061
|
+
spawnSpy.mockImplementation(() => {
|
|
1062
|
+
callCount++;
|
|
1063
|
+
if (callCount === 1) {
|
|
1064
|
+
// First send-keys fails with transient pane error
|
|
1065
|
+
return mockSpawnResult("", "can't find pane\n", 1);
|
|
1066
|
+
}
|
|
1067
|
+
// Second attempt succeeds
|
|
1068
|
+
return mockSpawnResult("", "", 0);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
await sendKeys("agentplate-retry-agent", "hello world");
|
|
1072
|
+
expect(spawnSpy).toHaveBeenCalledTimes(2);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
test("does not retry on permanent 'session not found' error", async () => {
|
|
1076
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "cant find session: gone-agent\n", 1));
|
|
1077
|
+
|
|
1078
|
+
await expect(sendKeys("gone-agent", "hello", 3)).rejects.toThrow(/does not exist/);
|
|
1079
|
+
// Only called once — no retries for permanent errors
|
|
1080
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
test("throws after exhausting retries on transient error", async () => {
|
|
1084
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find pane\n", 1));
|
|
1085
|
+
|
|
1086
|
+
await expect(sendKeys("agentplate-exhaust", "hello", 2)).rejects.toThrow(/not found after/);
|
|
1087
|
+
// Initial + 2 retries = 3 calls
|
|
1088
|
+
expect(spawnSpy).toHaveBeenCalledTimes(3);
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
describe("capturePaneContent", () => {
|
|
1093
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
1094
|
+
|
|
1095
|
+
beforeEach(() => {
|
|
1096
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
afterEach(() => {
|
|
1100
|
+
spawnSpy.mockRestore();
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("returns trimmed content on success", async () => {
|
|
1104
|
+
spawnSpy.mockImplementation(() => mockSpawnResult(" Welcome to Claude Code! \n\n", "", 0));
|
|
1105
|
+
|
|
1106
|
+
const content = await capturePaneContent("agentplate-agent");
|
|
1107
|
+
|
|
1108
|
+
expect(content).toBe("Welcome to Claude Code!");
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("passes correct args to tmux capture-pane", async () => {
|
|
1112
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("some content", "", 0));
|
|
1113
|
+
|
|
1114
|
+
await capturePaneContent("my-session", 100);
|
|
1115
|
+
|
|
1116
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1117
|
+
const cmd = callArgs[0] as string[];
|
|
1118
|
+
expect(cmd).toEqual([
|
|
1119
|
+
"tmux",
|
|
1120
|
+
"-L",
|
|
1121
|
+
"agentplate",
|
|
1122
|
+
"capture-pane",
|
|
1123
|
+
"-t",
|
|
1124
|
+
"my-session",
|
|
1125
|
+
"-p",
|
|
1126
|
+
"-S",
|
|
1127
|
+
"-100",
|
|
1128
|
+
]);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
test("uses default 50 lines when not specified", async () => {
|
|
1132
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("content", "", 0));
|
|
1133
|
+
|
|
1134
|
+
await capturePaneContent("my-session");
|
|
1135
|
+
|
|
1136
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1137
|
+
const cmd = callArgs[0] as string[];
|
|
1138
|
+
expect(cmd[8]).toBe("-50");
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
test("returns null when capture-pane fails", async () => {
|
|
1142
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "can't find session: gone", 1));
|
|
1143
|
+
|
|
1144
|
+
const content = await capturePaneContent("gone");
|
|
1145
|
+
|
|
1146
|
+
expect(content).toBeNull();
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("returns null when pane is empty (whitespace only)", async () => {
|
|
1150
|
+
spawnSpy.mockImplementation(() => mockSpawnResult(" \n\n \n", "", 0));
|
|
1151
|
+
|
|
1152
|
+
const content = await capturePaneContent("empty-pane");
|
|
1153
|
+
|
|
1154
|
+
expect(content).toBeNull();
|
|
1155
|
+
});
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
/** Claude-like detectReady for tests — matches the existing hardcoded behavior. */
|
|
1159
|
+
function claudeDetectReady(paneContent: string): ReadyState {
|
|
1160
|
+
if (
|
|
1161
|
+
paneContent.includes("WARNING: Claude Code running in Bypass Permissions mode") &&
|
|
1162
|
+
paneContent.includes("1. No, exit") &&
|
|
1163
|
+
paneContent.includes("2. Yes, I accept")
|
|
1164
|
+
) {
|
|
1165
|
+
return { phase: "dialog", action: "type:2" };
|
|
1166
|
+
}
|
|
1167
|
+
if (paneContent.includes("trust this folder")) {
|
|
1168
|
+
return { phase: "dialog", action: "Enter" };
|
|
1169
|
+
}
|
|
1170
|
+
const hasPrompt = paneContent.includes("\u276f") || paneContent.includes('Try "');
|
|
1171
|
+
const hasStatusBar =
|
|
1172
|
+
paneContent.includes("bypass permissions") || paneContent.includes("shift+tab");
|
|
1173
|
+
if (hasPrompt && hasStatusBar) {
|
|
1174
|
+
return { phase: "ready" };
|
|
1175
|
+
}
|
|
1176
|
+
return { phase: "loading" };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
describe("waitForTuiReady", () => {
|
|
1180
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
1181
|
+
let sleepSpy: ReturnType<typeof spyOn>;
|
|
1182
|
+
|
|
1183
|
+
beforeEach(() => {
|
|
1184
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
1185
|
+
// Mock Bun.sleep to avoid real delays in tests.
|
|
1186
|
+
// Cast needed because Bun.sleep has overloads that confuse spyOn's type inference.
|
|
1187
|
+
sleepSpy = spyOn(Bun as Record<string, unknown>, "sleep").mockResolvedValue(undefined);
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
afterEach(() => {
|
|
1191
|
+
spawnSpy.mockRestore();
|
|
1192
|
+
sleepSpy.mockRestore();
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
test("returns true immediately when pane has content on first poll", async () => {
|
|
1196
|
+
spawnSpy.mockImplementation(() =>
|
|
1197
|
+
mockSpawnResult('Try "help" to get started\nbypass permissions', "", 0),
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 5_000, 500);
|
|
1201
|
+
|
|
1202
|
+
expect(ready).toBe(true);
|
|
1203
|
+
// Should not have needed to sleep (content found on first poll)
|
|
1204
|
+
expect(sleepSpy).not.toHaveBeenCalled();
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("returns true after content appears on later poll", async () => {
|
|
1208
|
+
let captureCallCount = 0;
|
|
1209
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1210
|
+
const cmd = args[0] as string[];
|
|
1211
|
+
if (cmd[3] === "capture-pane") {
|
|
1212
|
+
captureCallCount++;
|
|
1213
|
+
if (captureCallCount <= 3) {
|
|
1214
|
+
// First 3 capture-pane polls: empty pane (TUI still loading)
|
|
1215
|
+
return mockSpawnResult("", "", 0);
|
|
1216
|
+
}
|
|
1217
|
+
// 4th poll: content appears with both prompt indicator and status bar
|
|
1218
|
+
return mockSpawnResult("Welcome to Claude Code!\n\n\u276f\nbypass permissions", "", 0);
|
|
1219
|
+
}
|
|
1220
|
+
// has-session: session is alive throughout
|
|
1221
|
+
return mockSpawnResult("", "", 0);
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1225
|
+
|
|
1226
|
+
expect(ready).toBe(true);
|
|
1227
|
+
// Should have slept 3 times (3 empty capture-pane polls before content appeared)
|
|
1228
|
+
expect(sleepSpy).toHaveBeenCalledTimes(3);
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test("returns false when timeout expires without content", async () => {
|
|
1232
|
+
// Pane always empty
|
|
1233
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 0));
|
|
1234
|
+
|
|
1235
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 2_000, 500);
|
|
1236
|
+
|
|
1237
|
+
expect(ready).toBe(false);
|
|
1238
|
+
// 2000ms / 500ms = 4 polls, 4 sleeps
|
|
1239
|
+
expect(sleepSpy).toHaveBeenCalledTimes(4);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
test("returns false when capture-pane always fails", async () => {
|
|
1243
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "session not found", 1));
|
|
1244
|
+
|
|
1245
|
+
const ready = await waitForTuiReady("dead-session", claudeDetectReady, 1_000, 500);
|
|
1246
|
+
|
|
1247
|
+
expect(ready).toBe(false);
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
test("uses default timeout and poll interval", async () => {
|
|
1251
|
+
// Return content immediately with both indicators
|
|
1252
|
+
spawnSpy.mockImplementation(() => mockSpawnResult('Try "help"\nshift+tab', "", 0));
|
|
1253
|
+
|
|
1254
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady);
|
|
1255
|
+
|
|
1256
|
+
expect(ready).toBe(true);
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
test("returns false immediately when session is dead", async () => {
|
|
1260
|
+
// capture-pane fails (session dead), has-session also fails (session dead)
|
|
1261
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1262
|
+
const cmd = args[0] as string[];
|
|
1263
|
+
if (cmd[3] === "capture-pane") {
|
|
1264
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
1265
|
+
}
|
|
1266
|
+
// has-session: session is dead
|
|
1267
|
+
return mockSpawnResult("", "can't find session", 1);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
const ready = await waitForTuiReady("dead-session", claudeDetectReady, 15_000, 500);
|
|
1271
|
+
|
|
1272
|
+
expect(ready).toBe(false);
|
|
1273
|
+
// Should NOT have polled the full timeout (no sleeps — returned immediately)
|
|
1274
|
+
expect(sleepSpy).not.toHaveBeenCalled();
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
test("continues polling when session is alive but pane is empty", async () => {
|
|
1278
|
+
let captureCallCount = 0;
|
|
1279
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1280
|
+
const cmd = args[0] as string[];
|
|
1281
|
+
if (cmd[3] === "capture-pane") {
|
|
1282
|
+
captureCallCount++;
|
|
1283
|
+
// Pane stays empty for all polls (session alive but TUI not rendered yet)
|
|
1284
|
+
return mockSpawnResult("", "", 0);
|
|
1285
|
+
}
|
|
1286
|
+
// has-session: session is alive
|
|
1287
|
+
return mockSpawnResult("", "", 0);
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
// Use a short timeout so the test doesn't take long
|
|
1291
|
+
const ready = await waitForTuiReady("loading-session", claudeDetectReady, 1_000, 500);
|
|
1292
|
+
|
|
1293
|
+
expect(ready).toBe(false);
|
|
1294
|
+
// Should have polled multiple times (not returned early)
|
|
1295
|
+
expect(captureCallCount).toBeGreaterThan(1);
|
|
1296
|
+
expect(sleepSpy).toHaveBeenCalled();
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test("returns false when only prompt seen but no status bar", async () => {
|
|
1300
|
+
// Pane always shows prompt indicator but never shows status bar text
|
|
1301
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1302
|
+
const cmd = args[0] as string[];
|
|
1303
|
+
if (cmd[3] === "capture-pane") {
|
|
1304
|
+
return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
|
|
1305
|
+
}
|
|
1306
|
+
// has-session: session is alive
|
|
1307
|
+
return mockSpawnResult("", "", 0);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 1_000, 500);
|
|
1311
|
+
|
|
1312
|
+
expect(ready).toBe(false);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
test("returns false when only status bar seen but no prompt", async () => {
|
|
1316
|
+
// Pane always shows status bar but never shows prompt indicator
|
|
1317
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1318
|
+
const cmd = args[0] as string[];
|
|
1319
|
+
if (cmd[3] === "capture-pane") {
|
|
1320
|
+
return mockSpawnResult("bypass permissions", "", 0);
|
|
1321
|
+
}
|
|
1322
|
+
// has-session: session is alive
|
|
1323
|
+
return mockSpawnResult("", "", 0);
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 1_000, 500);
|
|
1327
|
+
|
|
1328
|
+
expect(ready).toBe(false);
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test("returns true when prompt and status bar appear on different polls", async () => {
|
|
1332
|
+
let captureCallCount = 0;
|
|
1333
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1334
|
+
const cmd = args[0] as string[];
|
|
1335
|
+
if (cmd[3] === "capture-pane") {
|
|
1336
|
+
captureCallCount++;
|
|
1337
|
+
if (captureCallCount <= 2) {
|
|
1338
|
+
// First 2 polls: only prompt indicator visible (phase 1 only)
|
|
1339
|
+
return mockSpawnResult("Welcome to Claude Code!\n\u276f", "", 0);
|
|
1340
|
+
}
|
|
1341
|
+
// 3rd poll onwards: both prompt and status bar visible
|
|
1342
|
+
return mockSpawnResult("Welcome to Claude Code!\n\u276f\nbypass permissions", "", 0);
|
|
1343
|
+
}
|
|
1344
|
+
// has-session: session is alive
|
|
1345
|
+
return mockSpawnResult("", "", 0);
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1349
|
+
|
|
1350
|
+
expect(ready).toBe(true);
|
|
1351
|
+
// Should have slept at least twice (2 polls with only prompt before both appeared)
|
|
1352
|
+
expect(sleepSpy).toHaveBeenCalledTimes(2);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
test("detects trust dialog and auto-confirms with Enter", async () => {
|
|
1356
|
+
const sendKeysCalls: string[][] = [];
|
|
1357
|
+
let captureCallCount = 0;
|
|
1358
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1359
|
+
const cmd = args[0] as string[];
|
|
1360
|
+
if (cmd[3] === "capture-pane") {
|
|
1361
|
+
captureCallCount++;
|
|
1362
|
+
if (captureCallCount === 1) {
|
|
1363
|
+
// First poll: trust dialog is showing
|
|
1364
|
+
return mockSpawnResult("Do you trust this folder?", "", 0);
|
|
1365
|
+
}
|
|
1366
|
+
// Subsequent polls: trust confirmed, real TUI with both indicators
|
|
1367
|
+
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1368
|
+
}
|
|
1369
|
+
if (cmd[3] === "send-keys") {
|
|
1370
|
+
sendKeysCalls.push(cmd);
|
|
1371
|
+
return mockSpawnResult("", "", 0);
|
|
1372
|
+
}
|
|
1373
|
+
// has-session: session is alive
|
|
1374
|
+
return mockSpawnResult("", "", 0);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1378
|
+
|
|
1379
|
+
expect(ready).toBe(true);
|
|
1380
|
+
// sendKeys should have been called once to confirm the trust dialog
|
|
1381
|
+
expect(sendKeysCalls).toHaveLength(1);
|
|
1382
|
+
const trustCall = sendKeysCalls[0];
|
|
1383
|
+
expect(trustCall).toEqual([
|
|
1384
|
+
"tmux",
|
|
1385
|
+
"-L",
|
|
1386
|
+
"agentplate",
|
|
1387
|
+
"send-keys",
|
|
1388
|
+
"-t",
|
|
1389
|
+
"agentplate-agent",
|
|
1390
|
+
"",
|
|
1391
|
+
"Enter",
|
|
1392
|
+
]);
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
test("detects bypass permissions dialog and types 2 before Enter", async () => {
|
|
1396
|
+
const sendKeysCalls: string[][] = [];
|
|
1397
|
+
let captureCallCount = 0;
|
|
1398
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1399
|
+
const cmd = args[0] as string[];
|
|
1400
|
+
if (cmd[3] === "capture-pane") {
|
|
1401
|
+
captureCallCount++;
|
|
1402
|
+
if (captureCallCount === 1) {
|
|
1403
|
+
return mockSpawnResult(
|
|
1404
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
1405
|
+
"",
|
|
1406
|
+
0,
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1410
|
+
}
|
|
1411
|
+
if (cmd[3] === "send-keys") {
|
|
1412
|
+
sendKeysCalls.push(cmd);
|
|
1413
|
+
return mockSpawnResult("", "", 0);
|
|
1414
|
+
}
|
|
1415
|
+
return mockSpawnResult("", "", 0);
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1419
|
+
|
|
1420
|
+
expect(ready).toBe(true);
|
|
1421
|
+
expect(sendKeysCalls).toHaveLength(2);
|
|
1422
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1423
|
+
"tmux",
|
|
1424
|
+
"-L",
|
|
1425
|
+
"agentplate",
|
|
1426
|
+
"send-keys",
|
|
1427
|
+
"-t",
|
|
1428
|
+
"agentplate-agent",
|
|
1429
|
+
"2",
|
|
1430
|
+
]);
|
|
1431
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1432
|
+
"tmux",
|
|
1433
|
+
"-L",
|
|
1434
|
+
"agentplate",
|
|
1435
|
+
"send-keys",
|
|
1436
|
+
"-t",
|
|
1437
|
+
"agentplate-agent",
|
|
1438
|
+
"",
|
|
1439
|
+
"Enter",
|
|
1440
|
+
]);
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
test("retries typed bypass dialog action when the same dialog persists", async () => {
|
|
1444
|
+
const sendKeysCalls: string[][] = [];
|
|
1445
|
+
let captureCallCount = 0;
|
|
1446
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1447
|
+
const cmd = args[0] as string[];
|
|
1448
|
+
if (cmd[3] === "capture-pane") {
|
|
1449
|
+
captureCallCount++;
|
|
1450
|
+
if (captureCallCount <= 3) {
|
|
1451
|
+
return mockSpawnResult(
|
|
1452
|
+
"WARNING: Claude Code running in Bypass Permissions mode\n❯ 1. No, exit\n2. Yes, I accept",
|
|
1453
|
+
"",
|
|
1454
|
+
0,
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
return mockSpawnResult('Try "help"\nshift+tab', "", 0);
|
|
1458
|
+
}
|
|
1459
|
+
if (cmd[3] === "send-keys") {
|
|
1460
|
+
sendKeysCalls.push(cmd);
|
|
1461
|
+
return mockSpawnResult("", "", 0);
|
|
1462
|
+
}
|
|
1463
|
+
return mockSpawnResult("", "", 0);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1467
|
+
|
|
1468
|
+
expect(ready).toBe(true);
|
|
1469
|
+
expect(sendKeysCalls).toHaveLength(4);
|
|
1470
|
+
expect(sendKeysCalls[0]).toEqual([
|
|
1471
|
+
"tmux",
|
|
1472
|
+
"-L",
|
|
1473
|
+
"agentplate",
|
|
1474
|
+
"send-keys",
|
|
1475
|
+
"-t",
|
|
1476
|
+
"agentplate-agent",
|
|
1477
|
+
"2",
|
|
1478
|
+
]);
|
|
1479
|
+
expect(sendKeysCalls[1]).toEqual([
|
|
1480
|
+
"tmux",
|
|
1481
|
+
"-L",
|
|
1482
|
+
"agentplate",
|
|
1483
|
+
"send-keys",
|
|
1484
|
+
"-t",
|
|
1485
|
+
"agentplate-agent",
|
|
1486
|
+
"",
|
|
1487
|
+
"Enter",
|
|
1488
|
+
]);
|
|
1489
|
+
expect(sendKeysCalls[2]).toEqual([
|
|
1490
|
+
"tmux",
|
|
1491
|
+
"-L",
|
|
1492
|
+
"agentplate",
|
|
1493
|
+
"send-keys",
|
|
1494
|
+
"-t",
|
|
1495
|
+
"agentplate-agent",
|
|
1496
|
+
"2",
|
|
1497
|
+
]);
|
|
1498
|
+
expect(sendKeysCalls[3]).toEqual([
|
|
1499
|
+
"tmux",
|
|
1500
|
+
"-L",
|
|
1501
|
+
"agentplate",
|
|
1502
|
+
"send-keys",
|
|
1503
|
+
"-t",
|
|
1504
|
+
"agentplate-agent",
|
|
1505
|
+
"",
|
|
1506
|
+
"Enter",
|
|
1507
|
+
]);
|
|
1508
|
+
});
|
|
1509
|
+
|
|
1510
|
+
test("handles trust dialog only once (trustHandled flag)", async () => {
|
|
1511
|
+
const sendKeysCalls: string[][] = [];
|
|
1512
|
+
let captureCallCount = 0;
|
|
1513
|
+
spawnSpy.mockImplementation((...args: unknown[]) => {
|
|
1514
|
+
const cmd = args[0] as string[];
|
|
1515
|
+
if (cmd[3] === "capture-pane") {
|
|
1516
|
+
captureCallCount++;
|
|
1517
|
+
if (captureCallCount <= 3) {
|
|
1518
|
+
// Multiple polls still show trust dialog (slow dialog dismissal)
|
|
1519
|
+
return mockSpawnResult("Do you trust this folder?", "", 0);
|
|
1520
|
+
}
|
|
1521
|
+
// Eventually TUI loads with both indicators
|
|
1522
|
+
return mockSpawnResult('Try "help"\nbypass permissions', "", 0);
|
|
1523
|
+
}
|
|
1524
|
+
if (cmd[3] === "send-keys") {
|
|
1525
|
+
sendKeysCalls.push(cmd);
|
|
1526
|
+
return mockSpawnResult("", "", 0);
|
|
1527
|
+
}
|
|
1528
|
+
// has-session: session is alive
|
|
1529
|
+
return mockSpawnResult("", "", 0);
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
const ready = await waitForTuiReady("agentplate-agent", claudeDetectReady, 10_000, 500);
|
|
1533
|
+
|
|
1534
|
+
expect(ready).toBe(true);
|
|
1535
|
+
// sendKeys must be called exactly once — dialogHandled prevents duplicate Enter sends
|
|
1536
|
+
expect(sendKeysCalls).toHaveLength(1);
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
describe("ensureTmuxAvailable", () => {
|
|
1541
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
1542
|
+
|
|
1543
|
+
beforeEach(() => {
|
|
1544
|
+
spawnSpy = spyOn(Bun, "spawn");
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
afterEach(() => {
|
|
1548
|
+
spawnSpy.mockRestore();
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
test("succeeds when tmux is available", async () => {
|
|
1552
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("tmux 3.3a\n", "", 0));
|
|
1553
|
+
|
|
1554
|
+
// Should not throw
|
|
1555
|
+
await ensureTmuxAvailable();
|
|
1556
|
+
|
|
1557
|
+
expect(spawnSpy).toHaveBeenCalledTimes(1);
|
|
1558
|
+
const callArgs = spawnSpy.mock.calls[0] as unknown[];
|
|
1559
|
+
const cmd = callArgs[0] as string[];
|
|
1560
|
+
expect(cmd).toEqual(["tmux", "-V"]);
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
test("throws AgentError when tmux is not installed", async () => {
|
|
1564
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "tmux: command not found", 1));
|
|
1565
|
+
|
|
1566
|
+
await expect(ensureTmuxAvailable()).rejects.toThrow(AgentError);
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
test("AgentError message mentions tmux not installed", async () => {
|
|
1570
|
+
spawnSpy.mockImplementation(() => mockSpawnResult("", "", 127));
|
|
1571
|
+
|
|
1572
|
+
try {
|
|
1573
|
+
await ensureTmuxAvailable();
|
|
1574
|
+
expect(true).toBe(false); // Should have thrown
|
|
1575
|
+
} catch (err: unknown) {
|
|
1576
|
+
expect(err).toBeInstanceOf(AgentError);
|
|
1577
|
+
const agentErr = err as AgentError;
|
|
1578
|
+
expect(agentErr.message).toContain("tmux is not installed");
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
|
|
1583
|
+
describe("sanitizeTmuxName", () => {
|
|
1584
|
+
test("replaces dots with underscores", () => {
|
|
1585
|
+
expect(sanitizeTmuxName("consulting.hgoudat.com")).toBe("consulting_hgoudat_com");
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("replaces colons with underscores", () => {
|
|
1589
|
+
expect(sanitizeTmuxName("host:8080")).toBe("host_8080");
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
test("replaces mixed dots and colons", () => {
|
|
1593
|
+
expect(sanitizeTmuxName("my.project:v2.0")).toBe("my_project_v2_0");
|
|
1594
|
+
});
|
|
1595
|
+
|
|
1596
|
+
test("leaves names without special characters unchanged", () => {
|
|
1597
|
+
expect(sanitizeTmuxName("my-project")).toBe("my-project");
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
test("handles empty string", () => {
|
|
1601
|
+
expect(sanitizeTmuxName("")).toBe("");
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
test("handles name with only dots", () => {
|
|
1605
|
+
expect(sanitizeTmuxName("...")).toBe("___");
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
test("produces valid tmux session name components", () => {
|
|
1609
|
+
// A real-world project name that would break tmux target parsing
|
|
1610
|
+
const projectName = "consulting.hgoudat.com";
|
|
1611
|
+
const sessionName = `agentplate-${sanitizeTmuxName(projectName)}-coordinator`;
|
|
1612
|
+
expect(sessionName).toBe("agentplate-consulting_hgoudat_com-coordinator");
|
|
1613
|
+
// No dots or colons that tmux would interpret as separators
|
|
1614
|
+
expect(sessionName).not.toMatch(/[.:]/);
|
|
1615
|
+
});
|
|
1616
|
+
});
|