@cluesmith/codev 2.0.0-rc.6 → 2.0.0-rc.61
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-CXloFYpB.css +32 -0
- package/dashboard/dist/assets/index-Ca2fjOJf.js +131 -0
- package/dashboard/dist/assets/index-Ca2fjOJf.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 +94 -65
- package/dist/agent-farm/cli.js.map +1 -1
- package/dist/agent-farm/commands/architect.d.ts.map +1 -1
- package/dist/agent-farm/commands/architect.js +13 -6
- 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 +202 -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 +30 -3
- package/dist/agent-farm/commands/cleanup.js.map +1 -1
- package/dist/agent-farm/commands/consult.js +1 -1
- 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 +34 -70
- package/dist/agent-farm/commands/open.js.map +1 -1
- package/dist/agent-farm/commands/send.d.ts.map +1 -1
- package/dist/agent-farm/commands/send.js +55 -17
- 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 +61 -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 +503 -226
- package/dist/agent-farm/commands/spawn.js.map +1 -1
- package/dist/agent-farm/commands/start.d.ts +3 -0
- package/dist/agent-farm/commands/start.d.ts.map +1 -1
- package/dist/agent-farm/commands/start.js +58 -265
- 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 +61 -3
- 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 +116 -12
- package/dist/agent-farm/commands/stop.js.map +1 -1
- 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.map +1 -1
- package/dist/agent-farm/db/index.js +124 -0
- package/dist/agent-farm/db/index.js.map +1 -1
- package/dist/agent-farm/db/schema.d.ts +2 -2
- package/dist/agent-farm/db/schema.d.ts.map +1 -1
- package/dist/agent-farm/db/schema.js +26 -5
- package/dist/agent-farm/db/schema.js.map +1 -1
- package/dist/agent-farm/db/types.d.ts +3 -0
- package/dist/agent-farm/db/types.d.ts.map +1 -1
- package/dist/agent-farm/db/types.js +3 -0
- 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/tower-client.d.ts +157 -0
- package/dist/agent-farm/lib/tower-client.d.ts.map +1 -0
- package/dist/agent-farm/lib/tower-client.js +223 -0
- package/dist/agent-farm/lib/tower-client.js.map +1 -0
- package/dist/agent-farm/servers/tower-server.js +2340 -109
- package/dist/agent-farm/servers/tower-server.js.map +1 -1
- package/dist/agent-farm/state.d.ts +4 -10
- package/dist/agent-farm/state.d.ts.map +1 -1
- package/dist/agent-farm/state.js +30 -31
- package/dist/agent-farm/state.js.map +1 -1
- package/dist/agent-farm/types.d.ts +48 -1
- package/dist/agent-farm/types.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.d.ts.map +1 -1
- package/dist/agent-farm/utils/config.js +13 -14
- 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 -16
- package/dist/agent-farm/utils/deps.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/port-registry.d.ts +0 -1
- package/dist/agent-farm/utils/port-registry.d.ts.map +1 -1
- package/dist/agent-farm/utils/port-registry.js +1 -1
- package/dist/agent-farm/utils/port-registry.js.map +1 -1
- package/dist/agent-farm/utils/server-utils.d.ts +4 -4
- package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
- package/dist/agent-farm/utils/server-utils.js +4 -15
- package/dist/agent-farm/utils/server-utils.js.map +1 -1
- 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/agent-farm/utils/terminal-ports.d.ts +1 -1
- package/dist/agent-farm/utils/terminal-ports.js +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -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 +2 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +103 -6
- package/dist/commands/consult/index.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +0 -15
- 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 +21 -43
- package/dist/commands/porch/index.d.ts.map +1 -1
- package/dist/commands/porch/index.js +418 -1123
- 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 +481 -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 +255 -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 +23 -112
- package/dist/commands/porch/state.d.ts.map +1 -1
- package/dist/commands/porch/state.js +81 -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 +60 -0
- package/dist/terminal/pty-manager.d.ts.map +1 -0
- package/dist/terminal/pty-manager.js +334 -0
- package/dist/terminal/pty-manager.js.map +1 -0
- package/dist/terminal/pty-session.d.ts +79 -0
- package/dist/terminal/pty-session.d.ts.map +1 -0
- package/dist/terminal/pty-session.js +215 -0
- package/dist/terminal/pty-session.js.map +1 -0
- package/dist/terminal/ring-buffer.d.ts +27 -0
- package/dist/terminal/ring-buffer.d.ts.map +1 -0
- package/dist/terminal/ring-buffer.js +74 -0
- package/dist/terminal/ring-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 +18 -5
- package/skeleton/.claude/skills/af/SKILL.md +74 -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 +3 -29
- package/skeleton/builders.md +1 -1
- package/skeleton/consult-types/impl-review.md +9 -0
- 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 +61 -0
- package/skeleton/protocols/bugfix/protocol.json +19 -2
- 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 +14 -8
- 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 +7 -25
- package/skeleton/protocols/{spider → spir}/prompts/specify.md +33 -61
- package/skeleton/protocols/spir/protocol.json +152 -0
- package/skeleton/protocols/{spider → spir}/protocol.md +35 -21
- package/skeleton/protocols/{spider → spir}/templates/plan.md +14 -0
- package/skeleton/protocols/{spider → spir}/templates/review.md +1 -1
- 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 +25 -43
- 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 +109 -218
- 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 +542 -27
- package/dist/agent-farm/commands/kickoff.d.ts +0 -19
- package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
- package/dist/agent-farm/commands/kickoff.js +0 -331
- 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/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/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
|
@@ -7,17 +7,606 @@ import http from 'node:http';
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { spawn, execSync, spawnSync } from 'node:child_process';
|
|
12
|
+
import { homedir, tmpdir } from 'node:os';
|
|
12
13
|
import { fileURLToPath } from 'node:url';
|
|
13
14
|
import { Command } from 'commander';
|
|
15
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
14
16
|
import { getGlobalDb } from '../db/index.js';
|
|
15
17
|
import { cleanupStaleEntries } from '../utils/port-registry.js';
|
|
16
18
|
import { parseJsonBody, isRequestAllowed } from '../utils/server-utils.js';
|
|
19
|
+
import { TerminalManager } from '../../terminal/pty-manager.js';
|
|
20
|
+
import { encodeData, encodeControl, decodeFrame } from '../../terminal/ws-protocol.js';
|
|
17
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
18
22
|
const __dirname = path.dirname(__filename);
|
|
19
23
|
// Default port for tower dashboard
|
|
20
24
|
const DEFAULT_PORT = 4100;
|
|
25
|
+
// Rate limiting for activation requests (Spec 0090 Phase 1)
|
|
26
|
+
// Simple in-memory rate limiter: 10 activations per minute per client
|
|
27
|
+
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
|
|
28
|
+
const RATE_LIMIT_MAX = 10;
|
|
29
|
+
const activationRateLimits = new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Check if a client has exceeded the rate limit for activations
|
|
32
|
+
* Returns true if rate limit exceeded, false if allowed
|
|
33
|
+
*/
|
|
34
|
+
function isRateLimited(clientIp) {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const entry = activationRateLimits.get(clientIp);
|
|
37
|
+
if (!entry || now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
|
|
38
|
+
// New window
|
|
39
|
+
activationRateLimits.set(clientIp, { count: 1, windowStart: now });
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (entry.count >= RATE_LIMIT_MAX) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
entry.count++;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Clean up old rate limit entries periodically
|
|
50
|
+
*/
|
|
51
|
+
function cleanupRateLimits() {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
for (const [ip, entry] of activationRateLimits.entries()) {
|
|
54
|
+
if (now - entry.windowStart >= RATE_LIMIT_WINDOW_MS * 2) {
|
|
55
|
+
activationRateLimits.delete(ip);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Cleanup stale rate limit entries every 5 minutes
|
|
60
|
+
setInterval(cleanupRateLimits, 5 * 60 * 1000);
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// PHASE 2 & 4: Terminal Management (Spec 0090)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Global TerminalManager instance for tower-managed terminals
|
|
65
|
+
// Uses a temporary directory as projectRoot since terminals can be for any project
|
|
66
|
+
let terminalManager = null;
|
|
67
|
+
const projectTerminals = new Map();
|
|
68
|
+
/**
|
|
69
|
+
* Get or create project terminal registry entry
|
|
70
|
+
*/
|
|
71
|
+
function getProjectTerminalsEntry(projectPath) {
|
|
72
|
+
let entry = projectTerminals.get(projectPath);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
|
|
75
|
+
projectTerminals.set(projectPath, entry);
|
|
76
|
+
}
|
|
77
|
+
// Migration: ensure fileTabs exists for older entries
|
|
78
|
+
if (!entry.fileTabs) {
|
|
79
|
+
entry.fileTabs = new Map();
|
|
80
|
+
}
|
|
81
|
+
return entry;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get language identifier for syntax highlighting
|
|
85
|
+
*/
|
|
86
|
+
function getLanguageForExt(ext) {
|
|
87
|
+
const langMap = {
|
|
88
|
+
js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
|
|
89
|
+
py: 'python', sh: 'bash', bash: 'bash', md: 'markdown',
|
|
90
|
+
html: 'markup', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml',
|
|
91
|
+
rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c',
|
|
92
|
+
};
|
|
93
|
+
return langMap[ext] || ext || 'plaintext';
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Get MIME type for file
|
|
97
|
+
*/
|
|
98
|
+
function getMimeTypeForFile(filePath) {
|
|
99
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
100
|
+
const mimeTypes = {
|
|
101
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
102
|
+
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
103
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
|
|
104
|
+
pdf: 'application/pdf', txt: 'text/plain',
|
|
105
|
+
};
|
|
106
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Generate next shell ID for a project
|
|
110
|
+
*/
|
|
111
|
+
function getNextShellId(projectPath) {
|
|
112
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
113
|
+
let maxId = 0;
|
|
114
|
+
for (const id of entry.shells.keys()) {
|
|
115
|
+
const num = parseInt(id.replace('shell-', ''), 10);
|
|
116
|
+
if (!isNaN(num) && num > maxId)
|
|
117
|
+
maxId = num;
|
|
118
|
+
}
|
|
119
|
+
return `shell-${maxId + 1}`;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Get or create the global TerminalManager instance
|
|
123
|
+
*/
|
|
124
|
+
function getTerminalManager() {
|
|
125
|
+
if (!terminalManager) {
|
|
126
|
+
// Use a neutral projectRoot - terminals specify their own cwd
|
|
127
|
+
const projectRoot = process.env.HOME || '/tmp';
|
|
128
|
+
terminalManager = new TerminalManager({
|
|
129
|
+
projectRoot,
|
|
130
|
+
logDir: path.join(homedir(), '.agent-farm', 'logs'),
|
|
131
|
+
maxSessions: 100,
|
|
132
|
+
ringBufferLines: 1000,
|
|
133
|
+
diskLogEnabled: true,
|
|
134
|
+
diskLogMaxBytes: 50 * 1024 * 1024,
|
|
135
|
+
reconnectTimeoutMs: 300_000,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return terminalManager;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Normalize a project path to its canonical form for consistent SQLite storage.
|
|
142
|
+
* Uses realpath to resolve symlinks and relative paths.
|
|
143
|
+
*/
|
|
144
|
+
function normalizeProjectPath(projectPath) {
|
|
145
|
+
try {
|
|
146
|
+
return fs.realpathSync(projectPath);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Path doesn't exist yet, normalize without realpath
|
|
150
|
+
return path.resolve(projectPath);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Save a terminal session to SQLite.
|
|
155
|
+
* Guards against race conditions by checking if project is still active.
|
|
156
|
+
*/
|
|
157
|
+
function saveTerminalSession(terminalId, projectPath, type, roleId, pid, tmuxSession) {
|
|
158
|
+
try {
|
|
159
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
160
|
+
// Race condition guard: only save if project is still in the active registry
|
|
161
|
+
// This prevents zombie rows when stop races with session creation
|
|
162
|
+
if (!projectTerminals.has(normalizedPath) && !projectTerminals.has(projectPath)) {
|
|
163
|
+
log('INFO', `Skipping session save - project no longer active: ${projectPath}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const db = getGlobalDb();
|
|
167
|
+
db.prepare(`
|
|
168
|
+
INSERT OR REPLACE INTO terminal_sessions (id, project_path, type, role_id, pid, tmux_session)
|
|
169
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
170
|
+
`).run(terminalId, normalizedPath, type, roleId, pid, tmuxSession);
|
|
171
|
+
log('INFO', `Saved terminal session to SQLite: ${terminalId} (${type}) for ${path.basename(normalizedPath)}`);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
log('WARN', `Failed to save terminal session: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Delete a terminal session from SQLite
|
|
179
|
+
*/
|
|
180
|
+
function deleteTerminalSession(terminalId) {
|
|
181
|
+
try {
|
|
182
|
+
const db = getGlobalDb();
|
|
183
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(terminalId);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
log('WARN', `Failed to delete terminal session: ${err.message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Delete all terminal sessions for a project from SQLite.
|
|
191
|
+
* Normalizes path to ensure consistent cleanup regardless of how path was provided.
|
|
192
|
+
*/
|
|
193
|
+
function deleteProjectTerminalSessions(projectPath) {
|
|
194
|
+
try {
|
|
195
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
196
|
+
const db = getGlobalDb();
|
|
197
|
+
// Delete both normalized and raw path to handle any inconsistencies
|
|
198
|
+
db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(normalizedPath);
|
|
199
|
+
if (normalizedPath !== projectPath) {
|
|
200
|
+
db.prepare('DELETE FROM terminal_sessions WHERE project_path = ?').run(projectPath);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
log('WARN', `Failed to delete project terminal sessions: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// Whether tmux is available on this system (checked once at startup)
|
|
208
|
+
let tmuxAvailable = false;
|
|
209
|
+
/**
|
|
210
|
+
* Check if tmux is installed and available
|
|
211
|
+
*/
|
|
212
|
+
function checkTmux() {
|
|
213
|
+
try {
|
|
214
|
+
execSync('tmux -V', { stdio: 'ignore' });
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Sanitize a tmux session name to match what tmux actually creates.
|
|
223
|
+
* tmux replaces dots with underscores and strips colons from session names.
|
|
224
|
+
* Without this, stored names won't match actual tmux session names,
|
|
225
|
+
* causing reconnection to fail (e.g., "builder-codevos.ai-0001" vs "builder-codevos_ai-0001").
|
|
226
|
+
*/
|
|
227
|
+
function sanitizeTmuxSessionName(name) {
|
|
228
|
+
return name.replace(/\./g, '_').replace(/:/g, '');
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Create a tmux session with the given command.
|
|
232
|
+
* Returns the sanitized session name if created successfully, null on failure.
|
|
233
|
+
* Session names are sanitized to match tmux behavior (dots → underscores, colons stripped).
|
|
234
|
+
*/
|
|
235
|
+
function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
236
|
+
// Sanitize to match what tmux actually creates (dots → underscores, colons stripped)
|
|
237
|
+
sessionName = sanitizeTmuxSessionName(sessionName);
|
|
238
|
+
// Kill any stale session with this name
|
|
239
|
+
if (tmuxSessionExists(sessionName)) {
|
|
240
|
+
killTmuxSession(sessionName);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
// Use spawnSync with array args to avoid shell injection via project paths
|
|
244
|
+
const tmuxArgs = [
|
|
245
|
+
'new-session', '-d',
|
|
246
|
+
'-s', sessionName,
|
|
247
|
+
'-c', cwd,
|
|
248
|
+
'-x', String(cols),
|
|
249
|
+
'-y', String(rows),
|
|
250
|
+
command, ...args,
|
|
251
|
+
];
|
|
252
|
+
const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
|
|
253
|
+
if (result.status !== 0) {
|
|
254
|
+
log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
// Hide tmux status bar (dashboard has its own tabs), enable mouse, and
|
|
258
|
+
// use aggressive-resize so tmux sizes to the largest client (not smallest)
|
|
259
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
|
|
260
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
|
|
261
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
|
|
262
|
+
return sessionName;
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Check if a tmux session exists.
|
|
271
|
+
* Sanitizes the name to handle legacy entries stored before dot-replacement fix.
|
|
272
|
+
*/
|
|
273
|
+
function tmuxSessionExists(sessionName) {
|
|
274
|
+
const sanitized = sanitizeTmuxSessionName(sessionName);
|
|
275
|
+
try {
|
|
276
|
+
execSync(`tmux has-session -t "${sanitized}" 2>/dev/null`, { stdio: 'ignore' });
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Check if a process is running
|
|
285
|
+
*/
|
|
286
|
+
function processExists(pid) {
|
|
287
|
+
try {
|
|
288
|
+
process.kill(pid, 0);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Kill a tmux session by name
|
|
297
|
+
*/
|
|
298
|
+
function killTmuxSession(sessionName) {
|
|
299
|
+
try {
|
|
300
|
+
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
301
|
+
log('INFO', `Killed orphaned tmux session: ${sessionName}`);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Session may have already died
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Parse a codev tmux session name to extract type, project, and role.
|
|
309
|
+
* Returns null if the name doesn't match any known codev pattern.
|
|
310
|
+
*
|
|
311
|
+
* Examples:
|
|
312
|
+
* "architect-codev-public" → { type: 'architect', projectBasename: 'codev-public', roleId: null }
|
|
313
|
+
* "builder-codevos_ai-0001" → { type: 'builder', projectBasename: 'codevos_ai', roleId: '0001' }
|
|
314
|
+
* "shell-codev-public-shell-1" → { type: 'shell', projectBasename: 'codev-public', roleId: 'shell-1' }
|
|
315
|
+
*/
|
|
316
|
+
function parseTmuxSessionName(name) {
|
|
317
|
+
// architect-{basename}
|
|
318
|
+
const architectMatch = name.match(/^architect-(.+)$/);
|
|
319
|
+
if (architectMatch) {
|
|
320
|
+
return { type: 'architect', projectBasename: architectMatch[1], roleId: null };
|
|
321
|
+
}
|
|
322
|
+
// builder-{basename}-{specId} — specId is always the last segment (digits like "0001")
|
|
323
|
+
const builderMatch = name.match(/^builder-(.+)-(\d{4,})$/);
|
|
324
|
+
if (builderMatch) {
|
|
325
|
+
return { type: 'builder', projectBasename: builderMatch[1], roleId: builderMatch[2] };
|
|
326
|
+
}
|
|
327
|
+
// shell-{basename}-{shellId} — shellId is "shell-N" (last two segments)
|
|
328
|
+
const shellMatch = name.match(/^shell-(.+)-(shell-\d+)$/);
|
|
329
|
+
if (shellMatch) {
|
|
330
|
+
return { type: 'shell', projectBasename: shellMatch[1], roleId: shellMatch[2] };
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* List all tmux sessions that match codev naming conventions.
|
|
336
|
+
* Returns an array of { tmuxName, parsed } for each matching session.
|
|
337
|
+
*/
|
|
338
|
+
// Cache for listCodevTmuxSessions — avoid shelling out on every dashboard poll
|
|
339
|
+
let _tmuxListCache = [];
|
|
340
|
+
let _tmuxListCacheTime = 0;
|
|
341
|
+
const TMUX_LIST_CACHE_TTL = 10_000; // 10 seconds
|
|
342
|
+
function listCodevTmuxSessions(bypassCache = false) {
|
|
343
|
+
if (!tmuxAvailable)
|
|
344
|
+
return [];
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
if (!bypassCache && now - _tmuxListCacheTime < TMUX_LIST_CACHE_TTL) {
|
|
347
|
+
return _tmuxListCache;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const result = execSync('tmux list-sessions -F "#{session_name}" 2>/dev/null', { encoding: 'utf-8' });
|
|
351
|
+
const sessions = result.trim().split('\n').filter(Boolean);
|
|
352
|
+
const codevSessions = [];
|
|
353
|
+
for (const name of sessions) {
|
|
354
|
+
const parsed = parseTmuxSessionName(name);
|
|
355
|
+
if (parsed) {
|
|
356
|
+
codevSessions.push({ tmuxName: name, parsed });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
_tmuxListCache = codevSessions;
|
|
360
|
+
_tmuxListCacheTime = now;
|
|
361
|
+
return codevSessions;
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
_tmuxListCache = [];
|
|
365
|
+
_tmuxListCacheTime = now;
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Find the SQLite row that matches a given tmux session name.
|
|
371
|
+
* Looks up by tmux_session column directly.
|
|
372
|
+
*/
|
|
373
|
+
function findSqliteRowForTmuxSession(tmuxName) {
|
|
374
|
+
try {
|
|
375
|
+
const db = getGlobalDb();
|
|
376
|
+
return db.prepare('SELECT * FROM terminal_sessions WHERE tmux_session = ?').get(tmuxName) || null;
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Find the full project path for a tmux session's project basename.
|
|
384
|
+
* Checks active port allocations (which have full paths) for a matching basename.
|
|
385
|
+
* Returns null if no match found.
|
|
386
|
+
*/
|
|
387
|
+
function resolveProjectPathFromBasename(projectBasename) {
|
|
388
|
+
const allocations = loadPortAllocations();
|
|
389
|
+
for (const alloc of allocations) {
|
|
390
|
+
if (path.basename(alloc.project_path) === projectBasename) {
|
|
391
|
+
return normalizeProjectPath(alloc.project_path);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// Also check projectTerminals cache (may have entries not yet in allocations)
|
|
395
|
+
for (const [projectPath] of projectTerminals) {
|
|
396
|
+
if (path.basename(projectPath) === projectBasename) {
|
|
397
|
+
return projectPath;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Reconcile terminal sessions on startup.
|
|
404
|
+
*
|
|
405
|
+
* STRATEGY: tmux is the source of truth for existence.
|
|
406
|
+
*
|
|
407
|
+
* Phase 1 — tmux-first discovery:
|
|
408
|
+
* List all codev tmux sessions. For each, look up SQLite for metadata.
|
|
409
|
+
* If SQLite has a matching row → reconnect with full metadata.
|
|
410
|
+
* If SQLite has no row (orphaned tmux) → derive metadata from session name, reconnect.
|
|
411
|
+
*
|
|
412
|
+
* Phase 2 — SQLite sweep:
|
|
413
|
+
* Any SQLite rows not matched to a tmux session are stale → clean up.
|
|
414
|
+
* (Also kills orphaned processes that have no tmux backing.)
|
|
415
|
+
*/
|
|
416
|
+
async function reconcileTerminalSessions() {
|
|
417
|
+
const manager = getTerminalManager();
|
|
418
|
+
const db = getGlobalDb();
|
|
419
|
+
// Phase 1: Discover living tmux sessions (bypass cache on startup)
|
|
420
|
+
const liveTmuxSessions = listCodevTmuxSessions(/* bypassCache */ true);
|
|
421
|
+
// Track which SQLite rows we matched (by tmux_session name)
|
|
422
|
+
const matchedTmuxNames = new Set();
|
|
423
|
+
let reconnected = 0;
|
|
424
|
+
let orphanReconnected = 0;
|
|
425
|
+
if (liveTmuxSessions.length > 0) {
|
|
426
|
+
log('INFO', `Found ${liveTmuxSessions.length} live codev tmux session(s) — reconnecting...`);
|
|
427
|
+
}
|
|
428
|
+
for (const { tmuxName, parsed } of liveTmuxSessions) {
|
|
429
|
+
// Look up SQLite for this tmux session's metadata
|
|
430
|
+
const dbRow = findSqliteRowForTmuxSession(tmuxName);
|
|
431
|
+
matchedTmuxNames.add(tmuxName);
|
|
432
|
+
// Determine metadata — prefer SQLite, fall back to parsed name
|
|
433
|
+
const projectPath = dbRow?.project_path || resolveProjectPathFromBasename(parsed.projectBasename);
|
|
434
|
+
const type = dbRow?.type || parsed.type;
|
|
435
|
+
const roleId = dbRow?.role_id || parsed.roleId;
|
|
436
|
+
if (!projectPath) {
|
|
437
|
+
log('WARN', `Cannot resolve project path for tmux session "${tmuxName}" (basename: ${parsed.projectBasename}) — skipping`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const label = type === 'architect' ? 'Architect' : `${type} ${roleId || 'unknown'}`;
|
|
442
|
+
const newSession = await manager.createSession({
|
|
443
|
+
command: 'tmux',
|
|
444
|
+
args: ['attach-session', '-t', tmuxName],
|
|
445
|
+
cwd: projectPath,
|
|
446
|
+
label,
|
|
447
|
+
});
|
|
448
|
+
// Register in projectTerminals Map
|
|
449
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
450
|
+
if (type === 'architect') {
|
|
451
|
+
entry.architect = newSession.id;
|
|
452
|
+
}
|
|
453
|
+
else if (type === 'builder') {
|
|
454
|
+
entry.builders.set(roleId || tmuxName, newSession.id);
|
|
455
|
+
}
|
|
456
|
+
else if (type === 'shell') {
|
|
457
|
+
entry.shells.set(roleId || tmuxName, newSession.id);
|
|
458
|
+
}
|
|
459
|
+
// Update SQLite: delete old row (if any), insert fresh one
|
|
460
|
+
if (dbRow) {
|
|
461
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(dbRow.id);
|
|
462
|
+
}
|
|
463
|
+
saveTerminalSession(newSession.id, projectPath, type, roleId, newSession.pid, tmuxName);
|
|
464
|
+
if (dbRow) {
|
|
465
|
+
log('INFO', `Reconnected tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)})`);
|
|
466
|
+
reconnected++;
|
|
467
|
+
}
|
|
468
|
+
else {
|
|
469
|
+
log('INFO', `Recovered orphaned tmux "${tmuxName}" → terminal ${newSession.id} (${type} for ${path.basename(projectPath)}) [no SQLite row]`);
|
|
470
|
+
orphanReconnected++;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
log('WARN', `Failed to reconnect to tmux "${tmuxName}": ${err.message}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Phase 2: Sweep stale SQLite rows (those with no matching live tmux session)
|
|
478
|
+
let killed = 0;
|
|
479
|
+
let cleaned = 0;
|
|
480
|
+
let allDbSessions;
|
|
481
|
+
try {
|
|
482
|
+
allDbSessions = db.prepare('SELECT * FROM terminal_sessions').all();
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
log('WARN', `Failed to read terminal sessions for sweep: ${err.message}`);
|
|
486
|
+
allDbSessions = [];
|
|
487
|
+
}
|
|
488
|
+
for (const session of allDbSessions) {
|
|
489
|
+
// Skip rows that were already reconnected in Phase 1
|
|
490
|
+
if (session.tmux_session && matchedTmuxNames.has(session.tmux_session)) {
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
// Also skip rows whose terminal is still alive in PtyManager
|
|
494
|
+
// (non-tmux sessions created during this Tower run)
|
|
495
|
+
const existing = manager.getSession(session.id);
|
|
496
|
+
if (existing && existing.status !== 'exited') {
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
// Stale row — kill orphaned process if any, then delete
|
|
500
|
+
if (session.pid && processExists(session.pid)) {
|
|
501
|
+
log('INFO', `Killing orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
|
|
502
|
+
try {
|
|
503
|
+
process.kill(session.pid, 'SIGTERM');
|
|
504
|
+
killed++;
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
// Process may not be killable
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
|
|
511
|
+
cleaned++;
|
|
512
|
+
}
|
|
513
|
+
const total = reconnected + orphanReconnected;
|
|
514
|
+
if (total > 0 || killed > 0 || cleaned > 0) {
|
|
515
|
+
log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${orphanReconnected} orphan-recovered, ${killed} killed, ${cleaned} stale rows cleaned`);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
log('INFO', 'No terminal sessions to reconcile');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get terminal sessions from SQLite for a project.
|
|
523
|
+
* Normalizes path for consistent lookup.
|
|
524
|
+
*/
|
|
525
|
+
function getTerminalSessionsForProject(projectPath) {
|
|
526
|
+
try {
|
|
527
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
528
|
+
const db = getGlobalDb();
|
|
529
|
+
return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Handle WebSocket connection to a terminal session
|
|
537
|
+
* Uses hybrid binary protocol (Spec 0085):
|
|
538
|
+
* - 0x00 prefix: Control frame (JSON)
|
|
539
|
+
* - 0x01 prefix: Data frame (raw PTY bytes)
|
|
540
|
+
*/
|
|
541
|
+
function handleTerminalWebSocket(ws, session, req) {
|
|
542
|
+
const resumeSeq = req.headers['x-session-resume'];
|
|
543
|
+
// Create a client adapter for the PTY session
|
|
544
|
+
// Uses binary protocol for data frames
|
|
545
|
+
const client = {
|
|
546
|
+
send: (data) => {
|
|
547
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
548
|
+
// Encode as binary data frame (0x01 prefix)
|
|
549
|
+
ws.send(encodeData(data));
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
// Attach client to session and get replay data
|
|
554
|
+
let replayLines;
|
|
555
|
+
if (resumeSeq && typeof resumeSeq === 'string') {
|
|
556
|
+
replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
replayLines = session.attach(client);
|
|
560
|
+
}
|
|
561
|
+
// Send replay data as binary data frame
|
|
562
|
+
if (replayLines.length > 0) {
|
|
563
|
+
const replayData = replayLines.join('\n');
|
|
564
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
565
|
+
ws.send(encodeData(replayData));
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// Handle incoming messages from client (binary protocol)
|
|
569
|
+
ws.on('message', (rawData) => {
|
|
570
|
+
try {
|
|
571
|
+
const frame = decodeFrame(Buffer.from(rawData));
|
|
572
|
+
if (frame.type === 'data') {
|
|
573
|
+
// Write raw input to terminal
|
|
574
|
+
session.write(frame.data.toString('utf-8'));
|
|
575
|
+
}
|
|
576
|
+
else if (frame.type === 'control') {
|
|
577
|
+
// Handle control messages
|
|
578
|
+
const msg = frame.message;
|
|
579
|
+
if (msg.type === 'resize') {
|
|
580
|
+
const cols = msg.payload.cols;
|
|
581
|
+
const rows = msg.payload.rows;
|
|
582
|
+
if (typeof cols === 'number' && typeof rows === 'number') {
|
|
583
|
+
session.resize(cols, rows);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
else if (msg.type === 'ping') {
|
|
587
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
588
|
+
ws.send(encodeControl({ type: 'pong', payload: {} }));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// If decode fails, try treating as raw UTF-8 input (for simpler clients)
|
|
595
|
+
try {
|
|
596
|
+
session.write(rawData.toString('utf-8'));
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
// Ignore malformed input
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
ws.on('close', () => {
|
|
604
|
+
session.detach(client);
|
|
605
|
+
});
|
|
606
|
+
ws.on('error', () => {
|
|
607
|
+
session.detach(client);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
21
610
|
// Parse arguments with Commander
|
|
22
611
|
const program = new Command()
|
|
23
612
|
.name('tower-server')
|
|
@@ -52,6 +641,41 @@ function log(level, message) {
|
|
|
52
641
|
}
|
|
53
642
|
}
|
|
54
643
|
}
|
|
644
|
+
// Global exception handlers to catch uncaught errors
|
|
645
|
+
process.on('uncaughtException', (err) => {
|
|
646
|
+
log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
|
|
647
|
+
process.exit(1);
|
|
648
|
+
});
|
|
649
|
+
process.on('unhandledRejection', (reason) => {
|
|
650
|
+
const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
|
651
|
+
log('ERROR', `Unhandled rejection: ${message}`);
|
|
652
|
+
process.exit(1);
|
|
653
|
+
});
|
|
654
|
+
// Graceful shutdown handler (Phase 2 - Spec 0090)
|
|
655
|
+
async function gracefulShutdown(signal) {
|
|
656
|
+
log('INFO', `Received ${signal}, starting graceful shutdown...`);
|
|
657
|
+
// 1. Stop accepting new connections
|
|
658
|
+
server?.close();
|
|
659
|
+
// 2. Close all WebSocket connections
|
|
660
|
+
if (terminalWss) {
|
|
661
|
+
for (const client of terminalWss.clients) {
|
|
662
|
+
client.close(1001, 'Server shutting down');
|
|
663
|
+
}
|
|
664
|
+
terminalWss.close();
|
|
665
|
+
}
|
|
666
|
+
// 3. Kill all PTY sessions
|
|
667
|
+
if (terminalManager) {
|
|
668
|
+
log('INFO', 'Shutting down terminal manager...');
|
|
669
|
+
terminalManager.shutdown();
|
|
670
|
+
}
|
|
671
|
+
// 4. Stop cloudflared tunnel if running
|
|
672
|
+
stopTunnel();
|
|
673
|
+
log('INFO', 'Graceful shutdown complete');
|
|
674
|
+
process.exit(0);
|
|
675
|
+
}
|
|
676
|
+
// Catch signals for clean shutdown
|
|
677
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
678
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
55
679
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
56
680
|
log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
57
681
|
process.exit(1);
|
|
@@ -97,6 +721,340 @@ async function isPortListening(port) {
|
|
|
97
721
|
function getProjectName(projectPath) {
|
|
98
722
|
return path.basename(projectPath);
|
|
99
723
|
}
|
|
724
|
+
/**
|
|
725
|
+
* Get the base port for a project from global.db
|
|
726
|
+
* Returns null if project not found or not running
|
|
727
|
+
*/
|
|
728
|
+
async function getBasePortForProject(projectPath) {
|
|
729
|
+
try {
|
|
730
|
+
const db = getGlobalDb();
|
|
731
|
+
const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
|
|
732
|
+
if (!row)
|
|
733
|
+
return null;
|
|
734
|
+
// Check if actually running
|
|
735
|
+
const isRunning = await isPortListening(row.base_port);
|
|
736
|
+
return isRunning ? row.base_port : null;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
// Cloudflared tunnel management
|
|
743
|
+
let tunnelProcess = null;
|
|
744
|
+
let tunnelUrl = null;
|
|
745
|
+
function isCloudflaredInstalled() {
|
|
746
|
+
try {
|
|
747
|
+
execSync('which cloudflared', { stdio: 'ignore' });
|
|
748
|
+
return true;
|
|
749
|
+
}
|
|
750
|
+
catch {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function getTunnelStatus() {
|
|
755
|
+
return {
|
|
756
|
+
available: isCloudflaredInstalled(),
|
|
757
|
+
running: tunnelProcess !== null && tunnelUrl !== null,
|
|
758
|
+
url: tunnelUrl,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async function startTunnel(port) {
|
|
762
|
+
if (!isCloudflaredInstalled()) {
|
|
763
|
+
return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
|
|
764
|
+
}
|
|
765
|
+
if (tunnelProcess) {
|
|
766
|
+
return { success: true, url: tunnelUrl || undefined };
|
|
767
|
+
}
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
770
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
771
|
+
});
|
|
772
|
+
const handleOutput = (data) => {
|
|
773
|
+
const text = data.toString();
|
|
774
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
775
|
+
if (match && !tunnelUrl) {
|
|
776
|
+
tunnelUrl = match[0];
|
|
777
|
+
log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
|
|
778
|
+
resolve({ success: true, url: tunnelUrl });
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
tunnelProcess.stdout?.on('data', handleOutput);
|
|
782
|
+
tunnelProcess.stderr?.on('data', handleOutput);
|
|
783
|
+
tunnelProcess.on('close', (code) => {
|
|
784
|
+
log('INFO', `Cloudflared tunnel closed with code ${code}`);
|
|
785
|
+
tunnelProcess = null;
|
|
786
|
+
tunnelUrl = null;
|
|
787
|
+
});
|
|
788
|
+
// Timeout after 30 seconds
|
|
789
|
+
setTimeout(() => {
|
|
790
|
+
if (!tunnelUrl) {
|
|
791
|
+
tunnelProcess?.kill();
|
|
792
|
+
tunnelProcess = null;
|
|
793
|
+
resolve({ success: false, error: 'Tunnel startup timed out' });
|
|
794
|
+
}
|
|
795
|
+
}, 30000);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
function stopTunnel() {
|
|
799
|
+
if (tunnelProcess) {
|
|
800
|
+
tunnelProcess.kill();
|
|
801
|
+
tunnelProcess = null;
|
|
802
|
+
tunnelUrl = null;
|
|
803
|
+
log('INFO', 'Cloudflared tunnel stopped');
|
|
804
|
+
}
|
|
805
|
+
return { success: true };
|
|
806
|
+
}
|
|
807
|
+
const sseClients = [];
|
|
808
|
+
let notificationIdCounter = 0;
|
|
809
|
+
/**
|
|
810
|
+
* Broadcast a notification to all connected SSE clients
|
|
811
|
+
*/
|
|
812
|
+
function broadcastNotification(notification) {
|
|
813
|
+
const id = ++notificationIdCounter;
|
|
814
|
+
const data = JSON.stringify({ ...notification, id });
|
|
815
|
+
const message = `id: ${id}\ndata: ${data}\n\n`;
|
|
816
|
+
for (const client of sseClients) {
|
|
817
|
+
try {
|
|
818
|
+
client.res.write(message);
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
// Client disconnected, will be cleaned up on next iteration
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Get gate status for a project by querying its dashboard API.
|
|
827
|
+
* Uses timeout to prevent hung projects from stalling tower status.
|
|
828
|
+
*/
|
|
829
|
+
async function getGateStatusForProject(basePort) {
|
|
830
|
+
const controller = new AbortController();
|
|
831
|
+
const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
|
|
832
|
+
try {
|
|
833
|
+
const response = await fetch(`http://localhost:${basePort}/api/status`, {
|
|
834
|
+
signal: controller.signal,
|
|
835
|
+
});
|
|
836
|
+
clearTimeout(timeout);
|
|
837
|
+
if (!response.ok)
|
|
838
|
+
return { hasGate: false };
|
|
839
|
+
const projectStatus = await response.json();
|
|
840
|
+
// Check if any builder has a pending gate
|
|
841
|
+
const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
|
|
842
|
+
if (builderWithGate) {
|
|
843
|
+
return {
|
|
844
|
+
hasGate: true,
|
|
845
|
+
gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
|
|
846
|
+
builderId: builderWithGate.id,
|
|
847
|
+
timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// Project dashboard not responding or timeout
|
|
853
|
+
}
|
|
854
|
+
return { hasGate: false };
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Get terminal list for a project from tower's registry.
|
|
858
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
859
|
+
* Returns architect, builders, and shells with their URLs.
|
|
860
|
+
*/
|
|
861
|
+
async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
862
|
+
const manager = getTerminalManager();
|
|
863
|
+
const terminals = [];
|
|
864
|
+
// Query SQLite first, then augment with tmux discovery
|
|
865
|
+
const dbSessions = getTerminalSessionsForProject(projectPath);
|
|
866
|
+
// Use normalized path for cache consistency
|
|
867
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
868
|
+
// Build a fresh entry from SQLite, then replace atomically to avoid
|
|
869
|
+
// destroying in-memory state that was registered via POST /api/terminals.
|
|
870
|
+
// Previous approach cleared the cache then rebuilt, which lost terminals
|
|
871
|
+
// if their SQLite rows were deleted by external interference (e.g., tests).
|
|
872
|
+
const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
|
|
873
|
+
// Preserve file tabs from existing entry (not stored in SQLite)
|
|
874
|
+
const existingEntry = projectTerminals.get(normalizedPath);
|
|
875
|
+
if (existingEntry) {
|
|
876
|
+
freshEntry.fileTabs = existingEntry.fileTabs;
|
|
877
|
+
}
|
|
878
|
+
for (const dbSession of dbSessions) {
|
|
879
|
+
// Verify session still exists in TerminalManager (runtime state)
|
|
880
|
+
let session = manager.getSession(dbSession.id);
|
|
881
|
+
const sanitizedTmux = dbSession.tmux_session ? sanitizeTmuxSessionName(dbSession.tmux_session) : null;
|
|
882
|
+
if (!session && sanitizedTmux && tmuxAvailable && tmuxSessionExists(sanitizedTmux)) {
|
|
883
|
+
// PTY session gone but tmux session survives — reconnect on-the-fly
|
|
884
|
+
try {
|
|
885
|
+
const newSession = await manager.createSession({
|
|
886
|
+
command: 'tmux',
|
|
887
|
+
args: ['attach-session', '-t', sanitizedTmux],
|
|
888
|
+
cwd: dbSession.project_path,
|
|
889
|
+
label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
|
|
890
|
+
env: process.env,
|
|
891
|
+
});
|
|
892
|
+
// Update SQLite with new terminal ID (use sanitized tmux name)
|
|
893
|
+
deleteTerminalSession(dbSession.id);
|
|
894
|
+
saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, sanitizedTmux);
|
|
895
|
+
dbSession.id = newSession.id;
|
|
896
|
+
session = manager.getSession(newSession.id);
|
|
897
|
+
log('INFO', `Reconnected to tmux "${sanitizedTmux}" on-the-fly → ${newSession.id}`);
|
|
898
|
+
}
|
|
899
|
+
catch (err) {
|
|
900
|
+
log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
else if (!session) {
|
|
905
|
+
// Stale row in SQLite, no tmux to reconnect — clean it up
|
|
906
|
+
deleteTerminalSession(dbSession.id);
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (dbSession.type === 'architect') {
|
|
910
|
+
freshEntry.architect = dbSession.id;
|
|
911
|
+
terminals.push({
|
|
912
|
+
type: 'architect',
|
|
913
|
+
id: 'architect',
|
|
914
|
+
label: 'Architect',
|
|
915
|
+
url: `${proxyUrl}?tab=architect`,
|
|
916
|
+
active: true,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
else if (dbSession.type === 'builder') {
|
|
920
|
+
const builderId = dbSession.role_id || dbSession.id;
|
|
921
|
+
freshEntry.builders.set(builderId, dbSession.id);
|
|
922
|
+
terminals.push({
|
|
923
|
+
type: 'builder',
|
|
924
|
+
id: builderId,
|
|
925
|
+
label: `Builder ${builderId}`,
|
|
926
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
927
|
+
active: true,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
else if (dbSession.type === 'shell') {
|
|
931
|
+
const shellId = dbSession.role_id || dbSession.id;
|
|
932
|
+
freshEntry.shells.set(shellId, dbSession.id);
|
|
933
|
+
terminals.push({
|
|
934
|
+
type: 'shell',
|
|
935
|
+
id: shellId,
|
|
936
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
937
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
938
|
+
active: true,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// Also merge in-memory entries that may not be in SQLite yet
|
|
943
|
+
// (e.g., registered via POST /api/terminals but SQLite row was lost)
|
|
944
|
+
if (existingEntry) {
|
|
945
|
+
if (existingEntry.architect && !freshEntry.architect) {
|
|
946
|
+
const session = manager.getSession(existingEntry.architect);
|
|
947
|
+
if (session) {
|
|
948
|
+
freshEntry.architect = existingEntry.architect;
|
|
949
|
+
terminals.push({
|
|
950
|
+
type: 'architect',
|
|
951
|
+
id: 'architect',
|
|
952
|
+
label: 'Architect',
|
|
953
|
+
url: `${proxyUrl}?tab=architect`,
|
|
954
|
+
active: true,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
for (const [builderId, terminalId] of existingEntry.builders) {
|
|
959
|
+
if (!freshEntry.builders.has(builderId)) {
|
|
960
|
+
const session = manager.getSession(terminalId);
|
|
961
|
+
if (session) {
|
|
962
|
+
freshEntry.builders.set(builderId, terminalId);
|
|
963
|
+
terminals.push({
|
|
964
|
+
type: 'builder',
|
|
965
|
+
id: builderId,
|
|
966
|
+
label: `Builder ${builderId}`,
|
|
967
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
968
|
+
active: true,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
for (const [shellId, terminalId] of existingEntry.shells) {
|
|
974
|
+
if (!freshEntry.shells.has(shellId)) {
|
|
975
|
+
const session = manager.getSession(terminalId);
|
|
976
|
+
if (session) {
|
|
977
|
+
freshEntry.shells.set(shellId, terminalId);
|
|
978
|
+
terminals.push({
|
|
979
|
+
type: 'shell',
|
|
980
|
+
id: shellId,
|
|
981
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
982
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
983
|
+
active: true,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// Phase 3: tmux discovery — find tmux sessions for this project that are
|
|
990
|
+
// missing from both SQLite and the in-memory cache.
|
|
991
|
+
// This is the safety net: if SQLite rows got deleted but tmux survived,
|
|
992
|
+
// the session will still appear in the dashboard.
|
|
993
|
+
const projectBasename = sanitizeTmuxSessionName(path.basename(normalizedPath));
|
|
994
|
+
const liveTmux = listCodevTmuxSessions();
|
|
995
|
+
for (const { tmuxName, parsed } of liveTmux) {
|
|
996
|
+
// Only process sessions whose sanitized project basename matches
|
|
997
|
+
if (parsed.projectBasename !== projectBasename)
|
|
998
|
+
continue;
|
|
999
|
+
// Skip if we already have this session registered (from SQLite or in-memory)
|
|
1000
|
+
const alreadyRegistered = (parsed.type === 'architect' && freshEntry.architect) ||
|
|
1001
|
+
(parsed.type === 'builder' && parsed.roleId && freshEntry.builders.has(parsed.roleId)) ||
|
|
1002
|
+
(parsed.type === 'shell' && parsed.roleId && freshEntry.shells.has(parsed.roleId));
|
|
1003
|
+
if (alreadyRegistered)
|
|
1004
|
+
continue;
|
|
1005
|
+
// Orphaned tmux session — reconnect it
|
|
1006
|
+
try {
|
|
1007
|
+
const label = parsed.type === 'architect' ? 'Architect' : `${parsed.type} ${parsed.roleId || 'unknown'}`;
|
|
1008
|
+
const newSession = await manager.createSession({
|
|
1009
|
+
command: 'tmux',
|
|
1010
|
+
args: ['attach-session', '-t', tmuxName],
|
|
1011
|
+
cwd: normalizedPath,
|
|
1012
|
+
label,
|
|
1013
|
+
});
|
|
1014
|
+
const roleId = parsed.roleId;
|
|
1015
|
+
if (parsed.type === 'architect') {
|
|
1016
|
+
freshEntry.architect = newSession.id;
|
|
1017
|
+
terminals.push({ type: 'architect', id: 'architect', label: 'Architect', url: `${proxyUrl}?tab=architect`, active: true });
|
|
1018
|
+
}
|
|
1019
|
+
else if (parsed.type === 'builder' && roleId) {
|
|
1020
|
+
freshEntry.builders.set(roleId, newSession.id);
|
|
1021
|
+
terminals.push({ type: 'builder', id: roleId, label: `Builder ${roleId}`, url: `${proxyUrl}?tab=builder-${roleId}`, active: true });
|
|
1022
|
+
}
|
|
1023
|
+
else if (parsed.type === 'shell' && roleId) {
|
|
1024
|
+
freshEntry.shells.set(roleId, newSession.id);
|
|
1025
|
+
terminals.push({ type: 'shell', id: roleId, label: `Shell ${roleId.replace('shell-', '')}`, url: `${proxyUrl}?tab=shell-${roleId}`, active: true });
|
|
1026
|
+
}
|
|
1027
|
+
// Persist to SQLite so future polls find it directly
|
|
1028
|
+
saveTerminalSession(newSession.id, normalizedPath, parsed.type, roleId, newSession.pid, tmuxName);
|
|
1029
|
+
log('INFO', `[tmux-discovery] Recovered orphaned tmux "${tmuxName}" → ${newSession.id} (${parsed.type})`);
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
log('WARN', `[tmux-discovery] Failed to recover tmux "${tmuxName}": ${err.message}`);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
// Atomically replace the cache entry
|
|
1036
|
+
projectTerminals.set(normalizedPath, freshEntry);
|
|
1037
|
+
// Gate status - builders don't have gate tracking yet in tower
|
|
1038
|
+
// TODO: Add gate status tracking when porch integration is updated
|
|
1039
|
+
const gateStatus = { hasGate: false };
|
|
1040
|
+
return { terminals, gateStatus };
|
|
1041
|
+
}
|
|
1042
|
+
// Resolve once at module load: both symlinked and real temp dir paths
|
|
1043
|
+
const _tmpDir = tmpdir();
|
|
1044
|
+
const _tmpDirResolved = (() => {
|
|
1045
|
+
try {
|
|
1046
|
+
return fs.realpathSync(_tmpDir);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
return _tmpDir;
|
|
1050
|
+
}
|
|
1051
|
+
})();
|
|
1052
|
+
function isTempDirectory(projectPath) {
|
|
1053
|
+
return (projectPath.startsWith(_tmpDir + '/') ||
|
|
1054
|
+
projectPath.startsWith(_tmpDirResolved + '/') ||
|
|
1055
|
+
projectPath.startsWith('/tmp/') ||
|
|
1056
|
+
projectPath.startsWith('/private/tmp/'));
|
|
1057
|
+
}
|
|
100
1058
|
/**
|
|
101
1059
|
* Get all instances with their status
|
|
102
1060
|
*/
|
|
@@ -108,25 +1066,31 @@ async function getInstances() {
|
|
|
108
1066
|
if (allocation.project_path.includes('/.builders/')) {
|
|
109
1067
|
continue;
|
|
110
1068
|
}
|
|
1069
|
+
// Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
|
|
1070
|
+
if (!allocation.project_path.startsWith('remote:')) {
|
|
1071
|
+
if (!fs.existsSync(allocation.project_path)) {
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (isTempDirectory(allocation.project_path)) {
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
111
1078
|
const basePort = allocation.base_port;
|
|
112
1079
|
const dashboardPort = basePort;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
//
|
|
117
|
-
|
|
1080
|
+
// Encode project path for proxy URL
|
|
1081
|
+
const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
|
|
1082
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
1083
|
+
// Get terminals and gate status from tower's registry
|
|
1084
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
|
|
1085
|
+
const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
|
|
1086
|
+
// Project is active if it has any terminals (Phase 4: no port check needed)
|
|
1087
|
+
const isActive = terminals.length > 0;
|
|
118
1088
|
const ports = [
|
|
119
1089
|
{
|
|
120
1090
|
type: 'Dashboard',
|
|
121
1091
|
port: dashboardPort,
|
|
122
|
-
url:
|
|
123
|
-
active:
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
type: 'Architect',
|
|
127
|
-
port: architectPort,
|
|
128
|
-
url: `http://localhost:${architectPort}`,
|
|
129
|
-
active: architectActive,
|
|
1092
|
+
url: proxyUrl, // Use tower proxy URL, not raw localhost
|
|
1093
|
+
active: isActive,
|
|
130
1094
|
},
|
|
131
1095
|
];
|
|
132
1096
|
instances.push({
|
|
@@ -134,11 +1098,15 @@ async function getInstances() {
|
|
|
134
1098
|
projectName: getProjectName(allocation.project_path),
|
|
135
1099
|
basePort,
|
|
136
1100
|
dashboardPort,
|
|
137
|
-
architectPort,
|
|
1101
|
+
architectPort: basePort + 1, // Legacy field for backward compat
|
|
138
1102
|
registered: allocation.registered_at,
|
|
139
1103
|
lastUsed: allocation.last_used_at,
|
|
140
|
-
running:
|
|
1104
|
+
running: isActive,
|
|
1105
|
+
proxyUrl, // Tower proxy URL for dashboard
|
|
1106
|
+
architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
|
|
1107
|
+
terminals, // All available terminals
|
|
141
1108
|
ports,
|
|
1109
|
+
gateStatus,
|
|
142
1110
|
});
|
|
143
1111
|
}
|
|
144
1112
|
// Sort: running first, then by last used (most recent first)
|
|
@@ -164,6 +1132,10 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
164
1132
|
if (inputPath.startsWith('~')) {
|
|
165
1133
|
inputPath = inputPath.replace('~', homedir());
|
|
166
1134
|
}
|
|
1135
|
+
// Relative paths are meaningless for the tower daemon — only absolute paths
|
|
1136
|
+
if (!path.isAbsolute(inputPath)) {
|
|
1137
|
+
return [];
|
|
1138
|
+
}
|
|
167
1139
|
// Determine the directory to list and the prefix to filter by
|
|
168
1140
|
let dirToList;
|
|
169
1141
|
let prefix;
|
|
@@ -213,8 +1185,8 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
213
1185
|
}
|
|
214
1186
|
/**
|
|
215
1187
|
* Launch a new agent-farm instance
|
|
216
|
-
*
|
|
217
|
-
* Auto-adopts non-codev directories
|
|
1188
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
|
|
1189
|
+
* Auto-adopts non-codev directories and creates architect terminal
|
|
218
1190
|
*/
|
|
219
1191
|
async function launchInstance(projectPath) {
|
|
220
1192
|
// Clean up stale port allocations before launching (handles machine restarts)
|
|
@@ -246,74 +1218,122 @@ async function launchInstance(projectPath) {
|
|
|
246
1218
|
return { success: false, error: `Failed to adopt codev: ${err.message}` };
|
|
247
1219
|
}
|
|
248
1220
|
}
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// SECURITY: Use spawn with cwd option to avoid command injection
|
|
252
|
-
// Do NOT use bash -c with string concatenation
|
|
1221
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
1222
|
+
// No dashboard-server spawning - tower handles everything
|
|
253
1223
|
try {
|
|
254
|
-
//
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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)
|
|
1224
|
+
// Clear any stale state file
|
|
1225
|
+
const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
|
|
1226
|
+
if (fs.existsSync(stateFile)) {
|
|
1227
|
+
try {
|
|
1228
|
+
fs.unlinkSync(stateFile);
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
// Ignore - file might not exist or be locked
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
// Ensure project has port allocation
|
|
287
1235
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
288
1236
|
const db = getGlobalDb();
|
|
289
|
-
|
|
1237
|
+
let allocation = db
|
|
290
1238
|
.prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
|
|
291
1239
|
.get(projectPath, resolvedPath);
|
|
292
|
-
if (allocation) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
1240
|
+
if (!allocation) {
|
|
1241
|
+
// Allocate a new port for this project
|
|
1242
|
+
// Find the next available port block (starting at 4200, incrementing by 100)
|
|
1243
|
+
const existingPorts = db
|
|
1244
|
+
.prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
|
|
1245
|
+
.all();
|
|
1246
|
+
let nextPort = 4200;
|
|
1247
|
+
for (const { base_port } of existingPorts) {
|
|
1248
|
+
if (base_port >= nextPort) {
|
|
1249
|
+
nextPort = base_port + 100;
|
|
1250
|
+
}
|
|
303
1251
|
}
|
|
1252
|
+
db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
|
|
1253
|
+
allocation = { base_port: nextPort };
|
|
1254
|
+
log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
|
|
304
1255
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
1256
|
+
// Initialize project terminal entry
|
|
1257
|
+
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
1258
|
+
// Create architect terminal if not already present
|
|
1259
|
+
if (!entry.architect) {
|
|
1260
|
+
const manager = getTerminalManager();
|
|
1261
|
+
// Read af-config.json to get the architect command
|
|
1262
|
+
let architectCmd = 'claude';
|
|
1263
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
1264
|
+
if (fs.existsSync(configPath)) {
|
|
1265
|
+
try {
|
|
1266
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1267
|
+
if (config.shell?.architect) {
|
|
1268
|
+
architectCmd = config.shell.architect;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
// Ignore config read errors, use default
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
try {
|
|
1276
|
+
// Parse command string to separate command and args
|
|
1277
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
1278
|
+
let cmd = cmdParts[0];
|
|
1279
|
+
let cmdArgs = cmdParts.slice(1);
|
|
1280
|
+
// Wrap in tmux for session persistence across Tower restarts
|
|
1281
|
+
const tmuxName = `architect-${path.basename(projectPath)}`;
|
|
1282
|
+
let activeTmuxSession = null;
|
|
1283
|
+
if (tmuxAvailable) {
|
|
1284
|
+
const sanitizedName = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
|
|
1285
|
+
if (sanitizedName) {
|
|
1286
|
+
cmd = 'tmux';
|
|
1287
|
+
cmdArgs = ['attach-session', '-t', sanitizedName];
|
|
1288
|
+
activeTmuxSession = sanitizedName;
|
|
1289
|
+
log('INFO', `Created tmux session "${sanitizedName}" for architect`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
const session = await manager.createSession({
|
|
1293
|
+
command: cmd,
|
|
1294
|
+
args: cmdArgs,
|
|
1295
|
+
cwd: projectPath,
|
|
1296
|
+
label: 'Architect',
|
|
1297
|
+
env: process.env,
|
|
1298
|
+
});
|
|
1299
|
+
entry.architect = session.id;
|
|
1300
|
+
// TICK-001: Save to SQLite for persistence (with tmux session name)
|
|
1301
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
|
|
1302
|
+
// Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
|
|
1303
|
+
const ptySession = manager.getSession(session.id);
|
|
1304
|
+
if (ptySession) {
|
|
1305
|
+
const startedAt = Date.now();
|
|
1306
|
+
ptySession.on('exit', () => {
|
|
1307
|
+
entry.architect = undefined;
|
|
1308
|
+
deleteTerminalSession(session.id);
|
|
1309
|
+
// Kill stale tmux session so restart can create a fresh one
|
|
1310
|
+
if (activeTmuxSession) {
|
|
1311
|
+
try {
|
|
1312
|
+
execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
|
|
1313
|
+
}
|
|
1314
|
+
catch { /* already gone */ }
|
|
1315
|
+
}
|
|
1316
|
+
// Only restart if the architect ran for at least 5s (prevents crash loops)
|
|
1317
|
+
const uptime = Date.now() - startedAt;
|
|
1318
|
+
if (uptime < 5000) {
|
|
1319
|
+
log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
|
|
1323
|
+
setTimeout(() => {
|
|
1324
|
+
launchInstance(projectPath).catch((err) => {
|
|
1325
|
+
log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
|
|
1326
|
+
});
|
|
1327
|
+
}, 2000);
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
1331
|
+
}
|
|
1332
|
+
catch (err) {
|
|
1333
|
+
log('WARN', `Failed to create architect terminal: ${err.message}`);
|
|
1334
|
+
// Don't fail the launch - project is still active, just without architect
|
|
314
1335
|
}
|
|
315
1336
|
}
|
|
316
|
-
child.unref();
|
|
317
1337
|
return { success: true, adopted };
|
|
318
1338
|
}
|
|
319
1339
|
catch (err) {
|
|
@@ -321,40 +1341,69 @@ async function launchInstance(projectPath) {
|
|
|
321
1341
|
}
|
|
322
1342
|
}
|
|
323
1343
|
/**
|
|
324
|
-
*
|
|
1344
|
+
* Stop an agent-farm instance by killing all its terminals
|
|
1345
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
325
1346
|
*/
|
|
326
|
-
function
|
|
1347
|
+
async function stopInstance(projectPath) {
|
|
1348
|
+
const stopped = [];
|
|
1349
|
+
const manager = getTerminalManager();
|
|
1350
|
+
// Resolve symlinks for consistent lookup
|
|
1351
|
+
let resolvedPath = projectPath;
|
|
327
1352
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1353
|
+
if (fs.existsSync(projectPath)) {
|
|
1354
|
+
resolvedPath = fs.realpathSync(projectPath);
|
|
1355
|
+
}
|
|
331
1356
|
}
|
|
332
1357
|
catch {
|
|
333
|
-
|
|
1358
|
+
// Ignore - use original path
|
|
334
1359
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
stopped.push(p);
|
|
1360
|
+
// Get project terminals
|
|
1361
|
+
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
1362
|
+
if (entry) {
|
|
1363
|
+
// Query SQLite for tmux session names BEFORE deleting rows
|
|
1364
|
+
const dbSessions = getTerminalSessionsForProject(resolvedPath);
|
|
1365
|
+
const tmuxSessions = dbSessions
|
|
1366
|
+
.filter(s => s.tmux_session)
|
|
1367
|
+
.map(s => s.tmux_session);
|
|
1368
|
+
// Kill architect
|
|
1369
|
+
if (entry.architect) {
|
|
1370
|
+
const session = manager.getSession(entry.architect);
|
|
1371
|
+
if (session) {
|
|
1372
|
+
manager.killSession(entry.architect);
|
|
1373
|
+
stopped.push(session.pid);
|
|
350
1374
|
}
|
|
351
|
-
|
|
352
|
-
|
|
1375
|
+
}
|
|
1376
|
+
// Kill all shells
|
|
1377
|
+
for (const terminalId of entry.shells.values()) {
|
|
1378
|
+
const session = manager.getSession(terminalId);
|
|
1379
|
+
if (session) {
|
|
1380
|
+
manager.killSession(terminalId);
|
|
1381
|
+
stopped.push(session.pid);
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
// Kill all builders
|
|
1385
|
+
for (const terminalId of entry.builders.values()) {
|
|
1386
|
+
const session = manager.getSession(terminalId);
|
|
1387
|
+
if (session) {
|
|
1388
|
+
manager.killSession(terminalId);
|
|
1389
|
+
stopped.push(session.pid);
|
|
353
1390
|
}
|
|
354
1391
|
}
|
|
1392
|
+
// Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
|
|
1393
|
+
for (const tmuxName of tmuxSessions) {
|
|
1394
|
+
killTmuxSession(tmuxName);
|
|
1395
|
+
}
|
|
1396
|
+
// Clear project from registry
|
|
1397
|
+
projectTerminals.delete(resolvedPath);
|
|
1398
|
+
projectTerminals.delete(projectPath);
|
|
1399
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
1400
|
+
deleteProjectTerminalSessions(resolvedPath);
|
|
1401
|
+
if (resolvedPath !== projectPath) {
|
|
1402
|
+
deleteProjectTerminalSessions(projectPath);
|
|
1403
|
+
}
|
|
355
1404
|
}
|
|
356
1405
|
if (stopped.length === 0) {
|
|
357
|
-
return { success: true, error: 'No
|
|
1406
|
+
return { success: true, error: 'No terminals found to stop', stopped };
|
|
358
1407
|
}
|
|
359
1408
|
return { success: true, stopped };
|
|
360
1409
|
}
|
|
@@ -375,6 +1424,54 @@ function findTemplatePath() {
|
|
|
375
1424
|
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
376
1425
|
// Find template path
|
|
377
1426
|
const templatePath = findTemplatePath();
|
|
1427
|
+
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
1428
|
+
let terminalWss = null;
|
|
1429
|
+
// React dashboard dist path (for serving directly from tower)
|
|
1430
|
+
// React dashboard dist path (for serving directly from tower)
|
|
1431
|
+
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
1432
|
+
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
1433
|
+
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
1434
|
+
if (hasReactDashboard) {
|
|
1435
|
+
log('INFO', `React dashboard found at: ${reactDashboardPath}`);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
log('WARN', 'React dashboard not found - project dashboards will not work');
|
|
1439
|
+
}
|
|
1440
|
+
// MIME types for static file serving
|
|
1441
|
+
const MIME_TYPES = {
|
|
1442
|
+
'.html': 'text/html',
|
|
1443
|
+
'.js': 'application/javascript',
|
|
1444
|
+
'.css': 'text/css',
|
|
1445
|
+
'.json': 'application/json',
|
|
1446
|
+
'.png': 'image/png',
|
|
1447
|
+
'.jpg': 'image/jpeg',
|
|
1448
|
+
'.gif': 'image/gif',
|
|
1449
|
+
'.svg': 'image/svg+xml',
|
|
1450
|
+
'.ico': 'image/x-icon',
|
|
1451
|
+
'.woff': 'font/woff',
|
|
1452
|
+
'.woff2': 'font/woff2',
|
|
1453
|
+
'.ttf': 'font/ttf',
|
|
1454
|
+
'.map': 'application/json',
|
|
1455
|
+
};
|
|
1456
|
+
/**
|
|
1457
|
+
* Serve a static file from the React dashboard dist
|
|
1458
|
+
*/
|
|
1459
|
+
function serveStaticFile(filePath, res) {
|
|
1460
|
+
if (!fs.existsSync(filePath)) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
const ext = path.extname(filePath);
|
|
1464
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
1465
|
+
try {
|
|
1466
|
+
const content = fs.readFileSync(filePath);
|
|
1467
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
1468
|
+
res.end(content);
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
catch {
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
378
1475
|
// Create server
|
|
379
1476
|
const server = http.createServer(async (req, res) => {
|
|
380
1477
|
// Security: Validate Host and Origin headers
|
|
@@ -398,13 +1495,320 @@ const server = http.createServer(async (req, res) => {
|
|
|
398
1495
|
}
|
|
399
1496
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
400
1497
|
try {
|
|
401
|
-
//
|
|
1498
|
+
// =========================================================================
|
|
1499
|
+
// NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
|
|
1500
|
+
// =========================================================================
|
|
1501
|
+
// Health check endpoint (Spec 0090 Phase 1)
|
|
1502
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
1503
|
+
const instances = await getInstances();
|
|
1504
|
+
const activeCount = instances.filter((i) => i.running).length;
|
|
1505
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1506
|
+
res.end(JSON.stringify({
|
|
1507
|
+
status: 'healthy',
|
|
1508
|
+
uptime: process.uptime(),
|
|
1509
|
+
activeProjects: activeCount,
|
|
1510
|
+
totalProjects: instances.length,
|
|
1511
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
1512
|
+
timestamp: new Date().toISOString(),
|
|
1513
|
+
}));
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
// API: List all projects (Spec 0090 Phase 1)
|
|
1517
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
1518
|
+
const instances = await getInstances();
|
|
1519
|
+
const projects = instances.map((i) => ({
|
|
1520
|
+
path: i.projectPath,
|
|
1521
|
+
name: i.projectName,
|
|
1522
|
+
basePort: i.basePort,
|
|
1523
|
+
active: i.running,
|
|
1524
|
+
proxyUrl: i.proxyUrl,
|
|
1525
|
+
terminals: i.terminals.length,
|
|
1526
|
+
lastUsed: i.lastUsed,
|
|
1527
|
+
}));
|
|
1528
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1529
|
+
res.end(JSON.stringify({ projects }));
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
// API: Project-specific endpoints (Spec 0090 Phase 1)
|
|
1533
|
+
// Routes: /api/projects/:encodedPath/activate, /deactivate, /status
|
|
1534
|
+
const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
|
|
1535
|
+
if (projectApiMatch) {
|
|
1536
|
+
const [, encodedPath, action] = projectApiMatch;
|
|
1537
|
+
let projectPath;
|
|
1538
|
+
try {
|
|
1539
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1540
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1541
|
+
throw new Error('Invalid path');
|
|
1542
|
+
}
|
|
1543
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
1544
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
1545
|
+
}
|
|
1546
|
+
catch {
|
|
1547
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1548
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
// GET /api/projects/:path/status
|
|
1552
|
+
if (req.method === 'GET' && action === 'status') {
|
|
1553
|
+
const instances = await getInstances();
|
|
1554
|
+
const instance = instances.find((i) => i.projectPath === projectPath);
|
|
1555
|
+
if (!instance) {
|
|
1556
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1557
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1561
|
+
res.end(JSON.stringify({
|
|
1562
|
+
path: instance.projectPath,
|
|
1563
|
+
name: instance.projectName,
|
|
1564
|
+
active: instance.running,
|
|
1565
|
+
basePort: instance.basePort,
|
|
1566
|
+
terminals: instance.terminals,
|
|
1567
|
+
gateStatus: instance.gateStatus,
|
|
1568
|
+
}));
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
// POST /api/projects/:path/activate
|
|
1572
|
+
if (req.method === 'POST' && action === 'activate') {
|
|
1573
|
+
// Rate limiting: 10 activations per minute per client
|
|
1574
|
+
const clientIp = req.socket.remoteAddress || '127.0.0.1';
|
|
1575
|
+
if (isRateLimited(clientIp)) {
|
|
1576
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1577
|
+
res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const result = await launchInstance(projectPath);
|
|
1581
|
+
if (result.success) {
|
|
1582
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1583
|
+
res.end(JSON.stringify({ success: true, adopted: result.adopted }));
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1587
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
1588
|
+
}
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
// POST /api/projects/:path/deactivate
|
|
1592
|
+
if (req.method === 'POST' && action === 'deactivate') {
|
|
1593
|
+
// Check if project exists in port allocations
|
|
1594
|
+
const allocations = loadPortAllocations();
|
|
1595
|
+
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
1596
|
+
const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
|
|
1597
|
+
if (!allocation) {
|
|
1598
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1599
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
// Phase 4: Stop terminals directly via tower
|
|
1603
|
+
const result = await stopInstance(projectPath);
|
|
1604
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1605
|
+
res.end(JSON.stringify(result));
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
// =========================================================================
|
|
1610
|
+
// TERMINAL API (Phase 2 - Spec 0090)
|
|
1611
|
+
// =========================================================================
|
|
1612
|
+
// POST /api/terminals - Create a new terminal
|
|
1613
|
+
if (req.method === 'POST' && url.pathname === '/api/terminals') {
|
|
1614
|
+
try {
|
|
1615
|
+
const body = await parseJsonBody(req);
|
|
1616
|
+
const manager = getTerminalManager();
|
|
1617
|
+
// Parse request fields
|
|
1618
|
+
let command = typeof body.command === 'string' ? body.command : undefined;
|
|
1619
|
+
let args = Array.isArray(body.args) ? body.args : undefined;
|
|
1620
|
+
const cols = typeof body.cols === 'number' ? body.cols : undefined;
|
|
1621
|
+
const rows = typeof body.rows === 'number' ? body.rows : undefined;
|
|
1622
|
+
const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
|
|
1623
|
+
const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
|
|
1624
|
+
const label = typeof body.label === 'string' ? body.label : undefined;
|
|
1625
|
+
// Optional tmux wrapping: create tmux session, then node-pty attaches to it
|
|
1626
|
+
const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
|
|
1627
|
+
let activeTmuxSession = null;
|
|
1628
|
+
if (tmuxSession && tmuxAvailable && command && cwd) {
|
|
1629
|
+
const sanitizedName = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
|
|
1630
|
+
if (sanitizedName) {
|
|
1631
|
+
// Override: node-pty attaches to the tmux session (use sanitized name)
|
|
1632
|
+
command = 'tmux';
|
|
1633
|
+
args = ['attach-session', '-t', sanitizedName];
|
|
1634
|
+
activeTmuxSession = sanitizedName;
|
|
1635
|
+
log('INFO', `Created tmux session "${sanitizedName}" for terminal`);
|
|
1636
|
+
}
|
|
1637
|
+
// If tmux creation failed, fall through to bare node-pty
|
|
1638
|
+
}
|
|
1639
|
+
let info;
|
|
1640
|
+
try {
|
|
1641
|
+
info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
|
|
1642
|
+
}
|
|
1643
|
+
catch (createErr) {
|
|
1644
|
+
// Clean up orphaned tmux session if node-pty creation failed
|
|
1645
|
+
if (activeTmuxSession) {
|
|
1646
|
+
killTmuxSession(activeTmuxSession);
|
|
1647
|
+
log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
|
|
1648
|
+
}
|
|
1649
|
+
throw createErr;
|
|
1650
|
+
}
|
|
1651
|
+
// Optional project association: register terminal with project state
|
|
1652
|
+
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
1653
|
+
const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
|
|
1654
|
+
const roleId = typeof body.roleId === 'string' ? body.roleId : null;
|
|
1655
|
+
if (projectPath && termType && roleId) {
|
|
1656
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1657
|
+
if (termType === 'builder') {
|
|
1658
|
+
entry.builders.set(roleId, info.id);
|
|
1659
|
+
}
|
|
1660
|
+
else {
|
|
1661
|
+
entry.shells.set(roleId, info.id);
|
|
1662
|
+
}
|
|
1663
|
+
saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
|
|
1664
|
+
log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
|
|
1665
|
+
}
|
|
1666
|
+
// Return tmuxSession so caller knows whether tmux is backing this terminal
|
|
1667
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1668
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1672
|
+
log('ERROR', `Failed to create terminal: ${message}`);
|
|
1673
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1674
|
+
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
|
|
1675
|
+
}
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
// GET /api/terminals - List all terminals
|
|
1679
|
+
if (req.method === 'GET' && url.pathname === '/api/terminals') {
|
|
1680
|
+
const manager = getTerminalManager();
|
|
1681
|
+
const terminals = manager.listSessions();
|
|
1682
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1683
|
+
res.end(JSON.stringify({ terminals }));
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
// Terminal-specific routes: /api/terminals/:id/*
|
|
1687
|
+
const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
|
|
1688
|
+
if (terminalRouteMatch) {
|
|
1689
|
+
const [, terminalId, subpath] = terminalRouteMatch;
|
|
1690
|
+
const manager = getTerminalManager();
|
|
1691
|
+
// GET /api/terminals/:id - Get terminal info
|
|
1692
|
+
if (req.method === 'GET' && (!subpath || subpath === '')) {
|
|
1693
|
+
const session = manager.getSession(terminalId);
|
|
1694
|
+
if (!session) {
|
|
1695
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1696
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1700
|
+
res.end(JSON.stringify(session.info));
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
// DELETE /api/terminals/:id - Kill terminal
|
|
1704
|
+
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1705
|
+
if (!manager.killSession(terminalId)) {
|
|
1706
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1707
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
// TICK-001: Delete from SQLite
|
|
1711
|
+
deleteTerminalSession(terminalId);
|
|
1712
|
+
res.writeHead(204);
|
|
1713
|
+
res.end();
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
// POST /api/terminals/:id/resize - Resize terminal
|
|
1717
|
+
if (req.method === 'POST' && subpath === '/resize') {
|
|
1718
|
+
try {
|
|
1719
|
+
const body = await parseJsonBody(req);
|
|
1720
|
+
if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
|
|
1721
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1722
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const info = manager.resizeSession(terminalId, body.cols, body.rows);
|
|
1726
|
+
if (!info) {
|
|
1727
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1728
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1732
|
+
res.end(JSON.stringify(info));
|
|
1733
|
+
}
|
|
1734
|
+
catch {
|
|
1735
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1736
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1737
|
+
}
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
// GET /api/terminals/:id/output - Get terminal output
|
|
1741
|
+
if (req.method === 'GET' && subpath === '/output') {
|
|
1742
|
+
const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
|
|
1743
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
1744
|
+
const output = manager.getOutput(terminalId, lines, offset);
|
|
1745
|
+
if (!output) {
|
|
1746
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1747
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1751
|
+
res.end(JSON.stringify(output));
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
// =========================================================================
|
|
1756
|
+
// EXISTING API ENDPOINTS
|
|
1757
|
+
// =========================================================================
|
|
1758
|
+
// API: Get status of all instances (legacy - kept for backward compat)
|
|
402
1759
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
403
1760
|
const instances = await getInstances();
|
|
404
1761
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
1762
|
res.end(JSON.stringify({ instances }));
|
|
406
1763
|
return;
|
|
407
1764
|
}
|
|
1765
|
+
// API: Server-Sent Events for push notifications
|
|
1766
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1767
|
+
const clientId = crypto.randomBytes(8).toString('hex');
|
|
1768
|
+
res.writeHead(200, {
|
|
1769
|
+
'Content-Type': 'text/event-stream',
|
|
1770
|
+
'Cache-Control': 'no-cache',
|
|
1771
|
+
Connection: 'keep-alive',
|
|
1772
|
+
});
|
|
1773
|
+
// Send initial connection event
|
|
1774
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
|
|
1775
|
+
const client = { res, id: clientId };
|
|
1776
|
+
sseClients.push(client);
|
|
1777
|
+
log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
|
|
1778
|
+
// Clean up on disconnect
|
|
1779
|
+
req.on('close', () => {
|
|
1780
|
+
const index = sseClients.findIndex((c) => c.id === clientId);
|
|
1781
|
+
if (index !== -1) {
|
|
1782
|
+
sseClients.splice(index, 1);
|
|
1783
|
+
}
|
|
1784
|
+
log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
|
|
1785
|
+
});
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
// API: Receive notification from builder
|
|
1789
|
+
if (req.method === 'POST' && url.pathname === '/api/notify') {
|
|
1790
|
+
const body = await parseJsonBody(req);
|
|
1791
|
+
const type = typeof body.type === 'string' ? body.type : 'info';
|
|
1792
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
1793
|
+
const messageBody = typeof body.body === 'string' ? body.body : '';
|
|
1794
|
+
const project = typeof body.project === 'string' ? body.project : undefined;
|
|
1795
|
+
if (!title || !messageBody) {
|
|
1796
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1797
|
+
res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
// Broadcast to all connected SSE clients
|
|
1801
|
+
broadcastNotification({
|
|
1802
|
+
type,
|
|
1803
|
+
title,
|
|
1804
|
+
body: messageBody,
|
|
1805
|
+
project,
|
|
1806
|
+
});
|
|
1807
|
+
log('INFO', `Notification broadcast: ${title}`);
|
|
1808
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1809
|
+
res.end(JSON.stringify({ success: true }));
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
408
1812
|
// API: Browse directories for autocomplete
|
|
409
1813
|
if (req.method === 'GET' && url.pathname === '/api/browse') {
|
|
410
1814
|
const inputPath = url.searchParams.get('path') || '';
|
|
@@ -488,27 +1892,70 @@ const server = http.createServer(async (req, res) => {
|
|
|
488
1892
|
// API: Launch new instance
|
|
489
1893
|
if (req.method === 'POST' && url.pathname === '/api/launch') {
|
|
490
1894
|
const body = await parseJsonBody(req);
|
|
491
|
-
|
|
1895
|
+
let projectPath = body.projectPath;
|
|
492
1896
|
if (!projectPath) {
|
|
493
1897
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
494
1898
|
res.end(JSON.stringify({ success: false, error: 'Missing projectPath' }));
|
|
495
1899
|
return;
|
|
496
1900
|
}
|
|
1901
|
+
// Expand ~ to home directory
|
|
1902
|
+
if (projectPath.startsWith('~')) {
|
|
1903
|
+
projectPath = projectPath.replace('~', homedir());
|
|
1904
|
+
}
|
|
1905
|
+
// Reject relative paths — tower daemon CWD is unpredictable
|
|
1906
|
+
if (!path.isAbsolute(projectPath)) {
|
|
1907
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1908
|
+
res.end(JSON.stringify({
|
|
1909
|
+
success: false,
|
|
1910
|
+
error: `Relative paths are not supported. Use an absolute path (e.g., /Users/.../project or ~/Development/project).`,
|
|
1911
|
+
}));
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
// Normalize path (resolve .. segments, trailing slashes)
|
|
1915
|
+
projectPath = path.resolve(projectPath);
|
|
497
1916
|
const result = await launchInstance(projectPath);
|
|
498
1917
|
res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
499
1918
|
res.end(JSON.stringify(result));
|
|
500
1919
|
return;
|
|
501
1920
|
}
|
|
1921
|
+
// API: Get tunnel status (cloudflared availability and running tunnel)
|
|
1922
|
+
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1923
|
+
const status = getTunnelStatus();
|
|
1924
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1925
|
+
res.end(JSON.stringify(status));
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
// API: Start cloudflared tunnel
|
|
1929
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
|
|
1930
|
+
const result = await startTunnel(port);
|
|
1931
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1932
|
+
res.end(JSON.stringify(result));
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
// API: Stop cloudflared tunnel
|
|
1936
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
|
|
1937
|
+
const result = stopTunnel();
|
|
1938
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1939
|
+
res.end(JSON.stringify(result));
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
502
1942
|
// API: Stop an instance
|
|
1943
|
+
// Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
|
|
503
1944
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
504
1945
|
const body = await parseJsonBody(req);
|
|
505
|
-
|
|
506
|
-
if
|
|
1946
|
+
let targetPath = body.projectPath;
|
|
1947
|
+
// Backwards compat: if basePort provided, find the project path
|
|
1948
|
+
if (!targetPath && body.basePort) {
|
|
1949
|
+
const allocations = loadPortAllocations();
|
|
1950
|
+
const allocation = allocations.find((a) => a.base_port === body.basePort);
|
|
1951
|
+
targetPath = allocation?.project_path || '';
|
|
1952
|
+
}
|
|
1953
|
+
if (!targetPath) {
|
|
507
1954
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
508
|
-
res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
|
|
1955
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
|
|
509
1956
|
return;
|
|
510
1957
|
}
|
|
511
|
-
const result = await stopInstance(
|
|
1958
|
+
const result = await stopInstance(targetPath);
|
|
512
1959
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
513
1960
|
res.end(JSON.stringify(result));
|
|
514
1961
|
return;
|
|
@@ -531,6 +1978,712 @@ const server = http.createServer(async (req, res) => {
|
|
|
531
1978
|
}
|
|
532
1979
|
return;
|
|
533
1980
|
}
|
|
1981
|
+
// Project routes: /project/:base64urlPath/*
|
|
1982
|
+
// Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
|
|
1983
|
+
// Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
|
|
1984
|
+
if (url.pathname.startsWith('/project/')) {
|
|
1985
|
+
const pathParts = url.pathname.split('/');
|
|
1986
|
+
// ['', 'project', base64urlPath, ...rest]
|
|
1987
|
+
const encodedPath = pathParts[2];
|
|
1988
|
+
const subPath = pathParts.slice(3).join('/');
|
|
1989
|
+
if (!encodedPath) {
|
|
1990
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1991
|
+
res.end('Missing project path');
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
// Decode Base64URL (RFC 4648)
|
|
1995
|
+
let projectPath;
|
|
1996
|
+
try {
|
|
1997
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1998
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
1999
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
2000
|
+
throw new Error('Invalid project path');
|
|
2001
|
+
}
|
|
2002
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
2003
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
2004
|
+
}
|
|
2005
|
+
catch {
|
|
2006
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2007
|
+
res.end('Invalid project path encoding');
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const basePort = await getBasePortForProject(projectPath);
|
|
2011
|
+
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
2012
|
+
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
2013
|
+
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
2014
|
+
// GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
|
|
2015
|
+
if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
|
|
2016
|
+
const relPath = url.searchParams.get('path');
|
|
2017
|
+
const fullPath = path.resolve(projectPath, relPath);
|
|
2018
|
+
// Security: ensure resolved path stays within project directory
|
|
2019
|
+
if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
|
|
2020
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
2021
|
+
res.end('Forbidden');
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
2026
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2027
|
+
res.end(content);
|
|
2028
|
+
}
|
|
2029
|
+
catch {
|
|
2030
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2031
|
+
res.end('Not found');
|
|
2032
|
+
}
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
// Serve React dashboard static files directly if:
|
|
2036
|
+
// 1. Not an API call
|
|
2037
|
+
// 2. Not a WebSocket path
|
|
2038
|
+
// 3. React dashboard is available
|
|
2039
|
+
// 4. Project doesn't need to be running for static files
|
|
2040
|
+
if (!isApiCall && !isWsPath && hasReactDashboard) {
|
|
2041
|
+
// Determine which static file to serve
|
|
2042
|
+
let staticPath;
|
|
2043
|
+
if (!subPath || subPath === '' || subPath === 'index.html') {
|
|
2044
|
+
staticPath = path.join(reactDashboardPath, 'index.html');
|
|
2045
|
+
}
|
|
2046
|
+
else {
|
|
2047
|
+
// Check if it's a static asset
|
|
2048
|
+
staticPath = path.join(reactDashboardPath, subPath);
|
|
2049
|
+
}
|
|
2050
|
+
// Try to serve the static file
|
|
2051
|
+
if (serveStaticFile(staticPath, res)) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
// SPA fallback: serve index.html for client-side routing
|
|
2055
|
+
const indexPath = path.join(reactDashboardPath, 'index.html');
|
|
2056
|
+
if (serveStaticFile(indexPath, res)) {
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
// Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
|
|
2061
|
+
if (isApiCall) {
|
|
2062
|
+
const apiPath = subPath.replace(/^api\/?/, '');
|
|
2063
|
+
// GET /api/state - Return project state (architect, builders, shells)
|
|
2064
|
+
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
2065
|
+
// Refresh cache via getTerminalsForProject (handles SQLite sync,
|
|
2066
|
+
// tmux reconnection, and tmux discovery in one place)
|
|
2067
|
+
const encodedPath = Buffer.from(projectPath).toString('base64url');
|
|
2068
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
2069
|
+
await getTerminalsForProject(projectPath, proxyUrl);
|
|
2070
|
+
// Now read from the refreshed cache
|
|
2071
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2072
|
+
const manager = getTerminalManager();
|
|
2073
|
+
// Build state response compatible with React dashboard
|
|
2074
|
+
const state = {
|
|
2075
|
+
architect: null,
|
|
2076
|
+
builders: [],
|
|
2077
|
+
utils: [],
|
|
2078
|
+
annotations: [],
|
|
2079
|
+
projectName: path.basename(projectPath),
|
|
2080
|
+
};
|
|
2081
|
+
// Add architect if exists
|
|
2082
|
+
if (entry.architect) {
|
|
2083
|
+
const session = manager.getSession(entry.architect);
|
|
2084
|
+
if (session) {
|
|
2085
|
+
state.architect = {
|
|
2086
|
+
port: basePort || 0,
|
|
2087
|
+
pid: session.pid || 0,
|
|
2088
|
+
terminalId: entry.architect,
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
// Add shells from refreshed cache
|
|
2093
|
+
for (const [shellId, terminalId] of entry.shells) {
|
|
2094
|
+
const session = manager.getSession(terminalId);
|
|
2095
|
+
if (session) {
|
|
2096
|
+
state.utils.push({
|
|
2097
|
+
id: shellId,
|
|
2098
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2099
|
+
port: basePort || 0,
|
|
2100
|
+
pid: session.pid || 0,
|
|
2101
|
+
terminalId,
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
// Add builders from refreshed cache
|
|
2106
|
+
for (const [builderId, terminalId] of entry.builders) {
|
|
2107
|
+
const session = manager.getSession(terminalId);
|
|
2108
|
+
if (session) {
|
|
2109
|
+
state.builders.push({
|
|
2110
|
+
id: builderId,
|
|
2111
|
+
name: `Builder ${builderId}`,
|
|
2112
|
+
port: basePort || 0,
|
|
2113
|
+
pid: session.pid || 0,
|
|
2114
|
+
status: 'running',
|
|
2115
|
+
phase: '',
|
|
2116
|
+
worktree: '',
|
|
2117
|
+
branch: '',
|
|
2118
|
+
type: 'spec',
|
|
2119
|
+
terminalId,
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
// Add file tabs (Spec 0092 - served through Tower, no separate ports)
|
|
2124
|
+
for (const [tabId, tab] of entry.fileTabs) {
|
|
2125
|
+
state.annotations.push({
|
|
2126
|
+
id: tabId,
|
|
2127
|
+
file: tab.path,
|
|
2128
|
+
port: 0, // No separate port - served through Tower
|
|
2129
|
+
pid: 0, // No separate process
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2133
|
+
res.end(JSON.stringify(state));
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
// POST /api/tabs/shell - Create a new shell terminal
|
|
2137
|
+
if (req.method === 'POST' && apiPath === 'tabs/shell') {
|
|
2138
|
+
try {
|
|
2139
|
+
const manager = getTerminalManager();
|
|
2140
|
+
const shellId = getNextShellId(projectPath);
|
|
2141
|
+
// Wrap in tmux for session persistence
|
|
2142
|
+
let shellCmd = process.env.SHELL || '/bin/bash';
|
|
2143
|
+
let shellArgs = [];
|
|
2144
|
+
const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
|
|
2145
|
+
let activeTmuxSession = null;
|
|
2146
|
+
if (tmuxAvailable) {
|
|
2147
|
+
const sanitizedName = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
|
|
2148
|
+
if (sanitizedName) {
|
|
2149
|
+
shellCmd = 'tmux';
|
|
2150
|
+
shellArgs = ['attach-session', '-t', sanitizedName];
|
|
2151
|
+
activeTmuxSession = sanitizedName;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
// Create terminal session
|
|
2155
|
+
const session = await manager.createSession({
|
|
2156
|
+
command: shellCmd,
|
|
2157
|
+
args: shellArgs,
|
|
2158
|
+
cwd: projectPath,
|
|
2159
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
2160
|
+
env: process.env,
|
|
2161
|
+
});
|
|
2162
|
+
// Register terminal with project
|
|
2163
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2164
|
+
entry.shells.set(shellId, session.id);
|
|
2165
|
+
// TICK-001: Save to SQLite for persistence
|
|
2166
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
|
|
2167
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2168
|
+
res.end(JSON.stringify({
|
|
2169
|
+
id: shellId,
|
|
2170
|
+
port: basePort || 0,
|
|
2171
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
2172
|
+
terminalId: session.id,
|
|
2173
|
+
}));
|
|
2174
|
+
}
|
|
2175
|
+
catch (err) {
|
|
2176
|
+
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
2177
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2178
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2179
|
+
}
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
// POST /api/tabs/file - Create a file tab (Spec 0092)
|
|
2183
|
+
if (req.method === 'POST' && apiPath === 'tabs/file') {
|
|
2184
|
+
try {
|
|
2185
|
+
const body = await new Promise((resolve) => {
|
|
2186
|
+
let data = '';
|
|
2187
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2188
|
+
req.on('end', () => resolve(data));
|
|
2189
|
+
});
|
|
2190
|
+
const { path: filePath, line } = JSON.parse(body || '{}');
|
|
2191
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
2192
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2193
|
+
res.end(JSON.stringify({ error: 'Missing path parameter' }));
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
// Resolve path relative to project
|
|
2197
|
+
const fullPath = path.isAbsolute(filePath)
|
|
2198
|
+
? filePath
|
|
2199
|
+
: path.join(projectPath, filePath);
|
|
2200
|
+
// Security: ensure path is within project or is absolute path user provided
|
|
2201
|
+
const normalizedFull = path.normalize(fullPath);
|
|
2202
|
+
const normalizedProject = path.normalize(projectPath);
|
|
2203
|
+
if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
|
|
2204
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2205
|
+
res.end(JSON.stringify({ error: 'Path outside project' }));
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
// Check file exists
|
|
2209
|
+
if (!fs.existsSync(fullPath)) {
|
|
2210
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2211
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2215
|
+
// Check if already open
|
|
2216
|
+
for (const [id, tab] of entry.fileTabs) {
|
|
2217
|
+
if (tab.path === fullPath) {
|
|
2218
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2219
|
+
res.end(JSON.stringify({ id, existing: true, line }));
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
// Create new file tab
|
|
2224
|
+
const id = `file-${Date.now().toString(36)}`;
|
|
2225
|
+
entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
|
|
2226
|
+
log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
|
|
2227
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2228
|
+
res.end(JSON.stringify({ id, existing: false, line }));
|
|
2229
|
+
}
|
|
2230
|
+
catch (err) {
|
|
2231
|
+
log('ERROR', `Failed to create file tab: ${err.message}`);
|
|
2232
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2233
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2234
|
+
}
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
// GET /api/file/:id - Get file content as JSON (Spec 0092)
|
|
2238
|
+
const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
|
|
2239
|
+
if (req.method === 'GET' && fileGetMatch) {
|
|
2240
|
+
const tabId = fileGetMatch[1];
|
|
2241
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2242
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2243
|
+
if (!tab) {
|
|
2244
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2245
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2246
|
+
return;
|
|
2247
|
+
}
|
|
2248
|
+
try {
|
|
2249
|
+
const ext = path.extname(tab.path).slice(1).toLowerCase();
|
|
2250
|
+
const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
|
|
2251
|
+
if (isText) {
|
|
2252
|
+
const content = fs.readFileSync(tab.path, 'utf-8');
|
|
2253
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2254
|
+
res.end(JSON.stringify({
|
|
2255
|
+
path: tab.path,
|
|
2256
|
+
name: path.basename(tab.path),
|
|
2257
|
+
content,
|
|
2258
|
+
language: getLanguageForExt(ext),
|
|
2259
|
+
isMarkdown: ext === 'md',
|
|
2260
|
+
isImage: false,
|
|
2261
|
+
isVideo: false,
|
|
2262
|
+
}));
|
|
2263
|
+
}
|
|
2264
|
+
else {
|
|
2265
|
+
// For binary files, just return metadata
|
|
2266
|
+
const stat = fs.statSync(tab.path);
|
|
2267
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2268
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2269
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2270
|
+
res.end(JSON.stringify({
|
|
2271
|
+
path: tab.path,
|
|
2272
|
+
name: path.basename(tab.path),
|
|
2273
|
+
content: null,
|
|
2274
|
+
language: ext,
|
|
2275
|
+
isMarkdown: false,
|
|
2276
|
+
isImage,
|
|
2277
|
+
isVideo,
|
|
2278
|
+
size: stat.size,
|
|
2279
|
+
}));
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
catch (err) {
|
|
2283
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2284
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2285
|
+
}
|
|
2286
|
+
return;
|
|
2287
|
+
}
|
|
2288
|
+
// GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
|
|
2289
|
+
const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
|
|
2290
|
+
if (req.method === 'GET' && fileRawMatch) {
|
|
2291
|
+
const tabId = fileRawMatch[1];
|
|
2292
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2293
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2294
|
+
if (!tab) {
|
|
2295
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2296
|
+
res.end('File tab not found');
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
try {
|
|
2300
|
+
const data = fs.readFileSync(tab.path);
|
|
2301
|
+
const mimeType = getMimeTypeForFile(tab.path);
|
|
2302
|
+
res.writeHead(200, {
|
|
2303
|
+
'Content-Type': mimeType,
|
|
2304
|
+
'Content-Length': data.length,
|
|
2305
|
+
'Cache-Control': 'no-cache',
|
|
2306
|
+
});
|
|
2307
|
+
res.end(data);
|
|
2308
|
+
}
|
|
2309
|
+
catch (err) {
|
|
2310
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2311
|
+
res.end(err.message);
|
|
2312
|
+
}
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
// POST /api/file/:id/save - Save file content (Spec 0092)
|
|
2316
|
+
const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
|
|
2317
|
+
if (req.method === 'POST' && fileSaveMatch) {
|
|
2318
|
+
const tabId = fileSaveMatch[1];
|
|
2319
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2320
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2321
|
+
if (!tab) {
|
|
2322
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2323
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2326
|
+
try {
|
|
2327
|
+
const body = await new Promise((resolve) => {
|
|
2328
|
+
let data = '';
|
|
2329
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2330
|
+
req.on('end', () => resolve(data));
|
|
2331
|
+
});
|
|
2332
|
+
const { content } = JSON.parse(body || '{}');
|
|
2333
|
+
if (typeof content !== 'string') {
|
|
2334
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2335
|
+
res.end(JSON.stringify({ error: 'Missing content parameter' }));
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
fs.writeFileSync(tab.path, content, 'utf-8');
|
|
2339
|
+
log('INFO', `Saved file: ${tab.path}`);
|
|
2340
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2341
|
+
res.end(JSON.stringify({ success: true }));
|
|
2342
|
+
}
|
|
2343
|
+
catch (err) {
|
|
2344
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2345
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2346
|
+
}
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
// DELETE /api/tabs/:id - Delete a terminal or file tab
|
|
2350
|
+
const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
|
|
2351
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
2352
|
+
const tabId = deleteMatch[1];
|
|
2353
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2354
|
+
const manager = getTerminalManager();
|
|
2355
|
+
// Check if it's a file tab first (Spec 0092)
|
|
2356
|
+
if (tabId.startsWith('file-')) {
|
|
2357
|
+
if (entry.fileTabs.has(tabId)) {
|
|
2358
|
+
entry.fileTabs.delete(tabId);
|
|
2359
|
+
log('INFO', `Deleted file tab: ${tabId}`);
|
|
2360
|
+
res.writeHead(204);
|
|
2361
|
+
res.end();
|
|
2362
|
+
}
|
|
2363
|
+
else {
|
|
2364
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2365
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2366
|
+
}
|
|
2367
|
+
return;
|
|
2368
|
+
}
|
|
2369
|
+
// Find and delete the terminal
|
|
2370
|
+
let terminalId;
|
|
2371
|
+
if (tabId.startsWith('shell-')) {
|
|
2372
|
+
terminalId = entry.shells.get(tabId);
|
|
2373
|
+
if (terminalId) {
|
|
2374
|
+
entry.shells.delete(tabId);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
else if (tabId.startsWith('builder-')) {
|
|
2378
|
+
terminalId = entry.builders.get(tabId);
|
|
2379
|
+
if (terminalId) {
|
|
2380
|
+
entry.builders.delete(tabId);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
else if (tabId === 'architect') {
|
|
2384
|
+
terminalId = entry.architect;
|
|
2385
|
+
if (terminalId) {
|
|
2386
|
+
entry.architect = undefined;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
if (terminalId) {
|
|
2390
|
+
manager.killSession(terminalId);
|
|
2391
|
+
// TICK-001: Delete from SQLite
|
|
2392
|
+
deleteTerminalSession(terminalId);
|
|
2393
|
+
res.writeHead(204);
|
|
2394
|
+
res.end();
|
|
2395
|
+
}
|
|
2396
|
+
else {
|
|
2397
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2398
|
+
res.end(JSON.stringify({ error: 'Tab not found' }));
|
|
2399
|
+
}
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
// POST /api/stop - Stop all terminals for project
|
|
2403
|
+
if (req.method === 'POST' && apiPath === 'stop') {
|
|
2404
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2405
|
+
const manager = getTerminalManager();
|
|
2406
|
+
// Kill all terminals
|
|
2407
|
+
if (entry.architect) {
|
|
2408
|
+
manager.killSession(entry.architect);
|
|
2409
|
+
}
|
|
2410
|
+
for (const terminalId of entry.shells.values()) {
|
|
2411
|
+
manager.killSession(terminalId);
|
|
2412
|
+
}
|
|
2413
|
+
for (const terminalId of entry.builders.values()) {
|
|
2414
|
+
manager.killSession(terminalId);
|
|
2415
|
+
}
|
|
2416
|
+
// Clear registry
|
|
2417
|
+
projectTerminals.delete(projectPath);
|
|
2418
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
2419
|
+
deleteProjectTerminalSessions(projectPath);
|
|
2420
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2421
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
// GET /api/files - Return project directory tree for file browser (Spec 0092)
|
|
2425
|
+
if (req.method === 'GET' && apiPath === 'files') {
|
|
2426
|
+
const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
|
|
2427
|
+
const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
|
|
2428
|
+
function readTree(dir, depth) {
|
|
2429
|
+
if (depth <= 0)
|
|
2430
|
+
return [];
|
|
2431
|
+
try {
|
|
2432
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2433
|
+
return entries
|
|
2434
|
+
.filter(e => !e.name.startsWith('.') || e.name === '.env.example')
|
|
2435
|
+
.filter(e => !ignore.has(e.name))
|
|
2436
|
+
.sort((a, b) => {
|
|
2437
|
+
// Directories first, then alphabetical
|
|
2438
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
2439
|
+
return -1;
|
|
2440
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
2441
|
+
return 1;
|
|
2442
|
+
return a.name.localeCompare(b.name);
|
|
2443
|
+
})
|
|
2444
|
+
.map(e => {
|
|
2445
|
+
const fullPath = path.join(dir, e.name);
|
|
2446
|
+
const relativePath = path.relative(projectPath, fullPath);
|
|
2447
|
+
if (e.isDirectory()) {
|
|
2448
|
+
return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
|
|
2449
|
+
}
|
|
2450
|
+
return { name: e.name, path: relativePath, type: 'file' };
|
|
2451
|
+
});
|
|
2452
|
+
}
|
|
2453
|
+
catch {
|
|
2454
|
+
return [];
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
const tree = readTree(projectPath, maxDepth);
|
|
2458
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2459
|
+
res.end(JSON.stringify(tree));
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
// GET /api/git/status - Return git status for file browser (Spec 0092)
|
|
2463
|
+
if (req.method === 'GET' && apiPath === 'git/status') {
|
|
2464
|
+
try {
|
|
2465
|
+
// Get git status in porcelain format for parsing
|
|
2466
|
+
const result = execSync('git status --porcelain', {
|
|
2467
|
+
cwd: projectPath,
|
|
2468
|
+
encoding: 'utf-8',
|
|
2469
|
+
timeout: 5000,
|
|
2470
|
+
});
|
|
2471
|
+
// Parse porcelain output: XY filename
|
|
2472
|
+
// X = staging area status, Y = working tree status
|
|
2473
|
+
const modified = [];
|
|
2474
|
+
const staged = [];
|
|
2475
|
+
const untracked = [];
|
|
2476
|
+
for (const line of result.split('\n')) {
|
|
2477
|
+
if (!line)
|
|
2478
|
+
continue;
|
|
2479
|
+
const x = line[0]; // staging area
|
|
2480
|
+
const y = line[1]; // working tree
|
|
2481
|
+
const filepath = line.slice(3);
|
|
2482
|
+
if (x === '?' && y === '?') {
|
|
2483
|
+
untracked.push(filepath);
|
|
2484
|
+
}
|
|
2485
|
+
else {
|
|
2486
|
+
if (x !== ' ' && x !== '?') {
|
|
2487
|
+
staged.push(filepath);
|
|
2488
|
+
}
|
|
2489
|
+
if (y !== ' ' && y !== '?') {
|
|
2490
|
+
modified.push(filepath);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2495
|
+
res.end(JSON.stringify({ modified, staged, untracked }));
|
|
2496
|
+
}
|
|
2497
|
+
catch (err) {
|
|
2498
|
+
// Not a git repo or git command failed
|
|
2499
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2500
|
+
res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
|
|
2501
|
+
}
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
// GET /api/files/recent - Return recently opened file tabs (Spec 0092)
|
|
2505
|
+
if (req.method === 'GET' && apiPath === 'files/recent') {
|
|
2506
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2507
|
+
// Get all file tabs sorted by creation time (most recent first)
|
|
2508
|
+
const recentFiles = Array.from(entry.fileTabs.values())
|
|
2509
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
2510
|
+
.slice(0, 10) // Limit to 10 most recent
|
|
2511
|
+
.map(tab => ({
|
|
2512
|
+
id: tab.id,
|
|
2513
|
+
path: tab.path,
|
|
2514
|
+
name: path.basename(tab.path),
|
|
2515
|
+
relativePath: path.relative(projectPath, tab.path),
|
|
2516
|
+
}));
|
|
2517
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2518
|
+
res.end(JSON.stringify(recentFiles));
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
// GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
|
|
2522
|
+
const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
|
|
2523
|
+
if (annotateMatch) {
|
|
2524
|
+
const tabId = annotateMatch[1];
|
|
2525
|
+
const subRoute = annotateMatch[3] || '';
|
|
2526
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2527
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2528
|
+
if (!tab) {
|
|
2529
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2530
|
+
res.end('File tab not found');
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
const filePath = tab.path;
|
|
2534
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
2535
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2536
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2537
|
+
const is3D = ['stl', '3mf'].includes(ext);
|
|
2538
|
+
const isPdf = ext === 'pdf';
|
|
2539
|
+
const isMarkdown = ext === 'md';
|
|
2540
|
+
// Sub-route: GET /file — re-read file content from disk
|
|
2541
|
+
if (req.method === 'GET' && subRoute === 'file') {
|
|
2542
|
+
try {
|
|
2543
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2544
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2545
|
+
res.end(content);
|
|
2546
|
+
}
|
|
2547
|
+
catch (err) {
|
|
2548
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2549
|
+
res.end(err.message);
|
|
2550
|
+
}
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
// Sub-route: POST /save — save file content
|
|
2554
|
+
if (req.method === 'POST' && subRoute === 'save') {
|
|
2555
|
+
try {
|
|
2556
|
+
const body = await new Promise((resolve) => {
|
|
2557
|
+
let data = '';
|
|
2558
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2559
|
+
req.on('end', () => resolve(data));
|
|
2560
|
+
});
|
|
2561
|
+
const parsed = JSON.parse(body || '{}');
|
|
2562
|
+
const fileContent = parsed.content;
|
|
2563
|
+
if (typeof fileContent !== 'string') {
|
|
2564
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2565
|
+
res.end('Missing content');
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
fs.writeFileSync(filePath, fileContent, 'utf-8');
|
|
2569
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2570
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2571
|
+
}
|
|
2572
|
+
catch (err) {
|
|
2573
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2574
|
+
res.end(err.message);
|
|
2575
|
+
}
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
// Sub-route: GET /api/mtime — file modification time
|
|
2579
|
+
if (req.method === 'GET' && subRoute === 'api/mtime') {
|
|
2580
|
+
try {
|
|
2581
|
+
const stat = fs.statSync(filePath);
|
|
2582
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2583
|
+
res.end(JSON.stringify({ mtime: stat.mtimeMs }));
|
|
2584
|
+
}
|
|
2585
|
+
catch (err) {
|
|
2586
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2587
|
+
res.end(err.message);
|
|
2588
|
+
}
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
// Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
|
|
2592
|
+
if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
|
|
2593
|
+
try {
|
|
2594
|
+
const data = fs.readFileSync(filePath);
|
|
2595
|
+
const mimeType = getMimeTypeForFile(filePath);
|
|
2596
|
+
res.writeHead(200, {
|
|
2597
|
+
'Content-Type': mimeType,
|
|
2598
|
+
'Content-Length': data.length,
|
|
2599
|
+
'Cache-Control': 'no-cache',
|
|
2600
|
+
});
|
|
2601
|
+
res.end(data);
|
|
2602
|
+
}
|
|
2603
|
+
catch (err) {
|
|
2604
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2605
|
+
res.end(err.message);
|
|
2606
|
+
}
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
// Default: serve the annotator HTML template
|
|
2610
|
+
if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
|
|
2611
|
+
try {
|
|
2612
|
+
const templateFile = is3D ? '3d-viewer.html' : 'open.html';
|
|
2613
|
+
const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
|
|
2614
|
+
let html = fs.readFileSync(tplPath, 'utf-8');
|
|
2615
|
+
const fileName = path.basename(filePath);
|
|
2616
|
+
const fileSize = fs.statSync(filePath).size;
|
|
2617
|
+
if (is3D) {
|
|
2618
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2619
|
+
html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
|
|
2620
|
+
html = html.replace(/\{\{FORMAT\}\}/g, ext);
|
|
2621
|
+
}
|
|
2622
|
+
else {
|
|
2623
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2624
|
+
html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
|
|
2625
|
+
html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
|
|
2626
|
+
html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
|
|
2627
|
+
html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
|
|
2628
|
+
html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
|
|
2629
|
+
html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
|
|
2630
|
+
html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
|
|
2631
|
+
html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
|
|
2632
|
+
// Inject initialization script (template loads content via fetch)
|
|
2633
|
+
let initScript;
|
|
2634
|
+
if (isImage) {
|
|
2635
|
+
initScript = `initImage(${fileSize});`;
|
|
2636
|
+
}
|
|
2637
|
+
else if (isVideo) {
|
|
2638
|
+
initScript = `initVideo(${fileSize});`;
|
|
2639
|
+
}
|
|
2640
|
+
else if (isPdf) {
|
|
2641
|
+
initScript = `initPdf(${fileSize});`;
|
|
2642
|
+
}
|
|
2643
|
+
else {
|
|
2644
|
+
initScript = `fetch('file').then(r=>r.text()).then(init);`;
|
|
2645
|
+
}
|
|
2646
|
+
html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
|
|
2647
|
+
}
|
|
2648
|
+
// Handle ?line= query param for scroll-to-line
|
|
2649
|
+
const lineParam = url.searchParams.get('line');
|
|
2650
|
+
if (lineParam) {
|
|
2651
|
+
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>`;
|
|
2652
|
+
html = html.replace('</body>', `${scrollScript}</body>`);
|
|
2653
|
+
}
|
|
2654
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2655
|
+
res.end(html);
|
|
2656
|
+
}
|
|
2657
|
+
catch (err) {
|
|
2658
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2659
|
+
res.end(`Failed to serve annotator: ${err.message}`);
|
|
2660
|
+
}
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
// Unhandled API route
|
|
2665
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2666
|
+
res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
|
|
2667
|
+
return;
|
|
2668
|
+
}
|
|
2669
|
+
// For WebSocket paths, let the upgrade handler deal with it
|
|
2670
|
+
if (isWsPath) {
|
|
2671
|
+
// WebSocket paths are handled by the upgrade handler
|
|
2672
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2673
|
+
res.end('WebSocket connections should use ws:// protocol');
|
|
2674
|
+
return;
|
|
2675
|
+
}
|
|
2676
|
+
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
2677
|
+
if (!hasReactDashboard) {
|
|
2678
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2679
|
+
res.end('Dashboard not available');
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
// Fallback for unmatched paths
|
|
2683
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2684
|
+
res.end('Not found');
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
534
2687
|
// 404 for everything else
|
|
535
2688
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
536
2689
|
res.end('Not found');
|
|
@@ -542,8 +2695,86 @@ const server = http.createServer(async (req, res) => {
|
|
|
542
2695
|
}
|
|
543
2696
|
});
|
|
544
2697
|
// SECURITY: Bind to localhost only to prevent network exposure
|
|
545
|
-
server.listen(port, '127.0.0.1', () => {
|
|
2698
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
546
2699
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
2700
|
+
// Check tmux availability once at startup
|
|
2701
|
+
tmuxAvailable = checkTmux();
|
|
2702
|
+
log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
|
|
2703
|
+
// TICK-001: Reconcile terminal sessions from previous run
|
|
2704
|
+
await reconcileTerminalSessions();
|
|
2705
|
+
});
|
|
2706
|
+
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
2707
|
+
terminalWss = new WebSocketServer({ noServer: true });
|
|
2708
|
+
// WebSocket upgrade handler for terminal connections and proxying
|
|
2709
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
2710
|
+
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
2711
|
+
// Phase 2: Handle /ws/terminal/:id routes directly
|
|
2712
|
+
const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
2713
|
+
if (terminalMatch) {
|
|
2714
|
+
const terminalId = terminalMatch[1];
|
|
2715
|
+
const manager = getTerminalManager();
|
|
2716
|
+
const session = manager.getSession(terminalId);
|
|
2717
|
+
if (!session) {
|
|
2718
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2719
|
+
socket.destroy();
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2723
|
+
handleTerminalWebSocket(ws, session, req);
|
|
2724
|
+
});
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
// Phase 4 (Spec 0090): Handle project WebSocket routes directly
|
|
2728
|
+
// Route: /project/:encodedPath/ws/terminal/:terminalId
|
|
2729
|
+
if (!reqUrl.pathname.startsWith('/project/')) {
|
|
2730
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2731
|
+
socket.destroy();
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
const pathParts = reqUrl.pathname.split('/');
|
|
2735
|
+
// ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
|
|
2736
|
+
const encodedPath = pathParts[2];
|
|
2737
|
+
if (!encodedPath) {
|
|
2738
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2739
|
+
socket.destroy();
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
// Decode Base64URL (RFC 4648) - NOT URL encoding
|
|
2743
|
+
// Wrap in try/catch to handle malformed Base64 input gracefully
|
|
2744
|
+
let projectPath;
|
|
2745
|
+
try {
|
|
2746
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
2747
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
2748
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
2749
|
+
throw new Error('Invalid project path');
|
|
2750
|
+
}
|
|
2751
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
2752
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
2753
|
+
}
|
|
2754
|
+
catch {
|
|
2755
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2756
|
+
socket.destroy();
|
|
2757
|
+
return;
|
|
2758
|
+
}
|
|
2759
|
+
// Check for terminal WebSocket route: /project/:path/ws/terminal/:id
|
|
2760
|
+
const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
|
|
2761
|
+
if (wsMatch) {
|
|
2762
|
+
const terminalId = wsMatch[1];
|
|
2763
|
+
const manager = getTerminalManager();
|
|
2764
|
+
const session = manager.getSession(terminalId);
|
|
2765
|
+
if (!session) {
|
|
2766
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2767
|
+
socket.destroy();
|
|
2768
|
+
return;
|
|
2769
|
+
}
|
|
2770
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2771
|
+
handleTerminalWebSocket(ws, session, req);
|
|
2772
|
+
});
|
|
2773
|
+
return;
|
|
2774
|
+
}
|
|
2775
|
+
// Unhandled WebSocket route
|
|
2776
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2777
|
+
socket.destroy();
|
|
547
2778
|
});
|
|
548
2779
|
// Handle uncaught errors
|
|
549
2780
|
process.on('uncaughtException', (err) => {
|