@cluesmith/codev 2.0.0-rc.7 → 2.0.0-rc.71
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/bin/af.js +2 -2
- package/bin/consult.js +1 -1
- package/bin/porch.js +6 -35
- package/dashboard/dist/assets/index-C7FtNK6Y.css +32 -0
- package/dashboard/dist/assets/index-CDAINZKT.js +131 -0
- package/dashboard/dist/assets/index-CDAINZKT.js.map +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/agent-farm/cli.d.ts.map +1 -1
- package/dist/agent-farm/cli.js +173 -118
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts +3 -3
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +20 -147
- package/dist/agent-farm/commands/architect.js.map +1 -1
- package/dist/agent-farm/commands/attach.d.ts +13 -0
- package/dist/agent-farm/commands/attach.d.ts.map +1 -0
- package/dist/agent-farm/commands/attach.js +144 -0
- package/dist/agent-farm/commands/attach.js.map +1 -0
- package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
- package/dist/agent-farm/commands/cleanup.js +35 -19
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/consult.d.ts +3 -4
- package/dist/agent-farm/commands/consult.d.ts.map +1 -1
- package/dist/agent-farm/commands/consult.js +27 -37
- package/dist/agent-farm/commands/consult.js.map +1 -1
- package/dist/agent-farm/commands/index.d.ts +2 -2
- package/dist/agent-farm/commands/index.d.ts.map +1 -1
- package/dist/agent-farm/commands/index.js +2 -2
- package/dist/agent-farm/commands/index.js.map +1 -1
- package/dist/agent-farm/commands/open.d.ts +4 -2
- package/dist/agent-farm/commands/open.d.ts.map +1 -1
- package/dist/agent-farm/commands/open.js +33 -83
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/send.d.ts +1 -1
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +60 -79
- package/dist/agent-farm/commands/send.js.map +1 -1
- package/dist/agent-farm/commands/shell.d.ts +15 -0
- package/dist/agent-farm/commands/shell.d.ts.map +1 -0
- package/dist/agent-farm/commands/shell.js +50 -0
- package/dist/agent-farm/commands/shell.js.map +1 -0
- package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
- package/dist/agent-farm/commands/spawn.js +597 -281
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +10 -20
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +45 -491
- package/dist/agent-farm/commands/start.js.map +1 -1
- package/dist/agent-farm/commands/status.d.ts +2 -0
- package/dist/agent-farm/commands/status.d.ts.map +1 -1
- package/dist/agent-farm/commands/status.js +75 -24
- package/dist/agent-farm/commands/status.js.map +1 -1
- package/dist/agent-farm/commands/stop.d.ts +6 -0
- package/dist/agent-farm/commands/stop.d.ts.map +1 -1
- package/dist/agent-farm/commands/stop.js +49 -109
- package/dist/agent-farm/commands/stop.js.map +1 -1
- package/dist/agent-farm/commands/tower-cloud.d.ts +48 -0
- package/dist/agent-farm/commands/tower-cloud.d.ts.map +1 -0
- package/dist/agent-farm/commands/tower-cloud.js +329 -0
- package/dist/agent-farm/commands/tower-cloud.js.map +1 -0
- package/dist/agent-farm/commands/tower.d.ts +9 -0
- package/dist/agent-farm/commands/tower.d.ts.map +1 -1
- package/dist/agent-farm/commands/tower.js +59 -19
- package/dist/agent-farm/commands/tower.js.map +1 -1
- package/dist/agent-farm/db/index.d.ts +6 -2
- package/dist/agent-farm/db/index.d.ts.map +1 -1
- package/dist/agent-farm/db/index.js +246 -18
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/migrate.d.ts +0 -4
- package/dist/agent-farm/db/migrate.d.ts.map +1 -1
- package/dist/agent-farm/db/migrate.js +6 -55
- package/dist/agent-farm/db/migrate.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +3 -3
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +25 -19
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +3 -13
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +3 -11
- package/dist/agent-farm/db/types.js.map +1 -1
- package/dist/agent-farm/hq-connector.d.ts +2 -6
- package/dist/agent-farm/hq-connector.d.ts.map +1 -1
- package/dist/agent-farm/hq-connector.js +2 -17
- package/dist/agent-farm/hq-connector.js.map +1 -1
- package/dist/agent-farm/lib/cloud-config.d.ts +59 -0
- package/dist/agent-farm/lib/cloud-config.d.ts.map +1 -0
- package/dist/agent-farm/lib/cloud-config.js +143 -0
- package/dist/agent-farm/lib/cloud-config.js.map +1 -0
- package/dist/agent-farm/lib/tower-client.d.ts +163 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tower-client.js +233 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts +117 -0
- package/dist/agent-farm/lib/tunnel-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tunnel-client.js +504 -0
- package/dist/agent-farm/lib/tunnel-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +2653 -185
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts +6 -12
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +34 -49
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +49 -26
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts +0 -5
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +12 -44
- package/dist/agent-farm/utils/config.js.map +1 -1
- package/dist/agent-farm/utils/deps.d.ts.map +1 -1
- package/dist/agent-farm/utils/deps.js +0 -32
- package/dist/agent-farm/utils/deps.js.map +1 -1
- package/dist/agent-farm/utils/file-tabs.d.ts +27 -0
- package/dist/agent-farm/utils/file-tabs.d.ts.map +1 -0
- package/dist/agent-farm/utils/file-tabs.js +46 -0
- package/dist/agent-farm/utils/file-tabs.js.map +1 -0
- package/dist/agent-farm/utils/gate-status.d.ts +16 -0
- package/dist/agent-farm/utils/gate-status.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-status.js +79 -0
- package/dist/agent-farm/utils/gate-status.js.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts +38 -0
- package/dist/agent-farm/utils/gate-watcher.d.ts.map +1 -0
- package/dist/agent-farm/utils/gate-watcher.js +122 -0
- package/dist/agent-farm/utils/gate-watcher.js.map +1 -0
- package/dist/agent-farm/utils/index.d.ts +0 -1
- package/dist/agent-farm/utils/index.d.ts.map +1 -1
- package/dist/agent-farm/utils/index.js +0 -1
- package/dist/agent-farm/utils/index.js.map +1 -1
- package/dist/agent-farm/utils/notifications.d.ts +30 -0
- package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
- package/dist/agent-farm/utils/notifications.js +121 -0
- package/dist/agent-farm/utils/notifications.js.map +1 -0
- package/dist/agent-farm/utils/server-utils.d.ts +5 -5
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +5 -16
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- package/dist/agent-farm/utils/session.d.ts +32 -0
- package/dist/agent-farm/utils/session.d.ts.map +1 -0
- package/dist/agent-farm/utils/session.js +57 -0
- package/dist/agent-farm/utils/session.js.map +1 -0
- package/dist/agent-farm/utils/shell.d.ts +9 -22
- package/dist/agent-farm/utils/shell.d.ts.map +1 -1
- package/dist/agent-farm/utils/shell.js +34 -34
- package/dist/agent-farm/utils/shell.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +11 -54
- package/dist/cli.js.map +1 -1
- package/dist/commands/adopt.d.ts.map +1 -1
- package/dist/commands/adopt.js +49 -4
- package/dist/commands/adopt.js.map +1 -1
- package/dist/commands/consult/index.d.ts +13 -2
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +245 -29
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +96 -79
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/porch/build-counter.d.ts +5 -0
- package/dist/commands/porch/build-counter.d.ts.map +1 -0
- package/dist/commands/porch/build-counter.js +5 -0
- package/dist/commands/porch/build-counter.js.map +1 -0
- package/dist/commands/porch/checks.d.ts +17 -29
- package/dist/commands/porch/checks.d.ts.map +1 -1
- package/dist/commands/porch/checks.js +96 -144
- package/dist/commands/porch/checks.js.map +1 -1
- package/dist/commands/porch/index.d.ts +25 -43
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +463 -1116
- package/dist/commands/porch/index.js.map +1 -1
- package/dist/commands/porch/next.d.ts +22 -0
- package/dist/commands/porch/next.d.ts.map +1 -0
- package/dist/commands/porch/next.js +571 -0
- package/dist/commands/porch/next.js.map +1 -0
- package/dist/commands/porch/plan.d.ts +70 -0
- package/dist/commands/porch/plan.d.ts.map +1 -0
- package/dist/commands/porch/plan.js +190 -0
- package/dist/commands/porch/plan.js.map +1 -0
- package/dist/commands/porch/prompts.d.ts +19 -0
- package/dist/commands/porch/prompts.d.ts.map +1 -0
- package/dist/commands/porch/prompts.js +277 -0
- package/dist/commands/porch/prompts.js.map +1 -0
- package/dist/commands/porch/protocol.d.ts +59 -0
- package/dist/commands/porch/protocol.d.ts.map +1 -0
- package/dist/commands/porch/protocol.js +294 -0
- package/dist/commands/porch/protocol.js.map +1 -0
- package/dist/commands/porch/state.d.ts +36 -107
- package/dist/commands/porch/state.d.ts.map +1 -1
- package/dist/commands/porch/state.js +120 -699
- package/dist/commands/porch/state.js.map +1 -1
- package/dist/commands/porch/types.d.ts +99 -164
- package/dist/commands/porch/types.d.ts.map +1 -1
- package/dist/commands/porch/types.js +2 -1
- package/dist/commands/porch/types.js.map +1 -1
- package/dist/commands/porch/verdict.d.ts +31 -0
- package/dist/commands/porch/verdict.d.ts.map +1 -0
- package/dist/commands/porch/verdict.js +59 -0
- package/dist/commands/porch/verdict.js.map +1 -0
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +31 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/lib/scaffold.d.ts +37 -0
- package/dist/lib/scaffold.d.ts.map +1 -1
- package/dist/lib/scaffold.js +114 -0
- package/dist/lib/scaffold.js.map +1 -1
- package/dist/terminal/index.d.ts +8 -0
- package/dist/terminal/index.d.ts.map +1 -0
- package/dist/terminal/index.js +5 -0
- package/dist/terminal/index.js.map +1 -0
- package/dist/terminal/pty-manager.d.ts +69 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -0
- package/dist/terminal/pty-manager.js +377 -0
- package/dist/terminal/pty-manager.js.map +1 -0
- package/dist/terminal/pty-session.d.ts +104 -0
- package/dist/terminal/pty-session.d.ts.map +1 -0
- package/dist/terminal/pty-session.js +327 -0
- package/dist/terminal/pty-session.js.map +1 -0
- package/dist/terminal/ring-buffer.d.ts +34 -0
- package/dist/terminal/ring-buffer.d.ts.map +1 -0
- package/dist/terminal/ring-buffer.js +94 -0
- package/dist/terminal/ring-buffer.js.map +1 -0
- package/dist/terminal/session-manager.d.ts +115 -0
- package/dist/terminal/session-manager.d.ts.map +1 -0
- package/dist/terminal/session-manager.js +582 -0
- package/dist/terminal/session-manager.js.map +1 -0
- package/dist/terminal/shepherd-client.d.ts +66 -0
- package/dist/terminal/shepherd-client.d.ts.map +1 -0
- package/dist/terminal/shepherd-client.js +234 -0
- package/dist/terminal/shepherd-client.js.map +1 -0
- package/dist/terminal/shepherd-main.d.ts +19 -0
- package/dist/terminal/shepherd-main.d.ts.map +1 -0
- package/dist/terminal/shepherd-main.js +153 -0
- package/dist/terminal/shepherd-main.js.map +1 -0
- package/dist/terminal/shepherd-process.d.ts +75 -0
- package/dist/terminal/shepherd-process.d.ts.map +1 -0
- package/dist/terminal/shepherd-process.js +279 -0
- package/dist/terminal/shepherd-process.js.map +1 -0
- package/dist/terminal/shepherd-protocol.d.ts +115 -0
- package/dist/terminal/shepherd-protocol.d.ts.map +1 -0
- package/dist/terminal/shepherd-protocol.js +214 -0
- package/dist/terminal/shepherd-protocol.js.map +1 -0
- package/dist/terminal/shepherd-replay-buffer.d.ts +38 -0
- package/dist/terminal/shepherd-replay-buffer.d.ts.map +1 -0
- package/dist/terminal/shepherd-replay-buffer.js +94 -0
- package/dist/terminal/shepherd-replay-buffer.js.map +1 -0
- package/dist/terminal/ws-protocol.d.ts +27 -0
- package/dist/terminal/ws-protocol.d.ts.map +1 -0
- package/dist/terminal/ws-protocol.js +44 -0
- package/dist/terminal/ws-protocol.js.map +1 -0
- package/package.json +19 -5
- package/skeleton/.claude/skills/af/SKILL.md +89 -0
- package/skeleton/.claude/skills/codev/SKILL.md +41 -0
- package/skeleton/.claude/skills/consult/SKILL.md +81 -0
- package/skeleton/.claude/skills/generate-image/SKILL.md +56 -0
- package/skeleton/DEPENDENCIES.md +4 -62
- package/skeleton/builders.md +1 -1
- package/skeleton/consult-types/impl-review.md +18 -9
- package/skeleton/consult-types/integration-review.md +1 -1
- package/skeleton/consult-types/plan-review.md +1 -1
- package/skeleton/consult-types/pr-ready.md +1 -1
- package/skeleton/consult-types/spec-review.md +1 -1
- package/skeleton/porch/prompts/defend.md +1 -1
- package/skeleton/porch/prompts/evaluate.md +2 -2
- package/skeleton/porch/prompts/implement.md +1 -1
- package/skeleton/porch/prompts/plan.md +1 -1
- package/skeleton/porch/prompts/review.md +4 -4
- package/skeleton/porch/prompts/specify.md +1 -1
- package/skeleton/porch/prompts/understand.md +2 -2
- package/skeleton/protocol-schema.json +282 -0
- package/skeleton/protocols/bugfix/builder-prompt.md +54 -0
- package/skeleton/protocols/bugfix/prompts/fix.md +77 -0
- package/skeleton/protocols/bugfix/prompts/investigate.md +77 -0
- package/skeleton/protocols/bugfix/prompts/pr.md +84 -0
- package/skeleton/protocols/bugfix/protocol.json +20 -33
- package/skeleton/protocols/experiment/builder-prompt.md +52 -0
- package/skeleton/protocols/experiment/protocol.json +101 -0
- package/skeleton/protocols/experiment/protocol.md +3 -3
- package/skeleton/protocols/experiment/templates/notes.md +1 -1
- package/skeleton/protocols/maintain/builder-prompt.md +46 -0
- package/skeleton/protocols/maintain/prompts/audit.md +111 -0
- package/skeleton/protocols/maintain/prompts/clean.md +91 -0
- package/skeleton/protocols/maintain/prompts/sync.md +113 -0
- package/skeleton/protocols/maintain/prompts/verify.md +110 -0
- package/skeleton/protocols/maintain/protocol.json +141 -0
- package/skeleton/protocols/maintain/protocol.md +17 -11
- package/skeleton/protocols/protocol-schema.json +54 -1
- package/skeleton/protocols/spir/builder-prompt.md +59 -0
- package/skeleton/protocols/spir/prompts/implement.md +208 -0
- package/skeleton/protocols/{spider → spir}/prompts/plan.md +6 -70
- package/skeleton/protocols/{spider → spir}/prompts/review.md +20 -39
- package/skeleton/protocols/{spider → spir}/prompts/specify.md +33 -61
- package/skeleton/protocols/spir/protocol.json +156 -0
- package/skeleton/protocols/{spider → spir}/protocol.md +35 -21
- package/skeleton/protocols/{spider → spir}/templates/plan.md +14 -0
- package/skeleton/protocols/spir/templates/review.md +89 -0
- package/skeleton/protocols/tick/builder-prompt.md +56 -0
- package/skeleton/protocols/tick/protocol.json +7 -2
- package/skeleton/protocols/tick/protocol.md +18 -18
- package/skeleton/protocols/tick/templates/review.md +1 -1
- package/skeleton/resources/commands/agent-farm.md +63 -46
- package/skeleton/resources/commands/codev.md +0 -2
- package/skeleton/resources/commands/overview.md +7 -17
- package/skeleton/resources/workflow-reference.md +4 -4
- package/skeleton/roles/architect.md +152 -315
- package/skeleton/roles/builder.md +110 -218
- package/skeleton/roles/consultant.md +6 -6
- package/skeleton/templates/AGENTS.md +2 -2
- package/skeleton/templates/CLAUDE.md +2 -2
- package/skeleton/templates/cheatsheet.md +7 -5
- package/skeleton/templates/projectlist.md +1 -1
- package/templates/dashboard/index.html +17 -43
- package/templates/dashboard/js/dialogs.js +7 -7
- package/templates/dashboard/js/files.js +2 -2
- package/templates/dashboard/js/main.js +4 -4
- package/templates/dashboard/js/projects.js +3 -3
- package/templates/dashboard/js/tabs.js +1 -1
- package/templates/dashboard/js/utils.js +22 -87
- package/templates/open.html +26 -0
- package/templates/tower.html +642 -36
- package/dist/agent-farm/commands/kickoff.d.ts +0 -20
- package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
- package/dist/agent-farm/commands/kickoff.js +0 -337
- package/dist/agent-farm/commands/kickoff.js.map +0 -1
- package/dist/agent-farm/commands/rename.d.ts +0 -13
- package/dist/agent-farm/commands/rename.d.ts.map +0 -1
- package/dist/agent-farm/commands/rename.js +0 -33
- package/dist/agent-farm/commands/rename.js.map +0 -1
- package/dist/agent-farm/commands/tutorial.d.ts +0 -10
- package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
- package/dist/agent-farm/commands/tutorial.js +0 -49
- package/dist/agent-farm/commands/tutorial.js.map +0 -1
- package/dist/agent-farm/commands/util.d.ts +0 -15
- package/dist/agent-farm/commands/util.d.ts.map +0 -1
- package/dist/agent-farm/commands/util.js +0 -108
- package/dist/agent-farm/commands/util.js.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.d.ts +0 -7
- package/dist/agent-farm/servers/dashboard-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/dashboard-server.js +0 -1872
- package/dist/agent-farm/servers/dashboard-server.js.map +0 -1
- package/dist/agent-farm/servers/open-server.d.ts +0 -7
- package/dist/agent-farm/servers/open-server.d.ts.map +0 -1
- package/dist/agent-farm/servers/open-server.js +0 -315
- package/dist/agent-farm/servers/open-server.js.map +0 -1
- package/dist/agent-farm/tutorial/index.d.ts +0 -8
- package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/index.js +0 -8
- package/dist/agent-farm/tutorial/index.js.map +0 -1
- package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
- package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/prompts.js +0 -147
- package/dist/agent-farm/tutorial/prompts.js.map +0 -1
- package/dist/agent-farm/tutorial/runner.d.ts +0 -52
- package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/runner.js +0 -204
- package/dist/agent-farm/tutorial/runner.js.map +0 -1
- package/dist/agent-farm/tutorial/state.d.ts +0 -26
- package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/state.js +0 -89
- package/dist/agent-farm/tutorial/state.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
- package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
- package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
- package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/index.js +0 -10
- package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/planning.js +0 -143
- package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/review.js +0 -78
- package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/setup.js +0 -126
- package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
- package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
- package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
- package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
- package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.d.ts +0 -27
- package/dist/agent-farm/utils/orphan-handler.d.ts.map +0 -1
- package/dist/agent-farm/utils/orphan-handler.js +0 -149
- package/dist/agent-farm/utils/orphan-handler.js.map +0 -1
- package/dist/agent-farm/utils/port-registry.d.ts +0 -58
- package/dist/agent-farm/utils/port-registry.d.ts.map +0 -1
- package/dist/agent-farm/utils/port-registry.js +0 -166
- package/dist/agent-farm/utils/port-registry.js.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.d.ts +0 -18
- package/dist/agent-farm/utils/terminal-ports.d.ts.map +0 -1
- package/dist/agent-farm/utils/terminal-ports.js +0 -35
- package/dist/agent-farm/utils/terminal-ports.js.map +0 -1
- package/dist/commands/pcheck/cache.d.ts +0 -48
- package/dist/commands/pcheck/cache.d.ts.map +0 -1
- package/dist/commands/pcheck/cache.js +0 -170
- package/dist/commands/pcheck/cache.js.map +0 -1
- package/dist/commands/pcheck/evaluator.d.ts +0 -15
- package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
- package/dist/commands/pcheck/evaluator.js +0 -246
- package/dist/commands/pcheck/evaluator.js.map +0 -1
- package/dist/commands/pcheck/index.d.ts +0 -12
- package/dist/commands/pcheck/index.d.ts.map +0 -1
- package/dist/commands/pcheck/index.js +0 -249
- package/dist/commands/pcheck/index.js.map +0 -1
- package/dist/commands/pcheck/parser.d.ts +0 -39
- package/dist/commands/pcheck/parser.d.ts.map +0 -1
- package/dist/commands/pcheck/parser.js +0 -155
- package/dist/commands/pcheck/parser.js.map +0 -1
- package/dist/commands/pcheck/types.d.ts +0 -82
- package/dist/commands/pcheck/types.d.ts.map +0 -1
- package/dist/commands/pcheck/types.js +0 -5
- package/dist/commands/pcheck/types.js.map +0 -1
- package/dist/commands/porch/consultation.d.ts +0 -56
- package/dist/commands/porch/consultation.d.ts.map +0 -1
- package/dist/commands/porch/consultation.js +0 -330
- package/dist/commands/porch/consultation.js.map +0 -1
- package/dist/commands/porch/notifications.d.ts +0 -99
- package/dist/commands/porch/notifications.d.ts.map +0 -1
- package/dist/commands/porch/notifications.js +0 -223
- package/dist/commands/porch/notifications.js.map +0 -1
- package/dist/commands/porch/plan-parser.d.ts +0 -38
- package/dist/commands/porch/plan-parser.d.ts.map +0 -1
- package/dist/commands/porch/plan-parser.js +0 -166
- package/dist/commands/porch/plan-parser.js.map +0 -1
- package/dist/commands/porch/protocol-loader.d.ts +0 -46
- package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
- package/dist/commands/porch/protocol-loader.js +0 -253
- package/dist/commands/porch/protocol-loader.js.map +0 -1
- package/dist/commands/porch/signal-parser.d.ts +0 -88
- package/dist/commands/porch/signal-parser.d.ts.map +0 -1
- package/dist/commands/porch/signal-parser.js +0 -148
- package/dist/commands/porch/signal-parser.js.map +0 -1
- package/dist/commands/tower.d.ts +0 -16
- package/dist/commands/tower.d.ts.map +0 -1
- package/dist/commands/tower.js +0 -21
- package/dist/commands/tower.js.map +0 -1
- package/skeleton/config.json +0 -7
- package/skeleton/porch/protocols/bugfix.json +0 -85
- package/skeleton/porch/protocols/spider.json +0 -135
- package/skeleton/porch/protocols/tick.json +0 -76
- package/skeleton/protocols/spider/prompts/defend.md +0 -215
- package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
- package/skeleton/protocols/spider/prompts/implement.md +0 -149
- package/skeleton/protocols/spider/protocol.json +0 -210
- package/skeleton/protocols/spider/templates/review.md +0 -207
- package/templates/dashboard/css/activity.css +0 -151
- package/templates/dashboard/js/activity.js +0 -112
- /package/skeleton/protocols/{spider → spir}/templates/spec.md +0 -0
|
@@ -6,18 +6,707 @@
|
|
|
6
6
|
import http from 'node:http';
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
import { homedir } from 'node:os';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import { execSync } from 'node:child_process';
|
|
11
|
+
import { homedir, tmpdir } from 'node:os';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { Command } from 'commander';
|
|
14
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
14
15
|
import { getGlobalDb } from '../db/index.js';
|
|
15
|
-
import { cleanupStaleEntries } from '../utils/port-registry.js';
|
|
16
16
|
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
17
|
+
import { getGateStatusForProject } from '../utils/gate-status.js';
|
|
18
|
+
import { GateWatcher } from '../utils/gate-watcher.js';
|
|
19
|
+
import { saveFileTab as saveFileTabToDb, deleteFileTab as deleteFileTabFromDb, loadFileTabsForProject as loadFileTabsFromDb, } from '../utils/file-tabs.js';
|
|
20
|
+
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
21
|
+
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
22
|
+
import { TunnelClient } from '../lib/tunnel-client.js';
|
|
23
|
+
import { readCloudConfig, getCloudConfigPath, maskApiKey } from '../lib/cloud-config.js';
|
|
24
|
+
import { SessionManager } from '../../terminal/session-manager.js';
|
|
17
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
26
|
const __dirname = path.dirname(__filename);
|
|
19
27
|
// Default port for tower dashboard
|
|
20
28
|
const DEFAULT_PORT = 4100;
|
|
29
|
+
// Rate limiting for activation requests (Spec 0090 Phase 1)
|
|
30
|
+
// Simple in-memory rate limiter: 10 activations per minute per client
|
|
31
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
32
|
+
const RATE_LIMIT_MAX = 10;
|
|
33
|
+
const activationRateLimits = new Map();
|
|
34
|
+
/**
|
|
35
|
+
* Check if a client has exceeded the rate limit for activations
|
|
36
|
+
* Returns true if rate limit exceeded, false if allowed
|
|
37
|
+
*/
|
|
38
|
+
function isRateLimited(clientIp) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const entry = activationRateLimits.get(clientIp);
|
|
41
|
+
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
42
|
+
// New window
|
|
43
|
+
activationRateLimits.set(clientIp, { count: 1, windowStart: now });
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (entry.count >= RATE_LIMIT_MAX) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
entry.count++;
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Clean up old rate limit entries periodically
|
|
54
|
+
*/
|
|
55
|
+
function cleanupRateLimits() {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
for (const [ip, entry] of activationRateLimits.entries()) {
|
|
58
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
|
|
59
|
+
activationRateLimits.delete(ip);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Cleanup stale rate limit entries every 5 minutes
|
|
64
|
+
setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Cloud Tunnel Client (Spec 0097 Phase 4)
|
|
67
|
+
// ============================================================================
|
|
68
|
+
/** Tunnel client instance — created on startup or via POST /api/tunnel/connect */
|
|
69
|
+
let tunnelClient = null;
|
|
70
|
+
/** Config file watcher — watches cloud-config.json for changes */
|
|
71
|
+
let configWatcher = null;
|
|
72
|
+
/** Debounce timer for config file watcher events */
|
|
73
|
+
let configWatchDebounce = null;
|
|
74
|
+
/** Default tunnel port for codevos.ai */
|
|
75
|
+
// TICK-001: tunnelPort is no longer needed — WebSocket connects on the same port
|
|
76
|
+
/** Periodic metadata refresh interval (re-sends metadata to codevos.ai) */
|
|
77
|
+
let metadataRefreshInterval = null;
|
|
78
|
+
/** Metadata refresh period in milliseconds (30 seconds) */
|
|
79
|
+
const METADATA_REFRESH_MS = 30_000;
|
|
80
|
+
/**
|
|
81
|
+
* Gather current tower metadata (projects + terminals) for codevos.ai.
|
|
82
|
+
*/
|
|
83
|
+
async function gatherMetadata() {
|
|
84
|
+
const instances = await getInstances();
|
|
85
|
+
const projects = instances.map((i) => ({
|
|
86
|
+
path: i.projectPath,
|
|
87
|
+
name: i.projectName,
|
|
88
|
+
}));
|
|
89
|
+
// Build reverse mapping: terminal ID → project path
|
|
90
|
+
const terminalToProject = new Map();
|
|
91
|
+
for (const [projectPath, entry] of projectTerminals) {
|
|
92
|
+
if (entry.architect)
|
|
93
|
+
terminalToProject.set(entry.architect, projectPath);
|
|
94
|
+
for (const termId of entry.builders.values())
|
|
95
|
+
terminalToProject.set(termId, projectPath);
|
|
96
|
+
for (const termId of entry.shells.values())
|
|
97
|
+
terminalToProject.set(termId, projectPath);
|
|
98
|
+
}
|
|
99
|
+
const manager = terminalManager;
|
|
100
|
+
const terminals = [];
|
|
101
|
+
if (manager) {
|
|
102
|
+
for (const session of manager.listSessions()) {
|
|
103
|
+
terminals.push({
|
|
104
|
+
id: session.id,
|
|
105
|
+
projectPath: terminalToProject.get(session.id) ?? '',
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { projects, terminals };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Start periodic metadata refresh — re-gathers metadata and pushes to codevos.ai
|
|
113
|
+
* every METADATA_REFRESH_MS while the tunnel is connected.
|
|
114
|
+
*/
|
|
115
|
+
function startMetadataRefresh() {
|
|
116
|
+
stopMetadataRefresh();
|
|
117
|
+
metadataRefreshInterval = setInterval(async () => {
|
|
118
|
+
try {
|
|
119
|
+
if (tunnelClient && tunnelClient.getState() === 'connected') {
|
|
120
|
+
const metadata = await gatherMetadata();
|
|
121
|
+
tunnelClient.sendMetadata(metadata);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
log('WARN', `Metadata refresh failed: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}, METADATA_REFRESH_MS);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Stop the periodic metadata refresh.
|
|
131
|
+
*/
|
|
132
|
+
function stopMetadataRefresh() {
|
|
133
|
+
if (metadataRefreshInterval) {
|
|
134
|
+
clearInterval(metadataRefreshInterval);
|
|
135
|
+
metadataRefreshInterval = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Create or reconnect the tunnel client using the given config.
|
|
140
|
+
* Sets up state change listeners and sends initial metadata.
|
|
141
|
+
*/
|
|
142
|
+
async function connectTunnel(config) {
|
|
143
|
+
// Disconnect existing client if any
|
|
144
|
+
if (tunnelClient) {
|
|
145
|
+
tunnelClient.disconnect();
|
|
146
|
+
}
|
|
147
|
+
const client = new TunnelClient({
|
|
148
|
+
serverUrl: config.server_url,
|
|
149
|
+
apiKey: config.api_key,
|
|
150
|
+
towerId: config.tower_id,
|
|
151
|
+
localPort: port,
|
|
152
|
+
});
|
|
153
|
+
client.onStateChange((state, prev) => {
|
|
154
|
+
log('INFO', `Tunnel: ${prev} → ${state}`);
|
|
155
|
+
if (state === 'connected') {
|
|
156
|
+
startMetadataRefresh();
|
|
157
|
+
}
|
|
158
|
+
else if (prev === 'connected') {
|
|
159
|
+
stopMetadataRefresh();
|
|
160
|
+
}
|
|
161
|
+
if (state === 'auth_failed') {
|
|
162
|
+
log('ERROR', 'Cloud connection failed: API key is invalid or revoked. Run \'af tower register --reauth\' to update credentials.');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// Gather and set initial metadata before connecting
|
|
166
|
+
const metadata = await gatherMetadata();
|
|
167
|
+
client.sendMetadata(metadata);
|
|
168
|
+
tunnelClient = client;
|
|
169
|
+
client.connect();
|
|
170
|
+
// Ensure config watcher is running — the config directory now exists.
|
|
171
|
+
// Handles the case where Tower booted before registration (directory didn't
|
|
172
|
+
// exist, so startConfigWatcher() silently failed at boot time).
|
|
173
|
+
startConfigWatcher();
|
|
174
|
+
return client;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Start watching cloud-config.json for changes.
|
|
178
|
+
* On change: reconnect with new credentials.
|
|
179
|
+
* On delete: disconnect tunnel.
|
|
180
|
+
*/
|
|
181
|
+
function startConfigWatcher() {
|
|
182
|
+
stopConfigWatcher();
|
|
183
|
+
const configPath = getCloudConfigPath();
|
|
184
|
+
const configDir = path.dirname(configPath);
|
|
185
|
+
const configFile = path.basename(configPath);
|
|
186
|
+
// Watch the directory (more reliable than watching the file directly)
|
|
187
|
+
try {
|
|
188
|
+
configWatcher = fs.watch(configDir, (eventType, filename) => {
|
|
189
|
+
if (filename !== configFile)
|
|
190
|
+
return;
|
|
191
|
+
// Debounce: multiple events fire for a single write
|
|
192
|
+
if (configWatchDebounce)
|
|
193
|
+
clearTimeout(configWatchDebounce);
|
|
194
|
+
configWatchDebounce = setTimeout(async () => {
|
|
195
|
+
configWatchDebounce = null;
|
|
196
|
+
try {
|
|
197
|
+
const config = readCloudConfig();
|
|
198
|
+
if (config) {
|
|
199
|
+
log('INFO', `Cloud config changed, reconnecting tunnel (key: ${maskApiKey(config.api_key)})`);
|
|
200
|
+
// Reset circuit breaker in case previous key was invalid
|
|
201
|
+
if (tunnelClient)
|
|
202
|
+
tunnelClient.resetCircuitBreaker();
|
|
203
|
+
await connectTunnel(config);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// Config deleted or invalid
|
|
207
|
+
log('INFO', 'Cloud config removed or invalid, disconnecting tunnel');
|
|
208
|
+
if (tunnelClient) {
|
|
209
|
+
tunnelClient.disconnect();
|
|
210
|
+
tunnelClient = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
log('WARN', `Error handling config change: ${err.message}`);
|
|
216
|
+
}
|
|
217
|
+
}, 500);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Directory doesn't exist yet — that's fine, user hasn't registered
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Stop watching cloud-config.json.
|
|
226
|
+
*/
|
|
227
|
+
function stopConfigWatcher() {
|
|
228
|
+
if (configWatcher) {
|
|
229
|
+
configWatcher.close();
|
|
230
|
+
configWatcher = null;
|
|
231
|
+
}
|
|
232
|
+
if (configWatchDebounce) {
|
|
233
|
+
clearTimeout(configWatchDebounce);
|
|
234
|
+
configWatchDebounce = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// PHASE 2 & 4: Terminal Management (Spec 0090)
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Global TerminalManager instance for tower-managed terminals
|
|
241
|
+
// Uses a temporary directory as projectRoot since terminals can be for any project
|
|
242
|
+
let terminalManager = null;
|
|
243
|
+
const projectTerminals = new Map();
|
|
244
|
+
/**
|
|
245
|
+
* Get or create project terminal registry entry.
|
|
246
|
+
* On first access for a project, hydrates file tabs from SQLite so
|
|
247
|
+
* persisted tabs are available immediately (not just after /api/state).
|
|
248
|
+
*/
|
|
249
|
+
function getProjectTerminalsEntry(projectPath) {
|
|
250
|
+
let entry = projectTerminals.get(projectPath);
|
|
251
|
+
if (!entry) {
|
|
252
|
+
entry = { builders: new Map(), shells: new Map(), fileTabs: loadFileTabsForProject(projectPath) };
|
|
253
|
+
projectTerminals.set(projectPath, entry);
|
|
254
|
+
}
|
|
255
|
+
// Migration: ensure fileTabs exists for older entries
|
|
256
|
+
if (!entry.fileTabs) {
|
|
257
|
+
entry.fileTabs = new Map();
|
|
258
|
+
}
|
|
259
|
+
return entry;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Get language identifier for syntax highlighting
|
|
263
|
+
*/
|
|
264
|
+
function getLanguageForExt(ext) {
|
|
265
|
+
const langMap = {
|
|
266
|
+
js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
|
|
267
|
+
py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
|
|
268
|
+
html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
|
|
269
|
+
rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
|
|
270
|
+
};
|
|
271
|
+
return langMap[ext] || ext || 'plaintext';
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Get MIME type for file
|
|
275
|
+
*/
|
|
276
|
+
function getMimeTypeForFile(filePath) {
|
|
277
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
278
|
+
const mimeTypes = {
|
|
279
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
280
|
+
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
281
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
282
|
+
pdf: 'application/pdf', txt: 'text/plain',
|
|
283
|
+
};
|
|
284
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Generate next shell ID for a project
|
|
288
|
+
*/
|
|
289
|
+
function getNextShellId(projectPath) {
|
|
290
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
291
|
+
let maxId = 0;
|
|
292
|
+
for (const id of entry.shells.keys()) {
|
|
293
|
+
const num = parseInt(id.replace('shell-', ''), 10);
|
|
294
|
+
if (!isNaN(num) && num > maxId)
|
|
295
|
+
maxId = num;
|
|
296
|
+
}
|
|
297
|
+
return `shell-${maxId + 1}`;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Get or create the global TerminalManager instance
|
|
301
|
+
*/
|
|
302
|
+
function getTerminalManager() {
|
|
303
|
+
if (!terminalManager) {
|
|
304
|
+
// Use a neutral projectRoot - terminals specify their own cwd
|
|
305
|
+
const projectRoot = process.env.HOME || '/tmp';
|
|
306
|
+
terminalManager = new TerminalManager({
|
|
307
|
+
projectRoot,
|
|
308
|
+
logDir: path.join(homedir(), '.agent-farm', 'logs'),
|
|
309
|
+
maxSessions: 100,
|
|
310
|
+
ringBufferLines: 10000,
|
|
311
|
+
diskLogEnabled: true,
|
|
312
|
+
diskLogMaxBytes: 50 * 1024 * 1024,
|
|
313
|
+
reconnectTimeoutMs: 300_000,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return terminalManager;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Normalize a project path to its canonical form for consistent SQLite storage.
|
|
320
|
+
* Uses realpath to resolve symlinks and relative paths.
|
|
321
|
+
*/
|
|
322
|
+
function normalizeProjectPath(projectPath) {
|
|
323
|
+
try {
|
|
324
|
+
return fs.realpathSync(projectPath);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// Path doesn't exist yet, normalize without realpath
|
|
328
|
+
return path.resolve(projectPath);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Save a terminal session to SQLite.
|
|
333
|
+
* Guards against race conditions by checking if project is still active.
|
|
334
|
+
*/
|
|
335
|
+
function saveTerminalSession(terminalId, projectPath, type, roleId, pid, shepherdSocket = null, shepherdPid = null, shepherdStartTime = null) {
|
|
336
|
+
try {
|
|
337
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
338
|
+
// Race condition guard: only save if project is still in the active registry
|
|
339
|
+
// This prevents zombie rows when stop races with session creation
|
|
340
|
+
if (!projectTerminals.has(normalizedPath) && !projectTerminals.has(projectPath)) {
|
|
341
|
+
log('INFO', `Skipping session save - project no longer active: ${projectPath}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const db = getGlobalDb();
|
|
345
|
+
db.prepare(`
|
|
346
|
+
INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, shepherd_socket, shepherd_pid, shepherd_start_time)
|
|
347
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
348
|
+
`).run(terminalId, normalizedPath, type, roleId, pid, shepherdSocket, shepherdPid, shepherdStartTime);
|
|
349
|
+
log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
log('WARN', `Failed to save terminal session: ${err.message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Check if a terminal session is persistent (shepherd-backed).
|
|
357
|
+
* A session is persistent if it can survive a Tower restart.
|
|
358
|
+
*/
|
|
359
|
+
function isSessionPersistent(_terminalId, session) {
|
|
360
|
+
return session.shepherdBacked;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Delete a terminal session from SQLite
|
|
364
|
+
*/
|
|
365
|
+
function deleteTerminalSession(terminalId) {
|
|
366
|
+
try {
|
|
367
|
+
const db = getGlobalDb();
|
|
368
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(terminalId);
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
log('WARN', `Failed to delete terminal session: ${err.message}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Delete all terminal sessions for a project from SQLite.
|
|
376
|
+
* Normalizes path to ensure consistent cleanup regardless of how path was provided.
|
|
377
|
+
*/
|
|
378
|
+
function deleteProjectTerminalSessions(projectPath) {
|
|
379
|
+
try {
|
|
380
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
381
|
+
const db = getGlobalDb();
|
|
382
|
+
// Delete both normalized and raw path to handle any inconsistencies
|
|
383
|
+
db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(normalizedPath);
|
|
384
|
+
if (normalizedPath !== projectPath) {
|
|
385
|
+
db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(projectPath);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Save a file tab to SQLite for persistence across Tower restarts.
|
|
394
|
+
* Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
|
|
395
|
+
*/
|
|
396
|
+
function saveFileTab(id, projectPath, filePath, createdAt) {
|
|
397
|
+
try {
|
|
398
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
399
|
+
saveFileTabToDb(getGlobalDb(), id, normalizedPath, filePath, createdAt);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
log('WARN', `Failed to save file tab: ${err.message}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Delete a file tab from SQLite.
|
|
407
|
+
* Thin wrapper around utils/file-tabs.ts with error handling.
|
|
408
|
+
*/
|
|
409
|
+
function deleteFileTab(id) {
|
|
410
|
+
try {
|
|
411
|
+
deleteFileTabFromDb(getGlobalDb(), id);
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
log('WARN', `Failed to delete file tab: ${err.message}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Load file tabs for a project from SQLite.
|
|
419
|
+
* Thin wrapper around utils/file-tabs.ts with error handling and path normalization.
|
|
420
|
+
*/
|
|
421
|
+
function loadFileTabsForProject(projectPath) {
|
|
422
|
+
try {
|
|
423
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
424
|
+
return loadFileTabsFromDb(getGlobalDb(), normalizedPath);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
log('WARN', `Failed to load file tabs: ${err.message}`);
|
|
428
|
+
}
|
|
429
|
+
return new Map();
|
|
430
|
+
}
|
|
431
|
+
// Shepherd session manager (initialized at startup)
|
|
432
|
+
let shepherdManager = null;
|
|
433
|
+
/**
|
|
434
|
+
* Check if a process is running
|
|
435
|
+
*/
|
|
436
|
+
function processExists(pid) {
|
|
437
|
+
try {
|
|
438
|
+
process.kill(pid, 0);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Reconcile terminal sessions on startup.
|
|
447
|
+
*
|
|
448
|
+
* DUAL-SOURCE STRATEGY (shepherd + SQLite):
|
|
449
|
+
*
|
|
450
|
+
* Phase 1 — Shepherd reconnection:
|
|
451
|
+
* For SQLite rows with shepherd_socket IS NOT NULL, attempt to reconnect
|
|
452
|
+
* via SessionManager.reconnectSession(). Shepherd processes survive Tower
|
|
453
|
+
* restarts as detached OS processes.
|
|
454
|
+
*
|
|
455
|
+
* Phase 2 — SQLite sweep:
|
|
456
|
+
* Any rows not matched in Phase 1 are stale → clean up.
|
|
457
|
+
*
|
|
458
|
+
* File tabs are the exception: they have no backing process, so SQLite is
|
|
459
|
+
* the sole source of truth for their persistence (see file_tabs table).
|
|
460
|
+
*/
|
|
461
|
+
async function reconcileTerminalSessions() {
|
|
462
|
+
const manager = getTerminalManager();
|
|
463
|
+
const db = getGlobalDb();
|
|
464
|
+
let shepherdReconnected = 0;
|
|
465
|
+
let orphanReconnected = 0;
|
|
466
|
+
let killed = 0;
|
|
467
|
+
let cleaned = 0;
|
|
468
|
+
// Track matched session IDs across all phases
|
|
469
|
+
const matchedSessionIds = new Set();
|
|
470
|
+
// ---- Phase 1: Shepherd reconnection ----
|
|
471
|
+
let allDbSessions;
|
|
472
|
+
try {
|
|
473
|
+
allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
log('WARN', `Failed to read terminal sessions: ${err.message}`);
|
|
477
|
+
allDbSessions = [];
|
|
478
|
+
}
|
|
479
|
+
const shepherdSessions = allDbSessions.filter(s => s.shepherd_socket !== null);
|
|
480
|
+
if (shepherdSessions.length > 0) {
|
|
481
|
+
log('INFO', `Found ${shepherdSessions.length} shepherd session(s) in SQLite — reconnecting...`);
|
|
482
|
+
}
|
|
483
|
+
for (const dbSession of shepherdSessions) {
|
|
484
|
+
const projectPath = dbSession.project_path;
|
|
485
|
+
// Skip sessions whose project path doesn't exist or is in temp directory
|
|
486
|
+
if (!fs.existsSync(projectPath)) {
|
|
487
|
+
log('INFO', `Skipping shepherd session ${dbSession.id} — project path no longer exists: ${projectPath}`);
|
|
488
|
+
// Kill orphaned shepherd process before removing row
|
|
489
|
+
if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
|
|
490
|
+
try {
|
|
491
|
+
process.kill(dbSession.shepherd_pid, 'SIGTERM');
|
|
492
|
+
killed++;
|
|
493
|
+
}
|
|
494
|
+
catch { /* not killable */ }
|
|
495
|
+
}
|
|
496
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
497
|
+
cleaned++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const tmpDirs = ['/tmp', '/private/tmp', '/var/folders', '/private/var/folders'];
|
|
501
|
+
if (tmpDirs.some(d => projectPath === d || projectPath.startsWith(d + '/'))) {
|
|
502
|
+
log('INFO', `Skipping shepherd session ${dbSession.id} — project is in temp directory: ${projectPath}`);
|
|
503
|
+
// Kill orphaned shepherd process before removing row
|
|
504
|
+
if (dbSession.shepherd_pid && processExists(dbSession.shepherd_pid)) {
|
|
505
|
+
try {
|
|
506
|
+
process.kill(dbSession.shepherd_pid, 'SIGTERM');
|
|
507
|
+
killed++;
|
|
508
|
+
}
|
|
509
|
+
catch { /* not killable */ }
|
|
510
|
+
}
|
|
511
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
512
|
+
cleaned++;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (!shepherdManager) {
|
|
516
|
+
log('WARN', `Shepherd manager not initialized — cannot reconnect ${dbSession.id}`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
try {
|
|
520
|
+
// For architect sessions, restore auto-restart behavior after reconnection
|
|
521
|
+
let restartOptions;
|
|
522
|
+
if (dbSession.type === 'architect') {
|
|
523
|
+
let architectCmd = 'claude';
|
|
524
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
525
|
+
if (fs.existsSync(configPath)) {
|
|
526
|
+
try {
|
|
527
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
528
|
+
if (config.shell?.architect) {
|
|
529
|
+
architectCmd = config.shell.architect;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
catch { /* use default */ }
|
|
533
|
+
}
|
|
534
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
535
|
+
const cleanEnv = { ...process.env };
|
|
536
|
+
delete cleanEnv['CLAUDECODE'];
|
|
537
|
+
restartOptions = {
|
|
538
|
+
command: cmdParts[0],
|
|
539
|
+
args: cmdParts.slice(1),
|
|
540
|
+
cwd: projectPath,
|
|
541
|
+
env: cleanEnv,
|
|
542
|
+
restartDelay: 2000,
|
|
543
|
+
maxRestarts: 50,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
|
|
547
|
+
if (!client) {
|
|
548
|
+
log('INFO', `Shepherd session ${dbSession.id} is stale (PID/socket dead) — will clean up`);
|
|
549
|
+
continue; // Will be cleaned up in Phase 3
|
|
550
|
+
}
|
|
551
|
+
// Wait for REPLAY frame — the shepherd sends it right after WELCOME,
|
|
552
|
+
// but it may arrive in a separate read from the Unix socket.
|
|
553
|
+
const replayData = await client.waitForReplay();
|
|
554
|
+
const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || 'unknown'}`;
|
|
555
|
+
// Create a PtySession backed by the reconnected shepherd client
|
|
556
|
+
const session = manager.createSessionRaw({ label, cwd: projectPath });
|
|
557
|
+
const ptySession = manager.getSession(session.id);
|
|
558
|
+
if (ptySession) {
|
|
559
|
+
ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
|
|
560
|
+
}
|
|
561
|
+
// Register in projectTerminals Map
|
|
562
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
563
|
+
if (dbSession.type === 'architect') {
|
|
564
|
+
entry.architect = session.id;
|
|
565
|
+
}
|
|
566
|
+
else if (dbSession.type === 'builder') {
|
|
567
|
+
entry.builders.set(dbSession.role_id || dbSession.id, session.id);
|
|
568
|
+
}
|
|
569
|
+
else if (dbSession.type === 'shell') {
|
|
570
|
+
entry.shells.set(dbSession.role_id || dbSession.id, session.id);
|
|
571
|
+
}
|
|
572
|
+
// Update SQLite with new terminal ID
|
|
573
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbSession.id);
|
|
574
|
+
saveTerminalSession(session.id, projectPath, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
|
|
575
|
+
registerKnownProject(projectPath);
|
|
576
|
+
// Clean up on exit
|
|
577
|
+
if (ptySession) {
|
|
578
|
+
ptySession.on('exit', () => {
|
|
579
|
+
const currentEntry = getProjectTerminalsEntry(projectPath);
|
|
580
|
+
if (dbSession.type === 'architect' && currentEntry.architect === session.id) {
|
|
581
|
+
currentEntry.architect = undefined;
|
|
582
|
+
}
|
|
583
|
+
deleteTerminalSession(session.id);
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
matchedSessionIds.add(dbSession.id);
|
|
587
|
+
shepherdReconnected++;
|
|
588
|
+
log('INFO', `Reconnected shepherd session → ${session.id} (${dbSession.type} for ${path.basename(projectPath)})`);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
log('WARN', `Failed to reconnect shepherd session ${dbSession.id}: ${err.message}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// ---- Phase 2: Sweep stale SQLite rows ----
|
|
595
|
+
for (const session of allDbSessions) {
|
|
596
|
+
if (matchedSessionIds.has(session.id))
|
|
597
|
+
continue;
|
|
598
|
+
const existing = manager.getSession(session.id);
|
|
599
|
+
if (existing && existing.status !== 'exited')
|
|
600
|
+
continue;
|
|
601
|
+
// Stale row — kill orphaned process if any, then delete
|
|
602
|
+
if (session.pid && processExists(session.pid)) {
|
|
603
|
+
log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
|
|
604
|
+
try {
|
|
605
|
+
process.kill(session.pid, 'SIGTERM');
|
|
606
|
+
killed++;
|
|
607
|
+
}
|
|
608
|
+
catch { /* process not killable */ }
|
|
609
|
+
}
|
|
610
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
|
|
611
|
+
cleaned++;
|
|
612
|
+
}
|
|
613
|
+
const total = shepherdReconnected + orphanReconnected;
|
|
614
|
+
if (total > 0 || killed > 0 || cleaned > 0) {
|
|
615
|
+
log('INFO', `Reconciliation complete: ${shepherdReconnected} shepherd, ${orphanReconnected} orphan, ${killed} killed, ${cleaned} stale rows cleaned`);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
log('INFO', 'No terminal sessions to reconcile');
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Get terminal sessions from SQLite for a project.
|
|
623
|
+
* Normalizes path for consistent lookup.
|
|
624
|
+
*/
|
|
625
|
+
function getTerminalSessionsForProject(projectPath) {
|
|
626
|
+
try {
|
|
627
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
628
|
+
const db = getGlobalDb();
|
|
629
|
+
return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Handle WebSocket connection to a terminal session
|
|
637
|
+
* Uses hybrid binary protocol (Spec 0085):
|
|
638
|
+
* - 0x00 prefix: Control frame (JSON)
|
|
639
|
+
* - 0x01 prefix: Data frame (raw PTY bytes)
|
|
640
|
+
*/
|
|
641
|
+
function handleTerminalWebSocket(ws, session, req) {
|
|
642
|
+
const resumeSeq = req.headers['x-session-resume'];
|
|
643
|
+
// Create a client adapter for the PTY session
|
|
644
|
+
// Uses binary protocol for data frames
|
|
645
|
+
const client = {
|
|
646
|
+
send: (data) => {
|
|
647
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
648
|
+
// Encode as binary data frame (0x01 prefix)
|
|
649
|
+
ws.send(encodeData(data));
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
// Attach client to session and get replay data
|
|
654
|
+
let replayLines;
|
|
655
|
+
if (resumeSeq && typeof resumeSeq === 'string') {
|
|
656
|
+
replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
replayLines = session.attach(client);
|
|
660
|
+
}
|
|
661
|
+
// Send replay data as binary data frame
|
|
662
|
+
if (replayLines.length > 0) {
|
|
663
|
+
const replayData = replayLines.join('\n');
|
|
664
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
665
|
+
ws.send(encodeData(replayData));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Handle incoming messages from client (binary protocol)
|
|
669
|
+
ws.on('message', (rawData) => {
|
|
670
|
+
try {
|
|
671
|
+
const frame = decodeFrame(Buffer.from(rawData));
|
|
672
|
+
if (frame.type === 'data') {
|
|
673
|
+
// Write raw input to terminal
|
|
674
|
+
session.write(frame.data.toString('utf-8'));
|
|
675
|
+
}
|
|
676
|
+
else if (frame.type === 'control') {
|
|
677
|
+
// Handle control messages
|
|
678
|
+
const msg = frame.message;
|
|
679
|
+
if (msg.type === 'resize') {
|
|
680
|
+
const cols = msg.payload.cols;
|
|
681
|
+
const rows = msg.payload.rows;
|
|
682
|
+
if (typeof cols === 'number' && typeof rows === 'number') {
|
|
683
|
+
session.resize(cols, rows);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else if (msg.type === 'ping') {
|
|
687
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
688
|
+
ws.send(encodeControl({ type: 'pong', payload: {} }));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// If decode fails, try treating as raw UTF-8 input (for simpler clients)
|
|
695
|
+
try {
|
|
696
|
+
session.write(rawData.toString('utf-8'));
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// Ignore malformed input
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
ws.on('close', () => {
|
|
704
|
+
session.detach(client);
|
|
705
|
+
});
|
|
706
|
+
ws.on('error', () => {
|
|
707
|
+
session.detach(client);
|
|
708
|
+
});
|
|
709
|
+
}
|
|
21
710
|
// Parse arguments with Commander
|
|
22
711
|
const program = new Command()
|
|
23
712
|
.name('tower-server')
|
|
@@ -52,44 +741,115 @@ function log(level, message) {
|
|
|
52
741
|
}
|
|
53
742
|
}
|
|
54
743
|
}
|
|
744
|
+
// Global exception handlers to catch uncaught errors
|
|
745
|
+
process.on('uncaughtException', (err) => {
|
|
746
|
+
log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
|
|
747
|
+
process.exit(1);
|
|
748
|
+
});
|
|
749
|
+
process.on('unhandledRejection', (reason) => {
|
|
750
|
+
const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
|
751
|
+
log('ERROR', `Unhandled rejection: ${message}`);
|
|
752
|
+
process.exit(1);
|
|
753
|
+
});
|
|
754
|
+
// Graceful shutdown handler (Phase 2 - Spec 0090)
|
|
755
|
+
async function gracefulShutdown(signal) {
|
|
756
|
+
log('INFO', `Received ${signal}, starting graceful shutdown...`);
|
|
757
|
+
// 1. Stop accepting new connections
|
|
758
|
+
server?.close();
|
|
759
|
+
// 2. Close all WebSocket connections
|
|
760
|
+
if (terminalWss) {
|
|
761
|
+
for (const client of terminalWss.clients) {
|
|
762
|
+
client.close(1001, 'Server shutting down');
|
|
763
|
+
}
|
|
764
|
+
terminalWss.close();
|
|
765
|
+
}
|
|
766
|
+
// 3. Kill all PTY sessions
|
|
767
|
+
if (terminalManager) {
|
|
768
|
+
log('INFO', 'Shutting down terminal manager...');
|
|
769
|
+
terminalManager.shutdown();
|
|
770
|
+
}
|
|
771
|
+
// 3b. Shepherd clients: do NOT call shepherdManager.shutdown() here.
|
|
772
|
+
// SessionManager.shutdown() disconnects sockets, which triggers ShepherdClient
|
|
773
|
+
// 'close' events → PtySession exit(-1) → SQLite row deletion. This would erase
|
|
774
|
+
// the rows that reconcileTerminalSessions() needs on restart.
|
|
775
|
+
// Instead, let the process exit naturally — OS closes all sockets, and shepherds
|
|
776
|
+
// detect the disconnection and keep running. SQLite rows are preserved.
|
|
777
|
+
if (shepherdManager) {
|
|
778
|
+
log('INFO', 'Shepherd sessions will continue running (sockets close on process exit)');
|
|
779
|
+
}
|
|
780
|
+
// 4. Stop gate watcher
|
|
781
|
+
if (gateWatcherInterval) {
|
|
782
|
+
clearInterval(gateWatcherInterval);
|
|
783
|
+
gateWatcherInterval = null;
|
|
784
|
+
}
|
|
785
|
+
// 5. Disconnect tunnel (Spec 0097 Phase 4)
|
|
786
|
+
stopMetadataRefresh();
|
|
787
|
+
stopConfigWatcher();
|
|
788
|
+
if (tunnelClient) {
|
|
789
|
+
log('INFO', 'Disconnecting tunnel...');
|
|
790
|
+
tunnelClient.disconnect();
|
|
791
|
+
tunnelClient = null;
|
|
792
|
+
}
|
|
793
|
+
log('INFO', 'Graceful shutdown complete');
|
|
794
|
+
process.exit(0);
|
|
795
|
+
}
|
|
796
|
+
// Catch signals for clean shutdown
|
|
797
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
798
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
55
799
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
56
800
|
log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
57
801
|
process.exit(1);
|
|
58
802
|
}
|
|
59
803
|
log('INFO', `Tower server starting on port ${port}`);
|
|
60
804
|
/**
|
|
61
|
-
*
|
|
805
|
+
* Register a project in the known_projects table so it persists across restarts
|
|
806
|
+
* even when all terminal sessions are gone.
|
|
62
807
|
*/
|
|
63
|
-
function
|
|
808
|
+
function registerKnownProject(projectPath) {
|
|
64
809
|
try {
|
|
65
810
|
const db = getGlobalDb();
|
|
66
|
-
|
|
811
|
+
db.prepare(`
|
|
812
|
+
INSERT INTO known_projects (project_path, name, last_launched_at)
|
|
813
|
+
VALUES (?, ?, datetime('now'))
|
|
814
|
+
ON CONFLICT(project_path) DO UPDATE SET last_launched_at = datetime('now')
|
|
815
|
+
`).run(projectPath, path.basename(projectPath));
|
|
67
816
|
}
|
|
68
|
-
catch
|
|
69
|
-
|
|
70
|
-
return [];
|
|
817
|
+
catch {
|
|
818
|
+
// Table may not exist yet (pre-migration)
|
|
71
819
|
}
|
|
72
820
|
}
|
|
73
821
|
/**
|
|
74
|
-
*
|
|
822
|
+
* Get all known project paths from known_projects, terminal_sessions, and in-memory cache
|
|
75
823
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
824
|
+
function getKnownProjectPaths() {
|
|
825
|
+
const projectPaths = new Set();
|
|
826
|
+
// From known_projects table (persists even after all terminals are killed)
|
|
827
|
+
try {
|
|
828
|
+
const db = getGlobalDb();
|
|
829
|
+
const projects = db.prepare('SELECT project_path FROM known_projects').all();
|
|
830
|
+
for (const p of projects) {
|
|
831
|
+
projectPaths.add(p.project_path);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
// Table may not exist yet
|
|
836
|
+
}
|
|
837
|
+
// From terminal_sessions table (catches any missed by known_projects)
|
|
838
|
+
try {
|
|
839
|
+
const db = getGlobalDb();
|
|
840
|
+
const sessions = db.prepare('SELECT DISTINCT project_path FROM terminal_sessions').all();
|
|
841
|
+
for (const s of sessions) {
|
|
842
|
+
projectPaths.add(s.project_path);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
// Table may not exist yet
|
|
847
|
+
}
|
|
848
|
+
// From in-memory cache (includes projects activated this session)
|
|
849
|
+
for (const [projectPath] of projectTerminals) {
|
|
850
|
+
projectPaths.add(projectPath);
|
|
851
|
+
}
|
|
852
|
+
return Array.from(projectPaths);
|
|
93
853
|
}
|
|
94
854
|
/**
|
|
95
855
|
* Get project name from path
|
|
@@ -97,58 +857,279 @@ async function isPortListening(port) {
|
|
|
97
857
|
function getProjectName(projectPath) {
|
|
98
858
|
return path.basename(projectPath);
|
|
99
859
|
}
|
|
860
|
+
// Spec 0100: Gate watcher for af send notifications
|
|
861
|
+
const gateWatcher = new GateWatcher(log);
|
|
862
|
+
let gateWatcherInterval = null;
|
|
863
|
+
function startGateWatcher() {
|
|
864
|
+
gateWatcherInterval = setInterval(async () => {
|
|
865
|
+
const projectPaths = getKnownProjectPaths();
|
|
866
|
+
for (const projectPath of projectPaths) {
|
|
867
|
+
try {
|
|
868
|
+
const gateStatus = getGateStatusForProject(projectPath);
|
|
869
|
+
await gateWatcher.checkAndNotify(gateStatus, projectPath);
|
|
870
|
+
}
|
|
871
|
+
catch (err) {
|
|
872
|
+
log('WARN', `Gate watcher error for ${projectPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}, 10_000);
|
|
876
|
+
}
|
|
877
|
+
const sseClients = [];
|
|
878
|
+
let notificationIdCounter = 0;
|
|
879
|
+
/**
|
|
880
|
+
* Broadcast a notification to all connected SSE clients
|
|
881
|
+
*/
|
|
882
|
+
function broadcastNotification(notification) {
|
|
883
|
+
const id = ++notificationIdCounter;
|
|
884
|
+
const data = JSON.stringify({ ...notification, id });
|
|
885
|
+
const message = `id: ${id}\ndata: ${data}\n\n`;
|
|
886
|
+
for (const client of sseClients) {
|
|
887
|
+
try {
|
|
888
|
+
client.res.write(message);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
// Client disconnected, will be cleaned up on next iteration
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Get terminal list for a project from tower's registry.
|
|
897
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
898
|
+
* Returns architect, builders, and shells with their URLs.
|
|
899
|
+
*/
|
|
900
|
+
async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
901
|
+
const manager = getTerminalManager();
|
|
902
|
+
const terminals = [];
|
|
903
|
+
// Query SQLite first, then augment with shepherd reconnection
|
|
904
|
+
const dbSessions = getTerminalSessionsForProject(projectPath);
|
|
905
|
+
// Use normalized path for cache consistency
|
|
906
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
907
|
+
// Build a fresh entry from SQLite, then replace atomically to avoid
|
|
908
|
+
// destroying in-memory state that was registered via POST /api/terminals.
|
|
909
|
+
// Previous approach cleared the cache then rebuilt, which lost terminals
|
|
910
|
+
// if their SQLite rows were deleted by external interference (e.g., tests).
|
|
911
|
+
const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
|
|
912
|
+
// Load file tabs from SQLite (persisted across restarts)
|
|
913
|
+
const existingEntry = projectTerminals.get(normalizedPath);
|
|
914
|
+
if (existingEntry && existingEntry.fileTabs.size > 0) {
|
|
915
|
+
// Use in-memory state if already populated (avoids redundant DB reads)
|
|
916
|
+
freshEntry.fileTabs = existingEntry.fileTabs;
|
|
917
|
+
}
|
|
918
|
+
else {
|
|
919
|
+
freshEntry.fileTabs = loadFileTabsForProject(projectPath);
|
|
920
|
+
}
|
|
921
|
+
for (const dbSession of dbSessions) {
|
|
922
|
+
// Verify session still exists in TerminalManager (runtime state)
|
|
923
|
+
let session = manager.getSession(dbSession.id);
|
|
924
|
+
if (!session && dbSession.shepherd_socket && shepherdManager) {
|
|
925
|
+
// PTY session gone but shepherd may still be alive — reconnect on-the-fly
|
|
926
|
+
try {
|
|
927
|
+
// Restore auto-restart for architect sessions (same as startup reconciliation)
|
|
928
|
+
let restartOptions;
|
|
929
|
+
if (dbSession.type === 'architect') {
|
|
930
|
+
let architectCmd = 'claude';
|
|
931
|
+
const configPath = path.join(dbSession.project_path, 'af-config.json');
|
|
932
|
+
if (fs.existsSync(configPath)) {
|
|
933
|
+
try {
|
|
934
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
935
|
+
if (config.shell?.architect) {
|
|
936
|
+
architectCmd = config.shell.architect;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
catch { /* use default */ }
|
|
940
|
+
}
|
|
941
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
942
|
+
const cleanEnv = { ...process.env };
|
|
943
|
+
delete cleanEnv['CLAUDECODE'];
|
|
944
|
+
restartOptions = {
|
|
945
|
+
command: cmdParts[0],
|
|
946
|
+
args: cmdParts.slice(1),
|
|
947
|
+
cwd: dbSession.project_path,
|
|
948
|
+
env: cleanEnv,
|
|
949
|
+
restartDelay: 2000,
|
|
950
|
+
maxRestarts: 50,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const client = await shepherdManager.reconnectSession(dbSession.id, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time, restartOptions);
|
|
954
|
+
if (client) {
|
|
955
|
+
// Wait for REPLAY frame — same race as startup reconciliation path
|
|
956
|
+
const replayData = await client.waitForReplay();
|
|
957
|
+
const label = dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`;
|
|
958
|
+
const newSession = manager.createSessionRaw({ label, cwd: dbSession.project_path });
|
|
959
|
+
const ptySession = manager.getSession(newSession.id);
|
|
960
|
+
if (ptySession) {
|
|
961
|
+
ptySession.attachShepherd(client, replayData, dbSession.shepherd_pid, dbSession.id);
|
|
962
|
+
// Clean up on exit (same as startup reconciliation path)
|
|
963
|
+
ptySession.on('exit', () => {
|
|
964
|
+
const currentEntry = getProjectTerminalsEntry(dbSession.project_path);
|
|
965
|
+
if (dbSession.type === 'architect' && currentEntry.architect === newSession.id) {
|
|
966
|
+
currentEntry.architect = undefined;
|
|
967
|
+
}
|
|
968
|
+
deleteTerminalSession(newSession.id);
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
deleteTerminalSession(dbSession.id);
|
|
972
|
+
saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, dbSession.shepherd_pid, dbSession.shepherd_socket, dbSession.shepherd_pid, dbSession.shepherd_start_time);
|
|
973
|
+
dbSession.id = newSession.id;
|
|
974
|
+
session = manager.getSession(newSession.id);
|
|
975
|
+
log('INFO', `Reconnected to shepherd on-the-fly → ${newSession.id}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
log('WARN', `Failed shepherd on-the-fly reconnect for ${dbSession.id}: ${err.message}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
if (!session) {
|
|
983
|
+
// Stale row, nothing to reconnect — clean up
|
|
984
|
+
deleteTerminalSession(dbSession.id);
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
if (dbSession.type === 'architect') {
|
|
988
|
+
freshEntry.architect = dbSession.id;
|
|
989
|
+
terminals.push({
|
|
990
|
+
type: 'architect',
|
|
991
|
+
id: 'architect',
|
|
992
|
+
label: 'Architect',
|
|
993
|
+
url: `${proxyUrl}?tab=architect`,
|
|
994
|
+
active: true,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
else if (dbSession.type === 'builder') {
|
|
998
|
+
const builderId = dbSession.role_id || dbSession.id;
|
|
999
|
+
freshEntry.builders.set(builderId, dbSession.id);
|
|
1000
|
+
terminals.push({
|
|
1001
|
+
type: 'builder',
|
|
1002
|
+
id: builderId,
|
|
1003
|
+
label: `Builder ${builderId}`,
|
|
1004
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
1005
|
+
active: true,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
else if (dbSession.type === 'shell') {
|
|
1009
|
+
const shellId = dbSession.role_id || dbSession.id;
|
|
1010
|
+
freshEntry.shells.set(shellId, dbSession.id);
|
|
1011
|
+
terminals.push({
|
|
1012
|
+
type: 'shell',
|
|
1013
|
+
id: shellId,
|
|
1014
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1015
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
1016
|
+
active: true,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
// Also merge in-memory entries that may not be in SQLite yet
|
|
1021
|
+
// (e.g., registered via POST /api/terminals but SQLite row was lost)
|
|
1022
|
+
if (existingEntry) {
|
|
1023
|
+
if (existingEntry.architect && !freshEntry.architect) {
|
|
1024
|
+
const session = manager.getSession(existingEntry.architect);
|
|
1025
|
+
if (session && session.status === 'running') {
|
|
1026
|
+
freshEntry.architect = existingEntry.architect;
|
|
1027
|
+
terminals.push({
|
|
1028
|
+
type: 'architect',
|
|
1029
|
+
id: 'architect',
|
|
1030
|
+
label: 'Architect',
|
|
1031
|
+
url: `${proxyUrl}?tab=architect`,
|
|
1032
|
+
active: true,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
for (const [builderId, terminalId] of existingEntry.builders) {
|
|
1037
|
+
if (!freshEntry.builders.has(builderId)) {
|
|
1038
|
+
const session = manager.getSession(terminalId);
|
|
1039
|
+
if (session && session.status === 'running') {
|
|
1040
|
+
freshEntry.builders.set(builderId, terminalId);
|
|
1041
|
+
terminals.push({
|
|
1042
|
+
type: 'builder',
|
|
1043
|
+
id: builderId,
|
|
1044
|
+
label: `Builder ${builderId}`,
|
|
1045
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
1046
|
+
active: true,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
for (const [shellId, terminalId] of existingEntry.shells) {
|
|
1052
|
+
if (!freshEntry.shells.has(shellId)) {
|
|
1053
|
+
const session = manager.getSession(terminalId);
|
|
1054
|
+
if (session && session.status === 'running') {
|
|
1055
|
+
freshEntry.shells.set(shellId, terminalId);
|
|
1056
|
+
terminals.push({
|
|
1057
|
+
type: 'shell',
|
|
1058
|
+
id: shellId,
|
|
1059
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1060
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
1061
|
+
active: true,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// Atomically replace the cache entry
|
|
1068
|
+
projectTerminals.set(normalizedPath, freshEntry);
|
|
1069
|
+
// Read gate status from porch YAML files
|
|
1070
|
+
const gateStatus = getGateStatusForProject(projectPath);
|
|
1071
|
+
return { terminals, gateStatus };
|
|
1072
|
+
}
|
|
1073
|
+
// Resolve once at module load: both symlinked and real temp dir paths
|
|
1074
|
+
const _tmpDir = tmpdir();
|
|
1075
|
+
const _tmpDirResolved = (() => {
|
|
1076
|
+
try {
|
|
1077
|
+
return fs.realpathSync(_tmpDir);
|
|
1078
|
+
}
|
|
1079
|
+
catch {
|
|
1080
|
+
return _tmpDir;
|
|
1081
|
+
}
|
|
1082
|
+
})();
|
|
1083
|
+
function isTempDirectory(projectPath) {
|
|
1084
|
+
return (projectPath.startsWith(_tmpDir + '/') ||
|
|
1085
|
+
projectPath.startsWith(_tmpDirResolved + '/') ||
|
|
1086
|
+
projectPath.startsWith('/tmp/') ||
|
|
1087
|
+
projectPath.startsWith('/private/tmp/'));
|
|
1088
|
+
}
|
|
100
1089
|
/**
|
|
101
1090
|
* Get all instances with their status
|
|
102
1091
|
*/
|
|
103
1092
|
async function getInstances() {
|
|
104
|
-
const
|
|
1093
|
+
const knownPaths = getKnownProjectPaths();
|
|
105
1094
|
const instances = [];
|
|
106
|
-
for (const
|
|
1095
|
+
for (const projectPath of knownPaths) {
|
|
107
1096
|
// Skip builder worktrees - they're managed by their parent project
|
|
108
|
-
if (
|
|
1097
|
+
if (projectPath.includes('/.builders/')) {
|
|
109
1098
|
continue;
|
|
110
1099
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
url: `http://localhost:${architectPort}`,
|
|
129
|
-
active: architectActive,
|
|
130
|
-
},
|
|
131
|
-
];
|
|
1100
|
+
// Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
|
|
1101
|
+
if (!projectPath.startsWith('remote:')) {
|
|
1102
|
+
if (!fs.existsSync(projectPath)) {
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (isTempDirectory(projectPath)) {
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// Encode project path for proxy URL
|
|
1110
|
+
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
1111
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
1112
|
+
// Get terminals and gate status from tower's registry
|
|
1113
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
|
|
1114
|
+
const { terminals, gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
|
|
1115
|
+
// Project is active if it has any terminals (Phase 4: no port check needed)
|
|
1116
|
+
const isActive = terminals.length > 0;
|
|
132
1117
|
instances.push({
|
|
133
|
-
projectPath
|
|
134
|
-
projectName: getProjectName(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
running: dashboardActive,
|
|
141
|
-
ports,
|
|
1118
|
+
projectPath,
|
|
1119
|
+
projectName: getProjectName(projectPath),
|
|
1120
|
+
running: isActive,
|
|
1121
|
+
proxyUrl,
|
|
1122
|
+
architectUrl: `${proxyUrl}?tab=architect`,
|
|
1123
|
+
terminals,
|
|
1124
|
+
gateStatus,
|
|
142
1125
|
});
|
|
143
1126
|
}
|
|
144
|
-
// Sort: running first, then by
|
|
1127
|
+
// Sort: running first, then by project name
|
|
145
1128
|
instances.sort((a, b) => {
|
|
146
1129
|
if (a.running !== b.running) {
|
|
147
1130
|
return a.running ? -1 : 1;
|
|
148
1131
|
}
|
|
149
|
-
|
|
150
|
-
const bTime = b.lastUsed ? new Date(b.lastUsed).getTime() : 0;
|
|
151
|
-
return bTime - aTime;
|
|
1132
|
+
return a.projectName.localeCompare(b.projectName);
|
|
152
1133
|
});
|
|
153
1134
|
return instances;
|
|
154
1135
|
}
|
|
@@ -164,6 +1145,10 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
164
1145
|
if (inputPath.startsWith('~')) {
|
|
165
1146
|
inputPath = inputPath.replace('~', homedir());
|
|
166
1147
|
}
|
|
1148
|
+
// Relative paths are meaningless for the tower daemon — only absolute paths
|
|
1149
|
+
if (!path.isAbsolute(inputPath)) {
|
|
1150
|
+
return [];
|
|
1151
|
+
}
|
|
167
1152
|
// Determine the directory to list and the prefix to filter by
|
|
168
1153
|
let dirToList;
|
|
169
1154
|
let prefix;
|
|
@@ -213,12 +1198,10 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
213
1198
|
}
|
|
214
1199
|
/**
|
|
215
1200
|
* Launch a new agent-farm instance
|
|
216
|
-
*
|
|
217
|
-
* Auto-adopts non-codev directories
|
|
1201
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
|
|
1202
|
+
* Auto-adopts non-codev directories and creates architect terminal
|
|
218
1203
|
*/
|
|
219
1204
|
async function launchInstance(projectPath) {
|
|
220
|
-
// Clean up stale port allocations before launching (handles machine restarts)
|
|
221
|
-
cleanupStaleEntries();
|
|
222
1205
|
// Validate path exists
|
|
223
1206
|
if (!fs.existsSync(projectPath)) {
|
|
224
1207
|
return { success: false, error: `Path does not exist: ${projectPath}` };
|
|
@@ -246,74 +1229,122 @@ async function launchInstance(projectPath) {
|
|
|
246
1229
|
return { success: false, error: `Failed to adopt codev: ${err.message}` };
|
|
247
1230
|
}
|
|
248
1231
|
}
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// SECURITY: Use spawn with cwd option to avoid command injection
|
|
252
|
-
// Do NOT use bash -c with string concatenation
|
|
1232
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
1233
|
+
// No dashboard-server spawning - tower handles everything
|
|
253
1234
|
try {
|
|
254
|
-
//
|
|
255
|
-
const stopChild = spawn('codev', ['af', 'stop'], {
|
|
256
|
-
cwd: projectPath,
|
|
257
|
-
stdio: 'ignore',
|
|
258
|
-
});
|
|
259
|
-
// Wait for stop to complete
|
|
260
|
-
await new Promise((resolve) => {
|
|
261
|
-
stopChild.on('close', () => resolve());
|
|
262
|
-
stopChild.on('error', () => resolve());
|
|
263
|
-
// Timeout after 3 seconds
|
|
264
|
-
setTimeout(() => resolve(), 3000);
|
|
265
|
-
});
|
|
266
|
-
// Small delay to ensure cleanup
|
|
267
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
268
|
-
// Now start using codev af (avoids npx caching issues)
|
|
269
|
-
// Capture output to detect errors
|
|
270
|
-
const child = spawn('codev', ['af', 'start'], {
|
|
271
|
-
detached: true,
|
|
272
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
273
|
-
cwd: projectPath,
|
|
274
|
-
});
|
|
275
|
-
let stdout = '';
|
|
276
|
-
let stderr = '';
|
|
277
|
-
child.stdout?.on('data', (data) => {
|
|
278
|
-
stdout += data.toString();
|
|
279
|
-
});
|
|
280
|
-
child.stderr?.on('data', (data) => {
|
|
281
|
-
stderr += data.toString();
|
|
282
|
-
});
|
|
283
|
-
// Wait a moment for the process to start (or fail)
|
|
284
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
285
|
-
// Check if the dashboard port is listening
|
|
286
|
-
// Resolve symlinks (macOS /tmp -> /private/tmp)
|
|
1235
|
+
// Ensure project has port allocation
|
|
287
1236
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1237
|
+
// Persist in known_projects so the project survives terminal cleanup
|
|
1238
|
+
registerKnownProject(resolvedPath);
|
|
1239
|
+
// Initialize project terminal entry
|
|
1240
|
+
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
1241
|
+
// Create architect terminal if not already present
|
|
1242
|
+
if (!entry.architect) {
|
|
1243
|
+
const manager = getTerminalManager();
|
|
1244
|
+
// Read af-config.json to get the architect command
|
|
1245
|
+
let architectCmd = 'claude';
|
|
1246
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
1247
|
+
if (fs.existsSync(configPath)) {
|
|
1248
|
+
try {
|
|
1249
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1250
|
+
if (config.shell?.architect) {
|
|
1251
|
+
architectCmd = config.shell.architect;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
catch {
|
|
1255
|
+
// Ignore config read errors, use default
|
|
1256
|
+
}
|
|
303
1257
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
1258
|
+
try {
|
|
1259
|
+
// Parse command string to separate command and args
|
|
1260
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
1261
|
+
const cmd = cmdParts[0];
|
|
1262
|
+
const cmdArgs = cmdParts.slice(1);
|
|
1263
|
+
// Build env with CLAUDECODE removed so spawned Claude processes
|
|
1264
|
+
// don't detect a nested session
|
|
1265
|
+
const cleanEnv = { ...process.env };
|
|
1266
|
+
delete cleanEnv['CLAUDECODE'];
|
|
1267
|
+
// Try shepherd first for persistent session with auto-restart
|
|
1268
|
+
let shepherdCreated = false;
|
|
1269
|
+
if (shepherdManager) {
|
|
1270
|
+
try {
|
|
1271
|
+
const sessionId = crypto.randomUUID();
|
|
1272
|
+
const client = await shepherdManager.createSession({
|
|
1273
|
+
sessionId,
|
|
1274
|
+
command: cmd,
|
|
1275
|
+
args: cmdArgs,
|
|
1276
|
+
cwd: projectPath,
|
|
1277
|
+
env: cleanEnv,
|
|
1278
|
+
cols: 200,
|
|
1279
|
+
rows: 50,
|
|
1280
|
+
restartOnExit: true,
|
|
1281
|
+
restartDelay: 2000,
|
|
1282
|
+
maxRestarts: 50,
|
|
1283
|
+
});
|
|
1284
|
+
// Get replay data and shepherd info
|
|
1285
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
1286
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
1287
|
+
// Create a PtySession backed by the shepherd client
|
|
1288
|
+
const session = manager.createSessionRaw({
|
|
1289
|
+
label: 'Architect',
|
|
1290
|
+
cwd: projectPath,
|
|
1291
|
+
});
|
|
1292
|
+
const ptySession = manager.getSession(session.id);
|
|
1293
|
+
if (ptySession) {
|
|
1294
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
1295
|
+
}
|
|
1296
|
+
entry.architect = session.id;
|
|
1297
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
1298
|
+
// Clean up cache/SQLite when the shepherd session exits
|
|
1299
|
+
if (ptySession) {
|
|
1300
|
+
ptySession.on('exit', () => {
|
|
1301
|
+
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1302
|
+
if (currentEntry.architect === session.id) {
|
|
1303
|
+
currentEntry.architect = undefined;
|
|
1304
|
+
}
|
|
1305
|
+
deleteTerminalSession(session.id);
|
|
1306
|
+
log('INFO', `Architect shepherd session exited for ${projectPath}`);
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
shepherdCreated = true;
|
|
1310
|
+
log('INFO', `Created shepherd-backed architect session for project: ${projectPath}`);
|
|
1311
|
+
}
|
|
1312
|
+
catch (shepherdErr) {
|
|
1313
|
+
log('WARN', `Shepherd creation failed for architect, falling back: ${shepherdErr.message}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
1317
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
1318
|
+
if (!shepherdCreated) {
|
|
1319
|
+
const session = await manager.createSession({
|
|
1320
|
+
command: cmd,
|
|
1321
|
+
args: cmdArgs,
|
|
1322
|
+
cwd: projectPath,
|
|
1323
|
+
label: 'Architect',
|
|
1324
|
+
env: cleanEnv,
|
|
1325
|
+
});
|
|
1326
|
+
entry.architect = session.id;
|
|
1327
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid);
|
|
1328
|
+
const ptySession = manager.getSession(session.id);
|
|
1329
|
+
if (ptySession) {
|
|
1330
|
+
ptySession.on('exit', () => {
|
|
1331
|
+
const currentEntry = getProjectTerminalsEntry(resolvedPath);
|
|
1332
|
+
if (currentEntry.architect === session.id) {
|
|
1333
|
+
currentEntry.architect = undefined;
|
|
1334
|
+
}
|
|
1335
|
+
deleteTerminalSession(session.id);
|
|
1336
|
+
log('INFO', `Architect pty exited for ${projectPath}`);
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
log('WARN', `Architect terminal for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
1340
|
+
}
|
|
1341
|
+
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
log('WARN', `Failed to create architect terminal: ${err.message}`);
|
|
1345
|
+
// Don't fail the launch - project is still active, just without architect
|
|
314
1346
|
}
|
|
315
1347
|
}
|
|
316
|
-
child.unref();
|
|
317
1348
|
return { success: true, adopted };
|
|
318
1349
|
}
|
|
319
1350
|
catch (err) {
|
|
@@ -321,60 +1352,210 @@ async function launchInstance(projectPath) {
|
|
|
321
1352
|
}
|
|
322
1353
|
}
|
|
323
1354
|
/**
|
|
324
|
-
*
|
|
1355
|
+
* Kill a terminal session, including its shepherd auto-restart if applicable.
|
|
1356
|
+
* For shepherd-backed sessions, calls SessionManager.killSession() which clears
|
|
1357
|
+
* the restart timer and removes the session before sending SIGTERM, preventing
|
|
1358
|
+
* the shepherd from auto-restarting the process.
|
|
1359
|
+
*/
|
|
1360
|
+
async function killTerminalWithShepherd(manager, terminalId) {
|
|
1361
|
+
const session = manager.getSession(terminalId);
|
|
1362
|
+
if (!session)
|
|
1363
|
+
return false;
|
|
1364
|
+
// If shepherd-backed, disable auto-restart via SessionManager before killing the PtySession
|
|
1365
|
+
if (session.shepherdBacked && session.shepherdSessionId && shepherdManager) {
|
|
1366
|
+
await shepherdManager.killSession(session.shepherdSessionId);
|
|
1367
|
+
}
|
|
1368
|
+
return manager.killSession(terminalId);
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Stop an agent-farm instance by killing all its terminals
|
|
1372
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
325
1373
|
*/
|
|
326
|
-
function
|
|
1374
|
+
async function stopInstance(projectPath) {
|
|
1375
|
+
const stopped = [];
|
|
1376
|
+
const manager = getTerminalManager();
|
|
1377
|
+
// Resolve symlinks for consistent lookup
|
|
1378
|
+
let resolvedPath = projectPath;
|
|
327
1379
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1380
|
+
if (fs.existsSync(projectPath)) {
|
|
1381
|
+
resolvedPath = fs.realpathSync(projectPath);
|
|
1382
|
+
}
|
|
331
1383
|
}
|
|
332
1384
|
catch {
|
|
333
|
-
|
|
1385
|
+
// Ignore - use original path
|
|
1386
|
+
}
|
|
1387
|
+
// Get project terminals
|
|
1388
|
+
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
1389
|
+
if (entry) {
|
|
1390
|
+
// Kill architect (disable shepherd auto-restart if applicable)
|
|
1391
|
+
if (entry.architect) {
|
|
1392
|
+
const session = manager.getSession(entry.architect);
|
|
1393
|
+
if (session) {
|
|
1394
|
+
await killTerminalWithShepherd(manager, entry.architect);
|
|
1395
|
+
stopped.push(session.pid);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
// Kill all shells (disable shepherd auto-restart if applicable)
|
|
1399
|
+
for (const terminalId of entry.shells.values()) {
|
|
1400
|
+
const session = manager.getSession(terminalId);
|
|
1401
|
+
if (session) {
|
|
1402
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
1403
|
+
stopped.push(session.pid);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
// Kill all builders (disable shepherd auto-restart if applicable)
|
|
1407
|
+
for (const terminalId of entry.builders.values()) {
|
|
1408
|
+
const session = manager.getSession(terminalId);
|
|
1409
|
+
if (session) {
|
|
1410
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
1411
|
+
stopped.push(session.pid);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
// Clear project from registry
|
|
1415
|
+
projectTerminals.delete(resolvedPath);
|
|
1416
|
+
projectTerminals.delete(projectPath);
|
|
1417
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
1418
|
+
deleteProjectTerminalSessions(resolvedPath);
|
|
1419
|
+
if (resolvedPath !== projectPath) {
|
|
1420
|
+
deleteProjectTerminalSessions(projectPath);
|
|
1421
|
+
}
|
|
334
1422
|
}
|
|
1423
|
+
if (stopped.length === 0) {
|
|
1424
|
+
return { success: true, error: 'No terminals found to stop', stopped };
|
|
1425
|
+
}
|
|
1426
|
+
return { success: true, stopped };
|
|
335
1427
|
}
|
|
336
1428
|
/**
|
|
337
|
-
*
|
|
1429
|
+
* Find the tower template
|
|
1430
|
+
* Template is bundled with agent-farm package in templates/ directory
|
|
338
1431
|
*/
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
1432
|
+
function findTemplatePath() {
|
|
1433
|
+
// Templates are at package root: packages/codev/templates/
|
|
1434
|
+
// From compiled: dist/agent-farm/servers/ -> ../../../templates/
|
|
1435
|
+
// From source: src/agent-farm/servers/ -> ../../../templates/
|
|
1436
|
+
const pkgPath = path.resolve(__dirname, '../../../templates/tower.html');
|
|
1437
|
+
if (fs.existsSync(pkgPath)) {
|
|
1438
|
+
return pkgPath;
|
|
1439
|
+
}
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
1443
|
+
// Find template path
|
|
1444
|
+
const templatePath = findTemplatePath();
|
|
1445
|
+
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
1446
|
+
let terminalWss = null;
|
|
1447
|
+
// React dashboard dist path (for serving directly from tower)
|
|
1448
|
+
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
1449
|
+
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
1450
|
+
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
1451
|
+
if (hasReactDashboard) {
|
|
1452
|
+
log('INFO', `React dashboard found at: ${reactDashboardPath}`);
|
|
1453
|
+
}
|
|
1454
|
+
else {
|
|
1455
|
+
log('WARN', 'React dashboard not found - project dashboards will not work');
|
|
1456
|
+
}
|
|
1457
|
+
// MIME types for static file serving
|
|
1458
|
+
const MIME_TYPES = {
|
|
1459
|
+
'.html': 'text/html',
|
|
1460
|
+
'.js': 'application/javascript',
|
|
1461
|
+
'.css': 'text/css',
|
|
1462
|
+
'.json': 'application/json',
|
|
1463
|
+
'.png': 'image/png',
|
|
1464
|
+
'.jpg': 'image/jpeg',
|
|
1465
|
+
'.gif': 'image/gif',
|
|
1466
|
+
'.svg': 'image/svg+xml',
|
|
1467
|
+
'.ico': 'image/x-icon',
|
|
1468
|
+
'.woff': 'font/woff',
|
|
1469
|
+
'.woff2': 'font/woff2',
|
|
1470
|
+
'.ttf': 'font/ttf',
|
|
1471
|
+
'.map': 'application/json',
|
|
1472
|
+
};
|
|
1473
|
+
/**
|
|
1474
|
+
* Serve a static file from the React dashboard dist
|
|
1475
|
+
*/
|
|
1476
|
+
function serveStaticFile(filePath, res) {
|
|
1477
|
+
if (!fs.existsSync(filePath)) {
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
const ext = path.extname(filePath);
|
|
1481
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
1482
|
+
try {
|
|
1483
|
+
const content = fs.readFileSync(filePath);
|
|
1484
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
1485
|
+
res.end(content);
|
|
1486
|
+
return true;
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Handle tunnel management endpoints (Spec 0097 Phase 4).
|
|
1494
|
+
* Extracted so both /api/tunnel/* and /project/<encoded>/api/tunnel/* can use it.
|
|
1495
|
+
*/
|
|
1496
|
+
async function handleTunnelEndpoint(req, res, tunnelSub) {
|
|
1497
|
+
// POST connect
|
|
1498
|
+
if (req.method === 'POST' && tunnelSub === 'connect') {
|
|
1499
|
+
try {
|
|
1500
|
+
const config = readCloudConfig();
|
|
1501
|
+
if (!config) {
|
|
1502
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1503
|
+
res.end(JSON.stringify({ success: false, error: 'Not registered. Run \'af tower register\' first.' }));
|
|
1504
|
+
return;
|
|
353
1505
|
}
|
|
1506
|
+
if (tunnelClient)
|
|
1507
|
+
tunnelClient.resetCircuitBreaker();
|
|
1508
|
+
const client = await connectTunnel(config);
|
|
1509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1510
|
+
res.end(JSON.stringify({ success: true, state: client.getState() }));
|
|
1511
|
+
}
|
|
1512
|
+
catch (err) {
|
|
1513
|
+
log('ERROR', `Tunnel connect failed: ${err.message}`);
|
|
1514
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1515
|
+
res.end(JSON.stringify({ success: false, error: err.message }));
|
|
354
1516
|
}
|
|
1517
|
+
return;
|
|
355
1518
|
}
|
|
356
|
-
|
|
357
|
-
|
|
1519
|
+
// POST disconnect
|
|
1520
|
+
if (req.method === 'POST' && tunnelSub === 'disconnect') {
|
|
1521
|
+
if (tunnelClient) {
|
|
1522
|
+
tunnelClient.disconnect();
|
|
1523
|
+
tunnelClient = null;
|
|
1524
|
+
}
|
|
1525
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1526
|
+
res.end(JSON.stringify({ success: true }));
|
|
1527
|
+
return;
|
|
358
1528
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
1529
|
+
// GET status
|
|
1530
|
+
if (req.method === 'GET' && tunnelSub === 'status') {
|
|
1531
|
+
let config = null;
|
|
1532
|
+
try {
|
|
1533
|
+
config = readCloudConfig();
|
|
1534
|
+
}
|
|
1535
|
+
catch {
|
|
1536
|
+
// Config file may be corrupted — treat as unregistered
|
|
1537
|
+
}
|
|
1538
|
+
const state = tunnelClient?.getState() ?? 'disconnected';
|
|
1539
|
+
const uptime = tunnelClient?.getUptime() ?? null;
|
|
1540
|
+
const response = {
|
|
1541
|
+
registered: config !== null,
|
|
1542
|
+
state,
|
|
1543
|
+
uptime,
|
|
1544
|
+
};
|
|
1545
|
+
if (config) {
|
|
1546
|
+
response.towerId = config.tower_id;
|
|
1547
|
+
response.towerName = config.tower_name;
|
|
1548
|
+
response.serverUrl = config.server_url;
|
|
1549
|
+
response.accessUrl = `${config.server_url}/t/${config.tower_name}/`;
|
|
1550
|
+
}
|
|
1551
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1552
|
+
res.end(JSON.stringify(response));
|
|
1553
|
+
return;
|
|
372
1554
|
}
|
|
373
|
-
|
|
1555
|
+
// Unknown tunnel endpoint
|
|
1556
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1557
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
374
1558
|
}
|
|
375
|
-
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
376
|
-
// Find template path
|
|
377
|
-
const templatePath = findTemplatePath();
|
|
378
1559
|
// Create server
|
|
379
1560
|
const server = http.createServer(async (req, res) => {
|
|
380
1561
|
// Security: Validate Host and Origin headers
|
|
@@ -383,13 +1564,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
383
1564
|
res.end('Forbidden');
|
|
384
1565
|
return;
|
|
385
1566
|
}
|
|
386
|
-
// CORS headers
|
|
1567
|
+
// CORS headers — allow localhost and tunnel proxy origins
|
|
387
1568
|
const origin = req.headers.origin;
|
|
388
|
-
if (origin && (origin.startsWith('http://localhost:') ||
|
|
1569
|
+
if (origin && (origin.startsWith('http://localhost:') ||
|
|
1570
|
+
origin.startsWith('http://127.0.0.1:') ||
|
|
1571
|
+
origin.startsWith('https://'))) {
|
|
389
1572
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
390
1573
|
}
|
|
391
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
392
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1574
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
1575
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
393
1576
|
res.setHeader('Cache-Control', 'no-store');
|
|
394
1577
|
if (req.method === 'OPTIONS') {
|
|
395
1578
|
res.writeHead(200);
|
|
@@ -398,13 +1581,378 @@ const server = http.createServer(async (req, res) => {
|
|
|
398
1581
|
}
|
|
399
1582
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
400
1583
|
try {
|
|
401
|
-
//
|
|
1584
|
+
// =========================================================================
|
|
1585
|
+
// NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
|
|
1586
|
+
// =========================================================================
|
|
1587
|
+
// Health check endpoint (Spec 0090 Phase 1)
|
|
1588
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
1589
|
+
const instances = await getInstances();
|
|
1590
|
+
const activeCount = instances.filter((i) => i.running).length;
|
|
1591
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1592
|
+
res.end(JSON.stringify({
|
|
1593
|
+
status: 'healthy',
|
|
1594
|
+
uptime: process.uptime(),
|
|
1595
|
+
activeProjects: activeCount,
|
|
1596
|
+
totalProjects: instances.length,
|
|
1597
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
1598
|
+
timestamp: new Date().toISOString(),
|
|
1599
|
+
}));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
// =========================================================================
|
|
1603
|
+
// Tunnel Management Endpoints (Spec 0097 Phase 4)
|
|
1604
|
+
// Also reachable from /project/<encoded>/api/tunnel/* (see project router)
|
|
1605
|
+
// =========================================================================
|
|
1606
|
+
if (url.pathname.startsWith('/api/tunnel/')) {
|
|
1607
|
+
const tunnelSub = url.pathname.slice('/api/tunnel/'.length);
|
|
1608
|
+
await handleTunnelEndpoint(req, res, tunnelSub);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
// API: List all projects (Spec 0090 Phase 1)
|
|
1612
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
1613
|
+
const instances = await getInstances();
|
|
1614
|
+
const projects = instances.map((i) => ({
|
|
1615
|
+
path: i.projectPath,
|
|
1616
|
+
name: i.projectName,
|
|
1617
|
+
active: i.running,
|
|
1618
|
+
proxyUrl: i.proxyUrl,
|
|
1619
|
+
terminals: i.terminals.length,
|
|
1620
|
+
}));
|
|
1621
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1622
|
+
res.end(JSON.stringify({ projects }));
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// API: Project-specific endpoints (Spec 0090 Phase 1)
|
|
1626
|
+
// Routes: /api/projects/:encodedPath/activate, /deactivate, /status
|
|
1627
|
+
const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
|
|
1628
|
+
if (projectApiMatch) {
|
|
1629
|
+
const [, encodedPath, action] = projectApiMatch;
|
|
1630
|
+
let projectPath;
|
|
1631
|
+
try {
|
|
1632
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1633
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1634
|
+
throw new Error('Invalid path');
|
|
1635
|
+
}
|
|
1636
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
1637
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
1638
|
+
}
|
|
1639
|
+
catch {
|
|
1640
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1641
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
// GET /api/projects/:path/status
|
|
1645
|
+
if (req.method === 'GET' && action === 'status') {
|
|
1646
|
+
const instances = await getInstances();
|
|
1647
|
+
const instance = instances.find((i) => i.projectPath === projectPath);
|
|
1648
|
+
if (!instance) {
|
|
1649
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1650
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1654
|
+
res.end(JSON.stringify({
|
|
1655
|
+
path: instance.projectPath,
|
|
1656
|
+
name: instance.projectName,
|
|
1657
|
+
active: instance.running,
|
|
1658
|
+
terminals: instance.terminals,
|
|
1659
|
+
gateStatus: instance.gateStatus,
|
|
1660
|
+
}));
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
// POST /api/projects/:path/activate
|
|
1664
|
+
if (req.method === 'POST' && action === 'activate') {
|
|
1665
|
+
// Rate limiting: 10 activations per minute per client
|
|
1666
|
+
const clientIp = req.socket.remoteAddress || '127.0.0.1';
|
|
1667
|
+
if (isRateLimited(clientIp)) {
|
|
1668
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1669
|
+
res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const result = await launchInstance(projectPath);
|
|
1673
|
+
if (result.success) {
|
|
1674
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1675
|
+
res.end(JSON.stringify({ success: true, adopted: result.adopted }));
|
|
1676
|
+
}
|
|
1677
|
+
else {
|
|
1678
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1679
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
1680
|
+
}
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
// POST /api/projects/:path/deactivate
|
|
1684
|
+
if (req.method === 'POST' && action === 'deactivate') {
|
|
1685
|
+
// Check if project is known (has terminals or sessions)
|
|
1686
|
+
const knownPaths = getKnownProjectPaths();
|
|
1687
|
+
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
1688
|
+
const isKnown = knownPaths.some((p) => p === projectPath || p === resolvedPath);
|
|
1689
|
+
if (!isKnown) {
|
|
1690
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1691
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
// Phase 4: Stop terminals directly via tower
|
|
1695
|
+
const result = await stopInstance(projectPath);
|
|
1696
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1697
|
+
res.end(JSON.stringify(result));
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
// =========================================================================
|
|
1702
|
+
// TERMINAL API (Phase 2 - Spec 0090)
|
|
1703
|
+
// =========================================================================
|
|
1704
|
+
// POST /api/terminals - Create a new terminal
|
|
1705
|
+
if (req.method === 'POST' && url.pathname === '/api/terminals') {
|
|
1706
|
+
try {
|
|
1707
|
+
const body = await parseJsonBody(req);
|
|
1708
|
+
const manager = getTerminalManager();
|
|
1709
|
+
// Parse request fields
|
|
1710
|
+
let command = typeof body.command === 'string' ? body.command : undefined;
|
|
1711
|
+
let args = Array.isArray(body.args) ? body.args : undefined;
|
|
1712
|
+
const cols = typeof body.cols === 'number' ? body.cols : undefined;
|
|
1713
|
+
const rows = typeof body.rows === 'number' ? body.rows : undefined;
|
|
1714
|
+
const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
|
|
1715
|
+
const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
|
|
1716
|
+
const label = typeof body.label === 'string' ? body.label : undefined;
|
|
1717
|
+
// Optional session persistence via shepherd
|
|
1718
|
+
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
1719
|
+
const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
|
|
1720
|
+
const roleId = typeof body.roleId === 'string' ? body.roleId : null;
|
|
1721
|
+
const requestPersistence = body.persistent === true;
|
|
1722
|
+
let info;
|
|
1723
|
+
let persistent = false;
|
|
1724
|
+
// Try shepherd if persistence was requested
|
|
1725
|
+
if (requestPersistence && shepherdManager && command && cwd) {
|
|
1726
|
+
try {
|
|
1727
|
+
const sessionId = crypto.randomUUID();
|
|
1728
|
+
// Strip CLAUDECODE so spawned Claude processes don't detect nesting
|
|
1729
|
+
const sessionEnv = { ...(env || process.env) };
|
|
1730
|
+
delete sessionEnv['CLAUDECODE'];
|
|
1731
|
+
const client = await shepherdManager.createSession({
|
|
1732
|
+
sessionId,
|
|
1733
|
+
command,
|
|
1734
|
+
args: args || [],
|
|
1735
|
+
cwd,
|
|
1736
|
+
env: sessionEnv,
|
|
1737
|
+
cols: cols || 200,
|
|
1738
|
+
rows: 50,
|
|
1739
|
+
restartOnExit: false,
|
|
1740
|
+
});
|
|
1741
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
1742
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
1743
|
+
const session = manager.createSessionRaw({
|
|
1744
|
+
label: label || `terminal-${sessionId.slice(0, 8)}`,
|
|
1745
|
+
cwd,
|
|
1746
|
+
});
|
|
1747
|
+
const ptySession = manager.getSession(session.id);
|
|
1748
|
+
if (ptySession) {
|
|
1749
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
1750
|
+
}
|
|
1751
|
+
info = session;
|
|
1752
|
+
persistent = true;
|
|
1753
|
+
if (projectPath && termType && roleId) {
|
|
1754
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1755
|
+
if (termType === 'builder') {
|
|
1756
|
+
entry.builders.set(roleId, session.id);
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
entry.shells.set(roleId, session.id);
|
|
1760
|
+
}
|
|
1761
|
+
saveTerminalSession(session.id, projectPath, termType, roleId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
1762
|
+
log('INFO', `Registered shepherd terminal ${session.id} as ${termType} "${roleId}" for project ${projectPath}`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
catch (shepherdErr) {
|
|
1766
|
+
log('WARN', `Shepherd creation failed for terminal, falling back: ${shepherdErr.message}`);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
1770
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
1771
|
+
if (!info) {
|
|
1772
|
+
info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
|
|
1773
|
+
persistent = false;
|
|
1774
|
+
if (projectPath && termType && roleId) {
|
|
1775
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1776
|
+
if (termType === 'builder') {
|
|
1777
|
+
entry.builders.set(roleId, info.id);
|
|
1778
|
+
}
|
|
1779
|
+
else {
|
|
1780
|
+
entry.shells.set(roleId, info.id);
|
|
1781
|
+
}
|
|
1782
|
+
saveTerminalSession(info.id, projectPath, termType, roleId, info.pid);
|
|
1783
|
+
log('WARN', `Terminal ${info.id} for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1787
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, persistent }));
|
|
1788
|
+
}
|
|
1789
|
+
catch (err) {
|
|
1790
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1791
|
+
log('ERROR', `Failed to create terminal: ${message}`);
|
|
1792
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1793
|
+
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
|
|
1794
|
+
}
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
// GET /api/terminals - List all terminals
|
|
1798
|
+
if (req.method === 'GET' && url.pathname === '/api/terminals') {
|
|
1799
|
+
const manager = getTerminalManager();
|
|
1800
|
+
const terminals = manager.listSessions();
|
|
1801
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1802
|
+
res.end(JSON.stringify({ terminals }));
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
// Terminal-specific routes: /api/terminals/:id/*
|
|
1806
|
+
const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
|
|
1807
|
+
if (terminalRouteMatch) {
|
|
1808
|
+
const [, terminalId, subpath] = terminalRouteMatch;
|
|
1809
|
+
const manager = getTerminalManager();
|
|
1810
|
+
// GET /api/terminals/:id - Get terminal info
|
|
1811
|
+
if (req.method === 'GET' && (!subpath || subpath === '')) {
|
|
1812
|
+
const session = manager.getSession(terminalId);
|
|
1813
|
+
if (!session) {
|
|
1814
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1815
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1819
|
+
res.end(JSON.stringify(session.info));
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
// DELETE /api/terminals/:id - Kill terminal (disable shepherd auto-restart if applicable)
|
|
1823
|
+
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1824
|
+
if (!(await killTerminalWithShepherd(manager, terminalId))) {
|
|
1825
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1826
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
// TICK-001: Delete from SQLite
|
|
1830
|
+
deleteTerminalSession(terminalId);
|
|
1831
|
+
res.writeHead(204);
|
|
1832
|
+
res.end();
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
// POST /api/terminals/:id/write - Write data to terminal (Spec 0104)
|
|
1836
|
+
if (req.method === 'POST' && subpath === '/write') {
|
|
1837
|
+
try {
|
|
1838
|
+
const body = await parseJsonBody(req);
|
|
1839
|
+
if (typeof body.data !== 'string') {
|
|
1840
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1841
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'data must be a string' }));
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const session = manager.getSession(terminalId);
|
|
1845
|
+
if (!session) {
|
|
1846
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1847
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
session.write(body.data);
|
|
1851
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1852
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1853
|
+
}
|
|
1854
|
+
catch {
|
|
1855
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1856
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1857
|
+
}
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
// POST /api/terminals/:id/resize - Resize terminal
|
|
1861
|
+
if (req.method === 'POST' && subpath === '/resize') {
|
|
1862
|
+
try {
|
|
1863
|
+
const body = await parseJsonBody(req);
|
|
1864
|
+
if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
|
|
1865
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1866
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
const info = manager.resizeSession(terminalId, body.cols, body.rows);
|
|
1870
|
+
if (!info) {
|
|
1871
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1872
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1876
|
+
res.end(JSON.stringify(info));
|
|
1877
|
+
}
|
|
1878
|
+
catch {
|
|
1879
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1880
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1881
|
+
}
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
// GET /api/terminals/:id/output - Get terminal output
|
|
1885
|
+
if (req.method === 'GET' && subpath === '/output') {
|
|
1886
|
+
const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
|
|
1887
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
1888
|
+
const output = manager.getOutput(terminalId, lines, offset);
|
|
1889
|
+
if (!output) {
|
|
1890
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1891
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1895
|
+
res.end(JSON.stringify(output));
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
// =========================================================================
|
|
1900
|
+
// EXISTING API ENDPOINTS
|
|
1901
|
+
// =========================================================================
|
|
1902
|
+
// API: Get status of all instances (legacy - kept for backward compat)
|
|
402
1903
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
403
1904
|
const instances = await getInstances();
|
|
404
1905
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
1906
|
res.end(JSON.stringify({ instances }));
|
|
406
1907
|
return;
|
|
407
1908
|
}
|
|
1909
|
+
// API: Server-Sent Events for push notifications
|
|
1910
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1911
|
+
const clientId = crypto.randomBytes(8).toString('hex');
|
|
1912
|
+
res.writeHead(200, {
|
|
1913
|
+
'Content-Type': 'text/event-stream',
|
|
1914
|
+
'Cache-Control': 'no-cache',
|
|
1915
|
+
Connection: 'keep-alive',
|
|
1916
|
+
});
|
|
1917
|
+
// Send initial connection event
|
|
1918
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
|
|
1919
|
+
const client = { res, id: clientId };
|
|
1920
|
+
sseClients.push(client);
|
|
1921
|
+
log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
|
|
1922
|
+
// Clean up on disconnect
|
|
1923
|
+
req.on('close', () => {
|
|
1924
|
+
const index = sseClients.findIndex((c) => c.id === clientId);
|
|
1925
|
+
if (index !== -1) {
|
|
1926
|
+
sseClients.splice(index, 1);
|
|
1927
|
+
}
|
|
1928
|
+
log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
|
|
1929
|
+
});
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
// API: Receive notification from builder
|
|
1933
|
+
if (req.method === 'POST' && url.pathname === '/api/notify') {
|
|
1934
|
+
const body = await parseJsonBody(req);
|
|
1935
|
+
const type = typeof body.type === 'string' ? body.type : 'info';
|
|
1936
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
1937
|
+
const messageBody = typeof body.body === 'string' ? body.body : '';
|
|
1938
|
+
const project = typeof body.project === 'string' ? body.project : undefined;
|
|
1939
|
+
if (!title || !messageBody) {
|
|
1940
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1941
|
+
res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
|
|
1942
|
+
return;
|
|
1943
|
+
}
|
|
1944
|
+
// Broadcast to all connected SSE clients
|
|
1945
|
+
broadcastNotification({
|
|
1946
|
+
type,
|
|
1947
|
+
title,
|
|
1948
|
+
body: messageBody,
|
|
1949
|
+
project,
|
|
1950
|
+
});
|
|
1951
|
+
log('INFO', `Notification broadcast: ${title}`);
|
|
1952
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1953
|
+
res.end(JSON.stringify({ success: true }));
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
408
1956
|
// API: Browse directories for autocomplete
|
|
409
1957
|
if (req.method === 'GET' && url.pathname === '/api/browse') {
|
|
410
1958
|
const inputPath = url.searchParams.get('path') || '';
|
|
@@ -488,12 +2036,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
488
2036
|
// API: Launch new instance
|
|
489
2037
|
if (req.method === 'POST' && url.pathname === '/api/launch') {
|
|
490
2038
|
const body = await parseJsonBody(req);
|
|
491
|
-
|
|
2039
|
+
let projectPath = body.projectPath;
|
|
492
2040
|
if (!projectPath) {
|
|
493
2041
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
494
2042
|
res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
|
|
495
2043
|
return;
|
|
496
2044
|
}
|
|
2045
|
+
// Expand ~ to home directory
|
|
2046
|
+
if (projectPath.startsWith('~')) {
|
|
2047
|
+
projectPath = projectPath.replace('~', homedir());
|
|
2048
|
+
}
|
|
2049
|
+
// Reject relative paths — tower daemon CWD is unpredictable
|
|
2050
|
+
if (!path.isAbsolute(projectPath)) {
|
|
2051
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2052
|
+
res.end(JSON.stringify({
|
|
2053
|
+
success: false,
|
|
2054
|
+
error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
|
|
2055
|
+
}));
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
// Normalize path (resolve .. segments, trailing slashes)
|
|
2059
|
+
projectPath = path.resolve(projectPath);
|
|
497
2060
|
const result = await launchInstance(projectPath);
|
|
498
2061
|
res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
499
2062
|
res.end(JSON.stringify(result));
|
|
@@ -502,13 +2065,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
502
2065
|
// API: Stop an instance
|
|
503
2066
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
504
2067
|
const body = await parseJsonBody(req);
|
|
505
|
-
const
|
|
506
|
-
if (!
|
|
2068
|
+
const targetPath = body.projectPath;
|
|
2069
|
+
if (!targetPath) {
|
|
507
2070
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
508
|
-
res.end(JSON.stringify({ success: false, error: 'Missing
|
|
2071
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
|
|
509
2072
|
return;
|
|
510
2073
|
}
|
|
511
|
-
const result = await stopInstance(
|
|
2074
|
+
const result = await stopInstance(targetPath);
|
|
512
2075
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
513
2076
|
res.end(JSON.stringify(result));
|
|
514
2077
|
return;
|
|
@@ -531,19 +2094,924 @@ const server = http.createServer(async (req, res) => {
|
|
|
531
2094
|
}
|
|
532
2095
|
return;
|
|
533
2096
|
}
|
|
2097
|
+
// Project routes: /project/:base64urlPath/*
|
|
2098
|
+
// Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
|
|
2099
|
+
// Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
|
|
2100
|
+
if (url.pathname.startsWith('/project/')) {
|
|
2101
|
+
const pathParts = url.pathname.split('/');
|
|
2102
|
+
// ['', 'project', base64urlPath, ...rest]
|
|
2103
|
+
const encodedPath = pathParts[2];
|
|
2104
|
+
const subPath = pathParts.slice(3).join('/');
|
|
2105
|
+
if (!encodedPath) {
|
|
2106
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2107
|
+
res.end(JSON.stringify({ error: 'Missing project path' }));
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
// Decode Base64URL (RFC 4648)
|
|
2111
|
+
let projectPath;
|
|
2112
|
+
try {
|
|
2113
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
2114
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
2115
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
2116
|
+
throw new Error('Invalid project path');
|
|
2117
|
+
}
|
|
2118
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
2119
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
2120
|
+
}
|
|
2121
|
+
catch {
|
|
2122
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2123
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
2127
|
+
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
2128
|
+
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
2129
|
+
// Tunnel endpoints are tower-level, not project-scoped, but the React
|
|
2130
|
+
// dashboard uses relative paths (./api/tunnel/...) which resolve to
|
|
2131
|
+
// /project/<encoded>/api/tunnel/... in project context. Handle here by
|
|
2132
|
+
// extracting the tunnel sub-path and dispatching to handleTunnelEndpoint().
|
|
2133
|
+
if (subPath.startsWith('api/tunnel/')) {
|
|
2134
|
+
const tunnelSub = subPath.slice('api/tunnel/'.length); // e.g. "status", "connect", "disconnect"
|
|
2135
|
+
await handleTunnelEndpoint(req, res, tunnelSub);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
// GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
|
|
2139
|
+
if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
|
|
2140
|
+
const relPath = url.searchParams.get('path');
|
|
2141
|
+
const fullPath = path.resolve(projectPath, relPath);
|
|
2142
|
+
// Security: ensure resolved path stays within project directory
|
|
2143
|
+
if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
|
|
2144
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2145
|
+
res.end('Forbidden');
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
try {
|
|
2149
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
2150
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2151
|
+
res.end(content);
|
|
2152
|
+
}
|
|
2153
|
+
catch {
|
|
2154
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2155
|
+
res.end('Not found');
|
|
2156
|
+
}
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
// Serve React dashboard static files directly if:
|
|
2160
|
+
// 1. Not an API call
|
|
2161
|
+
// 2. Not a WebSocket path
|
|
2162
|
+
// 3. React dashboard is available
|
|
2163
|
+
// 4. Project doesn't need to be running for static files
|
|
2164
|
+
if (!isApiCall && !isWsPath && hasReactDashboard) {
|
|
2165
|
+
// Determine which static file to serve
|
|
2166
|
+
let staticPath;
|
|
2167
|
+
if (!subPath || subPath === '' || subPath === 'index.html') {
|
|
2168
|
+
staticPath = path.join(reactDashboardPath, 'index.html');
|
|
2169
|
+
}
|
|
2170
|
+
else {
|
|
2171
|
+
// Check if it's a static asset
|
|
2172
|
+
staticPath = path.join(reactDashboardPath, subPath);
|
|
2173
|
+
}
|
|
2174
|
+
// Try to serve the static file
|
|
2175
|
+
if (serveStaticFile(staticPath, res)) {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
// SPA fallback: serve index.html for client-side routing
|
|
2179
|
+
const indexPath = path.join(reactDashboardPath, 'index.html');
|
|
2180
|
+
if (serveStaticFile(indexPath, res)) {
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
// Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
|
|
2185
|
+
if (isApiCall) {
|
|
2186
|
+
const apiPath = subPath.replace(/^api\/?/, '');
|
|
2187
|
+
// GET /api/state - Return project state (architect, builders, shells)
|
|
2188
|
+
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
2189
|
+
// Refresh cache via getTerminalsForProject (handles SQLite sync
|
|
2190
|
+
// and shepherd reconnection in one place)
|
|
2191
|
+
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
2192
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
2193
|
+
const { gateStatus } = await getTerminalsForProject(projectPath, proxyUrl);
|
|
2194
|
+
// Now read from the refreshed cache
|
|
2195
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2196
|
+
const manager = getTerminalManager();
|
|
2197
|
+
const state = {
|
|
2198
|
+
architect: null,
|
|
2199
|
+
builders: [],
|
|
2200
|
+
utils: [],
|
|
2201
|
+
annotations: [],
|
|
2202
|
+
projectName: path.basename(projectPath),
|
|
2203
|
+
gateStatus,
|
|
2204
|
+
};
|
|
2205
|
+
// Add architect if exists
|
|
2206
|
+
if (entry.architect) {
|
|
2207
|
+
const session = manager.getSession(entry.architect);
|
|
2208
|
+
if (session) {
|
|
2209
|
+
state.architect = {
|
|
2210
|
+
port: 0,
|
|
2211
|
+
pid: session.pid || 0,
|
|
2212
|
+
terminalId: entry.architect,
|
|
2213
|
+
persistent: isSessionPersistent(entry.architect, session),
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
// Add shells from refreshed cache
|
|
2218
|
+
for (const [shellId, terminalId] of entry.shells) {
|
|
2219
|
+
const session = manager.getSession(terminalId);
|
|
2220
|
+
if (session) {
|
|
2221
|
+
state.utils.push({
|
|
2222
|
+
id: shellId,
|
|
2223
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2224
|
+
port: 0,
|
|
2225
|
+
pid: session.pid || 0,
|
|
2226
|
+
terminalId,
|
|
2227
|
+
persistent: isSessionPersistent(terminalId, session),
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
// Add builders from refreshed cache
|
|
2232
|
+
for (const [builderId, terminalId] of entry.builders) {
|
|
2233
|
+
const session = manager.getSession(terminalId);
|
|
2234
|
+
if (session) {
|
|
2235
|
+
state.builders.push({
|
|
2236
|
+
id: builderId,
|
|
2237
|
+
name: `Builder ${builderId}`,
|
|
2238
|
+
port: 0,
|
|
2239
|
+
pid: session.pid || 0,
|
|
2240
|
+
status: 'running',
|
|
2241
|
+
phase: '',
|
|
2242
|
+
worktree: '',
|
|
2243
|
+
branch: '',
|
|
2244
|
+
type: 'spec',
|
|
2245
|
+
terminalId,
|
|
2246
|
+
persistent: isSessionPersistent(terminalId, session),
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
// Add file tabs (Spec 0092 - served through Tower, no separate ports)
|
|
2251
|
+
for (const [tabId, tab] of entry.fileTabs) {
|
|
2252
|
+
state.annotations.push({
|
|
2253
|
+
id: tabId,
|
|
2254
|
+
file: tab.path,
|
|
2255
|
+
port: 0, // No separate port - served through Tower
|
|
2256
|
+
pid: 0, // No separate process
|
|
2257
|
+
});
|
|
2258
|
+
}
|
|
2259
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2260
|
+
res.end(JSON.stringify(state));
|
|
2261
|
+
return;
|
|
2262
|
+
}
|
|
2263
|
+
// POST /api/tabs/shell - Create a new shell terminal
|
|
2264
|
+
if (req.method === 'POST' && apiPath === 'tabs/shell') {
|
|
2265
|
+
try {
|
|
2266
|
+
const manager = getTerminalManager();
|
|
2267
|
+
const shellId = getNextShellId(projectPath);
|
|
2268
|
+
const shellCmd = process.env.SHELL || '/bin/bash';
|
|
2269
|
+
const shellArgs = [];
|
|
2270
|
+
let shellCreated = false;
|
|
2271
|
+
// Try shepherd first for persistent shell session
|
|
2272
|
+
if (shepherdManager) {
|
|
2273
|
+
try {
|
|
2274
|
+
const sessionId = crypto.randomUUID();
|
|
2275
|
+
// Strip CLAUDECODE so spawned Claude processes don't detect nesting
|
|
2276
|
+
const shellEnv = { ...process.env };
|
|
2277
|
+
delete shellEnv['CLAUDECODE'];
|
|
2278
|
+
const client = await shepherdManager.createSession({
|
|
2279
|
+
sessionId,
|
|
2280
|
+
command: shellCmd,
|
|
2281
|
+
args: shellArgs,
|
|
2282
|
+
cwd: projectPath,
|
|
2283
|
+
env: shellEnv,
|
|
2284
|
+
cols: 200,
|
|
2285
|
+
rows: 50,
|
|
2286
|
+
restartOnExit: false,
|
|
2287
|
+
});
|
|
2288
|
+
const replayData = client.getReplayData() ?? Buffer.alloc(0);
|
|
2289
|
+
const shepherdInfo = shepherdManager.getSessionInfo(sessionId);
|
|
2290
|
+
const session = manager.createSessionRaw({
|
|
2291
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
2292
|
+
cwd: projectPath,
|
|
2293
|
+
});
|
|
2294
|
+
const ptySession = manager.getSession(session.id);
|
|
2295
|
+
if (ptySession) {
|
|
2296
|
+
ptySession.attachShepherd(client, replayData, shepherdInfo.pid, sessionId);
|
|
2297
|
+
}
|
|
2298
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2299
|
+
entry.shells.set(shellId, session.id);
|
|
2300
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, shepherdInfo.pid, shepherdInfo.socketPath, shepherdInfo.pid, shepherdInfo.startTime);
|
|
2301
|
+
shellCreated = true;
|
|
2302
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2303
|
+
res.end(JSON.stringify({
|
|
2304
|
+
id: shellId,
|
|
2305
|
+
port: 0,
|
|
2306
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2307
|
+
terminalId: session.id,
|
|
2308
|
+
persistent: true,
|
|
2309
|
+
}));
|
|
2310
|
+
}
|
|
2311
|
+
catch (shepherdErr) {
|
|
2312
|
+
log('WARN', `Shepherd creation failed for shell, falling back: ${shepherdErr.message}`);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
// Fallback: non-persistent session (graceful degradation per plan)
|
|
2316
|
+
// Shepherd is the only persistence backend for new sessions.
|
|
2317
|
+
if (!shellCreated) {
|
|
2318
|
+
const session = await manager.createSession({
|
|
2319
|
+
command: shellCmd,
|
|
2320
|
+
args: shellArgs,
|
|
2321
|
+
cwd: projectPath,
|
|
2322
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
2323
|
+
env: process.env,
|
|
2324
|
+
});
|
|
2325
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2326
|
+
entry.shells.set(shellId, session.id);
|
|
2327
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid);
|
|
2328
|
+
log('WARN', `Shell ${shellId} for ${projectPath} is non-persistent (shepherd unavailable)`);
|
|
2329
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2330
|
+
res.end(JSON.stringify({
|
|
2331
|
+
id: shellId,
|
|
2332
|
+
port: 0,
|
|
2333
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2334
|
+
terminalId: session.id,
|
|
2335
|
+
persistent: false,
|
|
2336
|
+
}));
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
catch (err) {
|
|
2340
|
+
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
2341
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2342
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2343
|
+
}
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
// POST /api/tabs/file - Create a file tab (Spec 0092)
|
|
2347
|
+
if (req.method === 'POST' && apiPath === 'tabs/file') {
|
|
2348
|
+
try {
|
|
2349
|
+
const body = await new Promise((resolve) => {
|
|
2350
|
+
let data = '';
|
|
2351
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2352
|
+
req.on('end', () => resolve(data));
|
|
2353
|
+
});
|
|
2354
|
+
const { path: filePath, line, terminalId } = JSON.parse(body || '{}');
|
|
2355
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
2356
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2357
|
+
res.end(JSON.stringify({ error: 'Missing path parameter' }));
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
// Resolve path: use terminal's cwd for relative paths when terminalId is provided
|
|
2361
|
+
let fullPath;
|
|
2362
|
+
if (path.isAbsolute(filePath)) {
|
|
2363
|
+
fullPath = filePath;
|
|
2364
|
+
}
|
|
2365
|
+
else if (terminalId) {
|
|
2366
|
+
const manager = getTerminalManager();
|
|
2367
|
+
const session = manager.getSession(terminalId);
|
|
2368
|
+
if (session) {
|
|
2369
|
+
fullPath = path.join(session.cwd, filePath);
|
|
2370
|
+
}
|
|
2371
|
+
else {
|
|
2372
|
+
log('WARN', `Terminal session ${terminalId} not found, falling back to project root`);
|
|
2373
|
+
fullPath = path.join(projectPath, filePath);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
else {
|
|
2377
|
+
fullPath = path.join(projectPath, filePath);
|
|
2378
|
+
}
|
|
2379
|
+
// Security: symlink-aware containment check
|
|
2380
|
+
// For non-existent files, resolve the parent directory to handle
|
|
2381
|
+
// intermediate symlinks (e.g., /tmp -> /private/tmp on macOS).
|
|
2382
|
+
let resolvedPath;
|
|
2383
|
+
try {
|
|
2384
|
+
resolvedPath = fs.realpathSync(fullPath);
|
|
2385
|
+
}
|
|
2386
|
+
catch {
|
|
2387
|
+
try {
|
|
2388
|
+
resolvedPath = path.join(fs.realpathSync(path.dirname(fullPath)), path.basename(fullPath));
|
|
2389
|
+
}
|
|
2390
|
+
catch {
|
|
2391
|
+
resolvedPath = path.resolve(fullPath);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
let normalizedProject;
|
|
2395
|
+
try {
|
|
2396
|
+
normalizedProject = fs.realpathSync(projectPath);
|
|
2397
|
+
}
|
|
2398
|
+
catch {
|
|
2399
|
+
normalizedProject = path.resolve(projectPath);
|
|
2400
|
+
}
|
|
2401
|
+
const isWithinProject = resolvedPath.startsWith(normalizedProject + path.sep)
|
|
2402
|
+
|| resolvedPath === normalizedProject;
|
|
2403
|
+
if (!isWithinProject) {
|
|
2404
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2405
|
+
res.end(JSON.stringify({ error: 'Path outside project' }));
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
// Non-existent files still create a tab (spec 0101: file viewer shows "File not found")
|
|
2409
|
+
const fileExists = fs.existsSync(fullPath);
|
|
2410
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2411
|
+
// Check if already open
|
|
2412
|
+
for (const [id, tab] of entry.fileTabs) {
|
|
2413
|
+
if (tab.path === fullPath) {
|
|
2414
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2415
|
+
res.end(JSON.stringify({ id, existing: true, line, notFound: !fileExists }));
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
// Create new file tab (write-through: in-memory + SQLite)
|
|
2420
|
+
const id = `file-${crypto.randomUUID()}`;
|
|
2421
|
+
const createdAt = Date.now();
|
|
2422
|
+
entry.fileTabs.set(id, { id, path: fullPath, createdAt });
|
|
2423
|
+
saveFileTab(id, projectPath, fullPath, createdAt);
|
|
2424
|
+
log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
|
|
2425
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2426
|
+
res.end(JSON.stringify({ id, existing: false, line, notFound: !fileExists }));
|
|
2427
|
+
}
|
|
2428
|
+
catch (err) {
|
|
2429
|
+
log('ERROR', `Failed to create file tab: ${err.message}`);
|
|
2430
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2431
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2432
|
+
}
|
|
2433
|
+
return;
|
|
2434
|
+
}
|
|
2435
|
+
// GET /api/file/:id - Get file content as JSON (Spec 0092)
|
|
2436
|
+
const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
|
|
2437
|
+
if (req.method === 'GET' && fileGetMatch) {
|
|
2438
|
+
const tabId = fileGetMatch[1];
|
|
2439
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2440
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2441
|
+
if (!tab) {
|
|
2442
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2443
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
try {
|
|
2447
|
+
const ext = path.extname(tab.path).slice(1).toLowerCase();
|
|
2448
|
+
const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
|
|
2449
|
+
if (isText) {
|
|
2450
|
+
const content = fs.readFileSync(tab.path, 'utf-8');
|
|
2451
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2452
|
+
res.end(JSON.stringify({
|
|
2453
|
+
path: tab.path,
|
|
2454
|
+
name: path.basename(tab.path),
|
|
2455
|
+
content,
|
|
2456
|
+
language: getLanguageForExt(ext),
|
|
2457
|
+
isMarkdown: ext === 'md',
|
|
2458
|
+
isImage: false,
|
|
2459
|
+
isVideo: false,
|
|
2460
|
+
}));
|
|
2461
|
+
}
|
|
2462
|
+
else {
|
|
2463
|
+
// For binary files, just return metadata
|
|
2464
|
+
const stat = fs.statSync(tab.path);
|
|
2465
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2466
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2467
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2468
|
+
res.end(JSON.stringify({
|
|
2469
|
+
path: tab.path,
|
|
2470
|
+
name: path.basename(tab.path),
|
|
2471
|
+
content: null,
|
|
2472
|
+
language: ext,
|
|
2473
|
+
isMarkdown: false,
|
|
2474
|
+
isImage,
|
|
2475
|
+
isVideo,
|
|
2476
|
+
size: stat.size,
|
|
2477
|
+
}));
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
catch (err) {
|
|
2481
|
+
log('ERROR', `GET /api/file/:id failed: ${err.message}`);
|
|
2482
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2483
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2484
|
+
}
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
// GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
|
|
2488
|
+
const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
|
|
2489
|
+
if (req.method === 'GET' && fileRawMatch) {
|
|
2490
|
+
const tabId = fileRawMatch[1];
|
|
2491
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2492
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2493
|
+
if (!tab) {
|
|
2494
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2495
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
try {
|
|
2499
|
+
const data = fs.readFileSync(tab.path);
|
|
2500
|
+
const mimeType = getMimeTypeForFile(tab.path);
|
|
2501
|
+
res.writeHead(200, {
|
|
2502
|
+
'Content-Type': mimeType,
|
|
2503
|
+
'Content-Length': data.length,
|
|
2504
|
+
'Cache-Control': 'no-cache',
|
|
2505
|
+
});
|
|
2506
|
+
res.end(data);
|
|
2507
|
+
}
|
|
2508
|
+
catch (err) {
|
|
2509
|
+
log('ERROR', `GET /api/file/:id/raw failed: ${err.message}`);
|
|
2510
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2511
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2512
|
+
}
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
// POST /api/file/:id/save - Save file content (Spec 0092)
|
|
2516
|
+
const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
|
|
2517
|
+
if (req.method === 'POST' && fileSaveMatch) {
|
|
2518
|
+
const tabId = fileSaveMatch[1];
|
|
2519
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2520
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2521
|
+
if (!tab) {
|
|
2522
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2523
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
try {
|
|
2527
|
+
const body = await new Promise((resolve) => {
|
|
2528
|
+
let data = '';
|
|
2529
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2530
|
+
req.on('end', () => resolve(data));
|
|
2531
|
+
});
|
|
2532
|
+
const { content } = JSON.parse(body || '{}');
|
|
2533
|
+
if (typeof content !== 'string') {
|
|
2534
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2535
|
+
res.end(JSON.stringify({ error: 'Missing content parameter' }));
|
|
2536
|
+
return;
|
|
2537
|
+
}
|
|
2538
|
+
fs.writeFileSync(tab.path, content, 'utf-8');
|
|
2539
|
+
log('INFO', `Saved file: ${tab.path}`);
|
|
2540
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2541
|
+
res.end(JSON.stringify({ success: true }));
|
|
2542
|
+
}
|
|
2543
|
+
catch (err) {
|
|
2544
|
+
log('ERROR', `POST /api/file/:id/save failed: ${err.message}`);
|
|
2545
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2546
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2547
|
+
}
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
// DELETE /api/tabs/:id - Delete a terminal or file tab
|
|
2551
|
+
const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
|
|
2552
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
2553
|
+
const tabId = deleteMatch[1];
|
|
2554
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2555
|
+
const manager = getTerminalManager();
|
|
2556
|
+
// Check if it's a file tab first (Spec 0092, write-through: in-memory + SQLite)
|
|
2557
|
+
if (tabId.startsWith('file-')) {
|
|
2558
|
+
if (entry.fileTabs.has(tabId)) {
|
|
2559
|
+
entry.fileTabs.delete(tabId);
|
|
2560
|
+
deleteFileTab(tabId);
|
|
2561
|
+
log('INFO', `Deleted file tab: ${tabId}`);
|
|
2562
|
+
res.writeHead(204);
|
|
2563
|
+
res.end();
|
|
2564
|
+
}
|
|
2565
|
+
else {
|
|
2566
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2567
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2568
|
+
}
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
// Find and delete the terminal
|
|
2572
|
+
let terminalId;
|
|
2573
|
+
if (tabId.startsWith('shell-')) {
|
|
2574
|
+
terminalId = entry.shells.get(tabId);
|
|
2575
|
+
if (terminalId) {
|
|
2576
|
+
entry.shells.delete(tabId);
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
else if (tabId.startsWith('builder-')) {
|
|
2580
|
+
terminalId = entry.builders.get(tabId);
|
|
2581
|
+
if (terminalId) {
|
|
2582
|
+
entry.builders.delete(tabId);
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
else if (tabId === 'architect') {
|
|
2586
|
+
terminalId = entry.architect;
|
|
2587
|
+
if (terminalId) {
|
|
2588
|
+
entry.architect = undefined;
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
if (terminalId) {
|
|
2592
|
+
// Disable shepherd auto-restart if applicable, then kill the PtySession
|
|
2593
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2594
|
+
// TICK-001: Delete from SQLite
|
|
2595
|
+
deleteTerminalSession(terminalId);
|
|
2596
|
+
res.writeHead(204);
|
|
2597
|
+
res.end();
|
|
2598
|
+
}
|
|
2599
|
+
else {
|
|
2600
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2601
|
+
res.end(JSON.stringify({ error: 'Tab not found' }));
|
|
2602
|
+
}
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
// POST /api/stop - Stop all terminals for project
|
|
2606
|
+
if (req.method === 'POST' && apiPath === 'stop') {
|
|
2607
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2608
|
+
const manager = getTerminalManager();
|
|
2609
|
+
// Kill all terminals (disable shepherd auto-restart if applicable)
|
|
2610
|
+
if (entry.architect) {
|
|
2611
|
+
await killTerminalWithShepherd(manager, entry.architect);
|
|
2612
|
+
}
|
|
2613
|
+
for (const terminalId of entry.shells.values()) {
|
|
2614
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2615
|
+
}
|
|
2616
|
+
for (const terminalId of entry.builders.values()) {
|
|
2617
|
+
await killTerminalWithShepherd(manager, terminalId);
|
|
2618
|
+
}
|
|
2619
|
+
// Clear registry
|
|
2620
|
+
projectTerminals.delete(projectPath);
|
|
2621
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
2622
|
+
deleteProjectTerminalSessions(projectPath);
|
|
2623
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2624
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2625
|
+
return;
|
|
2626
|
+
}
|
|
2627
|
+
// GET /api/files - Return project directory tree for file browser (Spec 0092)
|
|
2628
|
+
if (req.method === 'GET' && apiPath === 'files') {
|
|
2629
|
+
const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
|
|
2630
|
+
const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
|
|
2631
|
+
function readTree(dir, depth) {
|
|
2632
|
+
if (depth <= 0)
|
|
2633
|
+
return [];
|
|
2634
|
+
try {
|
|
2635
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2636
|
+
return entries
|
|
2637
|
+
.filter(e => !e.name.startsWith('.') || e.name === '.env.example')
|
|
2638
|
+
.filter(e => !ignore.has(e.name))
|
|
2639
|
+
.sort((a, b) => {
|
|
2640
|
+
// Directories first, then alphabetical
|
|
2641
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
2642
|
+
return -1;
|
|
2643
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
2644
|
+
return 1;
|
|
2645
|
+
return a.name.localeCompare(b.name);
|
|
2646
|
+
})
|
|
2647
|
+
.map(e => {
|
|
2648
|
+
const fullPath = path.join(dir, e.name);
|
|
2649
|
+
const relativePath = path.relative(projectPath, fullPath);
|
|
2650
|
+
if (e.isDirectory()) {
|
|
2651
|
+
return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
|
|
2652
|
+
}
|
|
2653
|
+
return { name: e.name, path: relativePath, type: 'file' };
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
catch {
|
|
2657
|
+
return [];
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
const tree = readTree(projectPath, maxDepth);
|
|
2661
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2662
|
+
res.end(JSON.stringify(tree));
|
|
2663
|
+
return;
|
|
2664
|
+
}
|
|
2665
|
+
// GET /api/git/status - Return git status for file browser (Spec 0092)
|
|
2666
|
+
if (req.method === 'GET' && apiPath === 'git/status') {
|
|
2667
|
+
try {
|
|
2668
|
+
// Get git status in porcelain format for parsing
|
|
2669
|
+
const result = execSync('git status --porcelain', {
|
|
2670
|
+
cwd: projectPath,
|
|
2671
|
+
encoding: 'utf-8',
|
|
2672
|
+
timeout: 5000,
|
|
2673
|
+
});
|
|
2674
|
+
// Parse porcelain output: XY filename
|
|
2675
|
+
// X = staging area status, Y = working tree status
|
|
2676
|
+
const modified = [];
|
|
2677
|
+
const staged = [];
|
|
2678
|
+
const untracked = [];
|
|
2679
|
+
for (const line of result.split('\n')) {
|
|
2680
|
+
if (!line)
|
|
2681
|
+
continue;
|
|
2682
|
+
const x = line[0]; // staging area
|
|
2683
|
+
const y = line[1]; // working tree
|
|
2684
|
+
const filepath = line.slice(3);
|
|
2685
|
+
if (x === '?' && y === '?') {
|
|
2686
|
+
untracked.push(filepath);
|
|
2687
|
+
}
|
|
2688
|
+
else {
|
|
2689
|
+
if (x !== ' ' && x !== '?') {
|
|
2690
|
+
staged.push(filepath);
|
|
2691
|
+
}
|
|
2692
|
+
if (y !== ' ' && y !== '?') {
|
|
2693
|
+
modified.push(filepath);
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2698
|
+
res.end(JSON.stringify({ modified, staged, untracked }));
|
|
2699
|
+
}
|
|
2700
|
+
catch (err) {
|
|
2701
|
+
// Not a git repo or git command failed — return graceful degradation with error field
|
|
2702
|
+
log('WARN', `GET /api/git/status failed: ${err.message}`);
|
|
2703
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2704
|
+
res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
|
|
2705
|
+
}
|
|
2706
|
+
return;
|
|
2707
|
+
}
|
|
2708
|
+
// GET /api/files/recent - Return recently opened file tabs (Spec 0092)
|
|
2709
|
+
if (req.method === 'GET' && apiPath === 'files/recent') {
|
|
2710
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2711
|
+
// Get all file tabs sorted by creation time (most recent first)
|
|
2712
|
+
const recentFiles = Array.from(entry.fileTabs.values())
|
|
2713
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
2714
|
+
.slice(0, 10) // Limit to 10 most recent
|
|
2715
|
+
.map(tab => ({
|
|
2716
|
+
id: tab.id,
|
|
2717
|
+
path: tab.path,
|
|
2718
|
+
name: path.basename(tab.path),
|
|
2719
|
+
relativePath: path.relative(projectPath, tab.path),
|
|
2720
|
+
}));
|
|
2721
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2722
|
+
res.end(JSON.stringify(recentFiles));
|
|
2723
|
+
return;
|
|
2724
|
+
}
|
|
2725
|
+
// GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
|
|
2726
|
+
const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
|
|
2727
|
+
if (annotateMatch) {
|
|
2728
|
+
const tabId = annotateMatch[1];
|
|
2729
|
+
const subRoute = annotateMatch[3] || '';
|
|
2730
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2731
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2732
|
+
if (!tab) {
|
|
2733
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2734
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
const filePath = tab.path;
|
|
2738
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
2739
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2740
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2741
|
+
const is3D = ['stl', '3mf'].includes(ext);
|
|
2742
|
+
const isPdf = ext === 'pdf';
|
|
2743
|
+
const isMarkdown = ext === 'md';
|
|
2744
|
+
// Sub-route: GET /file — re-read file content from disk
|
|
2745
|
+
if (req.method === 'GET' && subRoute === 'file') {
|
|
2746
|
+
try {
|
|
2747
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2748
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2749
|
+
res.end(content);
|
|
2750
|
+
}
|
|
2751
|
+
catch (err) {
|
|
2752
|
+
log('ERROR', `GET /api/annotate/:id/file failed: ${err.message}`);
|
|
2753
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2754
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2755
|
+
}
|
|
2756
|
+
return;
|
|
2757
|
+
}
|
|
2758
|
+
// Sub-route: POST /save — save file content
|
|
2759
|
+
if (req.method === 'POST' && subRoute === 'save') {
|
|
2760
|
+
try {
|
|
2761
|
+
const body = await new Promise((resolve) => {
|
|
2762
|
+
let data = '';
|
|
2763
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2764
|
+
req.on('end', () => resolve(data));
|
|
2765
|
+
});
|
|
2766
|
+
const parsed = JSON.parse(body || '{}');
|
|
2767
|
+
const fileContent = parsed.content;
|
|
2768
|
+
if (typeof fileContent !== 'string') {
|
|
2769
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2770
|
+
res.end('Missing content');
|
|
2771
|
+
return;
|
|
2772
|
+
}
|
|
2773
|
+
fs.writeFileSync(filePath, fileContent, 'utf-8');
|
|
2774
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2775
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2776
|
+
}
|
|
2777
|
+
catch (err) {
|
|
2778
|
+
log('ERROR', `POST /api/annotate/:id/save failed: ${err.message}`);
|
|
2779
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2780
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2781
|
+
}
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
// Sub-route: GET /api/mtime — file modification time
|
|
2785
|
+
if (req.method === 'GET' && subRoute === 'api/mtime') {
|
|
2786
|
+
try {
|
|
2787
|
+
const stat = fs.statSync(filePath);
|
|
2788
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2789
|
+
res.end(JSON.stringify({ mtime: stat.mtimeMs }));
|
|
2790
|
+
}
|
|
2791
|
+
catch (err) {
|
|
2792
|
+
log('ERROR', `GET /api/annotate/:id/api/mtime failed: ${err.message}`);
|
|
2793
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2794
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2795
|
+
}
|
|
2796
|
+
return;
|
|
2797
|
+
}
|
|
2798
|
+
// Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
|
|
2799
|
+
if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
|
|
2800
|
+
try {
|
|
2801
|
+
const data = fs.readFileSync(filePath);
|
|
2802
|
+
const mimeType = getMimeTypeForFile(filePath);
|
|
2803
|
+
res.writeHead(200, {
|
|
2804
|
+
'Content-Type': mimeType,
|
|
2805
|
+
'Content-Length': data.length,
|
|
2806
|
+
'Cache-Control': 'no-cache',
|
|
2807
|
+
});
|
|
2808
|
+
res.end(data);
|
|
2809
|
+
}
|
|
2810
|
+
catch (err) {
|
|
2811
|
+
log('ERROR', `GET /api/annotate/:id/${subRoute} failed: ${err.message}`);
|
|
2812
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2813
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2814
|
+
}
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
// Default: serve the annotator HTML template
|
|
2818
|
+
if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
|
|
2819
|
+
try {
|
|
2820
|
+
const templateFile = is3D ? '3d-viewer.html' : 'open.html';
|
|
2821
|
+
const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
|
|
2822
|
+
let html = fs.readFileSync(tplPath, 'utf-8');
|
|
2823
|
+
const fileName = path.basename(filePath);
|
|
2824
|
+
const fileSize = fs.statSync(filePath).size;
|
|
2825
|
+
if (is3D) {
|
|
2826
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2827
|
+
html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
|
|
2828
|
+
html = html.replace(/\{\{FORMAT\}\}/g, ext);
|
|
2829
|
+
}
|
|
2830
|
+
else {
|
|
2831
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2832
|
+
html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
|
|
2833
|
+
html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
|
|
2834
|
+
html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
|
|
2835
|
+
html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
|
|
2836
|
+
html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
|
|
2837
|
+
html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
|
|
2838
|
+
html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
|
|
2839
|
+
html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
|
|
2840
|
+
// Inject initialization script (template loads content via fetch)
|
|
2841
|
+
let initScript;
|
|
2842
|
+
if (isImage) {
|
|
2843
|
+
initScript = `initImage(${fileSize});`;
|
|
2844
|
+
}
|
|
2845
|
+
else if (isVideo) {
|
|
2846
|
+
initScript = `initVideo(${fileSize});`;
|
|
2847
|
+
}
|
|
2848
|
+
else if (isPdf) {
|
|
2849
|
+
initScript = `initPdf(${fileSize});`;
|
|
2850
|
+
}
|
|
2851
|
+
else {
|
|
2852
|
+
initScript = `fetch('file').then(r=>r.text()).then(init);`;
|
|
2853
|
+
}
|
|
2854
|
+
html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
|
|
2855
|
+
}
|
|
2856
|
+
// Handle ?line= query param for scroll-to-line
|
|
2857
|
+
const lineParam = url.searchParams.get('line');
|
|
2858
|
+
if (lineParam) {
|
|
2859
|
+
const scrollScript = `<script>window.addEventListener('load',()=>{setTimeout(()=>{const el=document.querySelector('[data-line="${lineParam}"]');if(el){el.scrollIntoView({block:'center'});el.classList.add('highlighted-line');}},200);})</script>`;
|
|
2860
|
+
html = html.replace('</body>', `${scrollScript}</body>`);
|
|
2861
|
+
}
|
|
2862
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2863
|
+
res.end(html);
|
|
2864
|
+
}
|
|
2865
|
+
catch (err) {
|
|
2866
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2867
|
+
res.end(`Failed to serve annotator: ${err.message}`);
|
|
2868
|
+
}
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
// Unhandled API route
|
|
2873
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2874
|
+
res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
// For WebSocket paths, let the upgrade handler deal with it
|
|
2878
|
+
if (isWsPath) {
|
|
2879
|
+
// WebSocket paths are handled by the upgrade handler
|
|
2880
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2881
|
+
res.end('WebSocket connections should use ws:// protocol');
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
2885
|
+
if (!hasReactDashboard) {
|
|
2886
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2887
|
+
res.end('Dashboard not available');
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
// Fallback for unmatched paths
|
|
2891
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2892
|
+
res.end('Not found');
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
534
2895
|
// 404 for everything else
|
|
535
2896
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
536
2897
|
res.end('Not found');
|
|
537
2898
|
}
|
|
538
2899
|
catch (err) {
|
|
539
2900
|
log('ERROR', `Request error: ${err.message}`);
|
|
540
|
-
res.writeHead(500, { 'Content-Type': '
|
|
541
|
-
res.end(
|
|
2901
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2902
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
542
2903
|
}
|
|
543
2904
|
});
|
|
544
2905
|
// SECURITY: Bind to localhost only to prevent network exposure
|
|
545
|
-
server.listen(port, '127.0.0.1', () => {
|
|
2906
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
546
2907
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
2908
|
+
// Initialize shepherd session manager for persistent terminals
|
|
2909
|
+
const socketDir = path.join(homedir(), '.codev', 'run');
|
|
2910
|
+
const shepherdScript = path.join(__dirname, '..', '..', 'terminal', 'shepherd-main.js');
|
|
2911
|
+
shepherdManager = new SessionManager({
|
|
2912
|
+
socketDir,
|
|
2913
|
+
shepherdScript,
|
|
2914
|
+
nodeExecutable: process.execPath,
|
|
2915
|
+
});
|
|
2916
|
+
const staleCleaned = await shepherdManager.cleanupStaleSockets();
|
|
2917
|
+
if (staleCleaned > 0) {
|
|
2918
|
+
log('INFO', `Cleaned up ${staleCleaned} stale shepherd socket(s)`);
|
|
2919
|
+
}
|
|
2920
|
+
log('INFO', 'Shepherd session manager initialized');
|
|
2921
|
+
// TICK-001: Reconcile terminal sessions from previous run
|
|
2922
|
+
await reconcileTerminalSessions();
|
|
2923
|
+
// Spec 0100: Start background gate watcher for af send notifications
|
|
2924
|
+
startGateWatcher();
|
|
2925
|
+
log('INFO', 'Gate watcher started (10s poll interval)');
|
|
2926
|
+
// Spec 0097 Phase 4: Auto-connect tunnel if registered
|
|
2927
|
+
try {
|
|
2928
|
+
const config = readCloudConfig();
|
|
2929
|
+
if (config) {
|
|
2930
|
+
log('INFO', `Cloud config found, connecting tunnel (tower: ${config.tower_name}, key: ${maskApiKey(config.api_key)})`);
|
|
2931
|
+
await connectTunnel(config);
|
|
2932
|
+
}
|
|
2933
|
+
else {
|
|
2934
|
+
log('INFO', 'No cloud config found, operating in local-only mode');
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
catch (err) {
|
|
2938
|
+
log('WARN', `Failed to read cloud config: ${err.message}. Operating in local-only mode.`);
|
|
2939
|
+
}
|
|
2940
|
+
// Start watching cloud-config.json for changes
|
|
2941
|
+
startConfigWatcher();
|
|
2942
|
+
});
|
|
2943
|
+
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
2944
|
+
terminalWss = new WebSocketServer({ noServer: true });
|
|
2945
|
+
// WebSocket upgrade handler for terminal connections and proxying
|
|
2946
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
2947
|
+
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
2948
|
+
// Phase 2: Handle /ws/terminal/:id routes directly
|
|
2949
|
+
const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
2950
|
+
if (terminalMatch) {
|
|
2951
|
+
const terminalId = terminalMatch[1];
|
|
2952
|
+
const manager = getTerminalManager();
|
|
2953
|
+
const session = manager.getSession(terminalId);
|
|
2954
|
+
if (!session) {
|
|
2955
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2956
|
+
socket.destroy();
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2960
|
+
handleTerminalWebSocket(ws, session, req);
|
|
2961
|
+
});
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
// Phase 4 (Spec 0090): Handle project WebSocket routes directly
|
|
2965
|
+
// Route: /project/:encodedPath/ws/terminal/:terminalId
|
|
2966
|
+
if (!reqUrl.pathname.startsWith('/project/')) {
|
|
2967
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2968
|
+
socket.destroy();
|
|
2969
|
+
return;
|
|
2970
|
+
}
|
|
2971
|
+
const pathParts = reqUrl.pathname.split('/');
|
|
2972
|
+
// ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
|
|
2973
|
+
const encodedPath = pathParts[2];
|
|
2974
|
+
if (!encodedPath) {
|
|
2975
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2976
|
+
socket.destroy();
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
// Decode Base64URL (RFC 4648) - NOT URL encoding
|
|
2980
|
+
// Wrap in try/catch to handle malformed Base64 input gracefully
|
|
2981
|
+
let projectPath;
|
|
2982
|
+
try {
|
|
2983
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
2984
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
2985
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
2986
|
+
throw new Error('Invalid project path');
|
|
2987
|
+
}
|
|
2988
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
2989
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
2990
|
+
}
|
|
2991
|
+
catch {
|
|
2992
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2993
|
+
socket.destroy();
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
// Check for terminal WebSocket route: /project/:path/ws/terminal/:id
|
|
2997
|
+
const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
|
|
2998
|
+
if (wsMatch) {
|
|
2999
|
+
const terminalId = wsMatch[1];
|
|
3000
|
+
const manager = getTerminalManager();
|
|
3001
|
+
const session = manager.getSession(terminalId);
|
|
3002
|
+
if (!session) {
|
|
3003
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
3004
|
+
socket.destroy();
|
|
3005
|
+
return;
|
|
3006
|
+
}
|
|
3007
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
3008
|
+
handleTerminalWebSocket(ws, session, req);
|
|
3009
|
+
});
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
// Unhandled WebSocket route
|
|
3013
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
3014
|
+
socket.destroy();
|
|
547
3015
|
});
|
|
548
3016
|
// Handle uncaught errors
|
|
549
3017
|
process.on('uncaughtException', (err) => {
|