@cluesmith/codev 2.0.0-rc.6 → 2.0.0-rc.60
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 +37 -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/{util.d.ts → shell.d.ts} +5 -5
- package/dist/agent-farm/commands/shell.d.ts.map +1 -0
- package/dist/agent-farm/commands/{util.js → shell.js} +23 -36
- 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 +55 -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 +2137 -112
- 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 +7 -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 +1 -0
- package/dist/commands/consult/index.d.ts.map +1 -1
- package/dist/commands/consult/index.js +85 -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 +479 -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/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.map +0 -1
- 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,464 @@ 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
|
+
* Create a tmux session with the given command.
|
|
223
|
+
* Returns true if created successfully, false on failure.
|
|
224
|
+
*/
|
|
225
|
+
function createTmuxSession(sessionName, command, args, cwd, cols, rows) {
|
|
226
|
+
// Kill any stale session with this name
|
|
227
|
+
if (tmuxSessionExists(sessionName)) {
|
|
228
|
+
killTmuxSession(sessionName);
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
// Use spawnSync with array args to avoid shell injection via project paths
|
|
232
|
+
const tmuxArgs = [
|
|
233
|
+
'new-session', '-d',
|
|
234
|
+
'-s', sessionName,
|
|
235
|
+
'-c', cwd,
|
|
236
|
+
'-x', String(cols),
|
|
237
|
+
'-y', String(rows),
|
|
238
|
+
command, ...args,
|
|
239
|
+
];
|
|
240
|
+
const result = spawnSync('tmux', tmuxArgs, { stdio: 'ignore' });
|
|
241
|
+
if (result.status !== 0) {
|
|
242
|
+
log('WARN', `tmux new-session exited with code ${result.status} for "${sessionName}"`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
// Hide tmux status bar (dashboard has its own tabs), enable mouse, and
|
|
246
|
+
// use aggressive-resize so tmux sizes to the largest client (not smallest)
|
|
247
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'status', 'off'], { stdio: 'ignore' });
|
|
248
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'mouse', 'on'], { stdio: 'ignore' });
|
|
249
|
+
spawnSync('tmux', ['set-option', '-t', sessionName, 'aggressive-resize', 'on'], { stdio: 'ignore' });
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
log('WARN', `Failed to create tmux session "${sessionName}": ${err.message}`);
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Check if a tmux session exists
|
|
259
|
+
*/
|
|
260
|
+
function tmuxSessionExists(sessionName) {
|
|
261
|
+
try {
|
|
262
|
+
execSync(`tmux has-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Check if a process is running
|
|
271
|
+
*/
|
|
272
|
+
function processExists(pid) {
|
|
273
|
+
try {
|
|
274
|
+
process.kill(pid, 0);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Kill a tmux session by name
|
|
283
|
+
*/
|
|
284
|
+
function killTmuxSession(sessionName) {
|
|
285
|
+
try {
|
|
286
|
+
execSync(`tmux kill-session -t "${sessionName}" 2>/dev/null`, { stdio: 'ignore' });
|
|
287
|
+
log('INFO', `Killed orphaned tmux session: ${sessionName}`);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Session may have already died
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Reconcile terminal sessions from SQLite against reality on startup.
|
|
295
|
+
*
|
|
296
|
+
* For sessions with surviving tmux sessions: re-attach via new node-pty,
|
|
297
|
+
* register in projectTerminals, and update SQLite with new terminal ID.
|
|
298
|
+
* For dead sessions: clean up SQLite rows and kill orphaned processes.
|
|
299
|
+
*/
|
|
300
|
+
async function reconcileTerminalSessions() {
|
|
301
|
+
const db = getGlobalDb();
|
|
302
|
+
let sessions;
|
|
303
|
+
try {
|
|
304
|
+
sessions = db.prepare('SELECT * FROM terminal_sessions').all();
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
log('WARN', `Failed to read terminal sessions for reconciliation: ${err.message}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (sessions.length === 0) {
|
|
311
|
+
log('INFO', 'No terminal sessions to reconcile');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
log('INFO', `Reconciling ${sessions.length} terminal sessions from previous run...`);
|
|
315
|
+
const manager = getTerminalManager();
|
|
316
|
+
let reconnected = 0;
|
|
317
|
+
let killed = 0;
|
|
318
|
+
let cleaned = 0;
|
|
319
|
+
for (const session of sessions) {
|
|
320
|
+
// Can we reconnect to a surviving tmux session?
|
|
321
|
+
if (session.tmux_session && tmuxAvailable && tmuxSessionExists(session.tmux_session)) {
|
|
322
|
+
try {
|
|
323
|
+
// Create new node-pty that attaches to the surviving tmux session
|
|
324
|
+
const newSession = await manager.createSession({
|
|
325
|
+
command: 'tmux',
|
|
326
|
+
args: ['attach-session', '-t', session.tmux_session],
|
|
327
|
+
cwd: session.project_path,
|
|
328
|
+
label: session.type === 'architect' ? 'Architect' : `${session.type} ${session.role_id || session.id}`,
|
|
329
|
+
});
|
|
330
|
+
// Register in projectTerminals Map
|
|
331
|
+
const entry = getProjectTerminalsEntry(session.project_path);
|
|
332
|
+
if (session.type === 'architect') {
|
|
333
|
+
entry.architect = newSession.id;
|
|
334
|
+
}
|
|
335
|
+
else if (session.type === 'builder') {
|
|
336
|
+
const builderId = session.role_id || session.id;
|
|
337
|
+
entry.builders.set(builderId, newSession.id);
|
|
338
|
+
}
|
|
339
|
+
else if (session.type === 'shell') {
|
|
340
|
+
const shellId = session.role_id || session.id;
|
|
341
|
+
entry.shells.set(shellId, newSession.id);
|
|
342
|
+
}
|
|
343
|
+
// Update SQLite: delete old row, insert new with new terminal ID
|
|
344
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
|
|
345
|
+
saveTerminalSession(newSession.id, session.project_path, session.type, session.role_id, newSession.pid, session.tmux_session);
|
|
346
|
+
log('INFO', `Reconnected to tmux session "${session.tmux_session}" → terminal ${newSession.id} (${session.type} for ${path.basename(session.project_path)})`);
|
|
347
|
+
reconnected++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
log('WARN', `Failed to reconnect to tmux session "${session.tmux_session}": ${err.message}`);
|
|
352
|
+
// Fall through to cleanup
|
|
353
|
+
killTmuxSession(session.tmux_session);
|
|
354
|
+
killed++;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// No tmux or tmux session dead — check for orphaned processes
|
|
358
|
+
else if (session.tmux_session && tmuxSessionExists(session.tmux_session)) {
|
|
359
|
+
// tmux exists but tmuxAvailable is false (shouldn't happen, but be safe)
|
|
360
|
+
killTmuxSession(session.tmux_session);
|
|
361
|
+
killed++;
|
|
362
|
+
}
|
|
363
|
+
else if (session.pid && processExists(session.pid)) {
|
|
364
|
+
log('INFO', `Found orphaned process: PID ${session.pid} (${session.type} for ${path.basename(session.project_path)})`);
|
|
365
|
+
try {
|
|
366
|
+
process.kill(session.pid, 'SIGTERM');
|
|
367
|
+
killed++;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// Process may not be killable (different user, etc)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Clean up the DB row for sessions we couldn't reconnect
|
|
374
|
+
db.prepare('DELETE FROM terminal_sessions WHERE id = ?').run(session.id);
|
|
375
|
+
cleaned++;
|
|
376
|
+
}
|
|
377
|
+
log('INFO', `Reconciliation complete: ${reconnected} reconnected, ${killed} orphaned killed, ${cleaned} cleaned up`);
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Get terminal sessions from SQLite for a project.
|
|
381
|
+
* Normalizes path for consistent lookup.
|
|
382
|
+
*/
|
|
383
|
+
function getTerminalSessionsForProject(projectPath) {
|
|
384
|
+
try {
|
|
385
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
386
|
+
const db = getGlobalDb();
|
|
387
|
+
return db.prepare('SELECT * FROM terminal_sessions WHERE project_path = ?').all(normalizedPath);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Handle WebSocket connection to a terminal session
|
|
395
|
+
* Uses hybrid binary protocol (Spec 0085):
|
|
396
|
+
* - 0x00 prefix: Control frame (JSON)
|
|
397
|
+
* - 0x01 prefix: Data frame (raw PTY bytes)
|
|
398
|
+
*/
|
|
399
|
+
function handleTerminalWebSocket(ws, session, req) {
|
|
400
|
+
const resumeSeq = req.headers['x-session-resume'];
|
|
401
|
+
// Create a client adapter for the PTY session
|
|
402
|
+
// Uses binary protocol for data frames
|
|
403
|
+
const client = {
|
|
404
|
+
send: (data) => {
|
|
405
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
406
|
+
// Encode as binary data frame (0x01 prefix)
|
|
407
|
+
ws.send(encodeData(data));
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
// Attach client to session and get replay data
|
|
412
|
+
let replayLines;
|
|
413
|
+
if (resumeSeq && typeof resumeSeq === 'string') {
|
|
414
|
+
replayLines = session.attachResume(client, parseInt(resumeSeq, 10));
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
replayLines = session.attach(client);
|
|
418
|
+
}
|
|
419
|
+
// Send replay data as binary data frame
|
|
420
|
+
if (replayLines.length > 0) {
|
|
421
|
+
const replayData = replayLines.join('\n');
|
|
422
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
423
|
+
ws.send(encodeData(replayData));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// Handle incoming messages from client (binary protocol)
|
|
427
|
+
ws.on('message', (rawData) => {
|
|
428
|
+
try {
|
|
429
|
+
const frame = decodeFrame(Buffer.from(rawData));
|
|
430
|
+
if (frame.type === 'data') {
|
|
431
|
+
// Write raw input to terminal
|
|
432
|
+
session.write(frame.data.toString('utf-8'));
|
|
433
|
+
}
|
|
434
|
+
else if (frame.type === 'control') {
|
|
435
|
+
// Handle control messages
|
|
436
|
+
const msg = frame.message;
|
|
437
|
+
if (msg.type === 'resize') {
|
|
438
|
+
const cols = msg.payload.cols;
|
|
439
|
+
const rows = msg.payload.rows;
|
|
440
|
+
if (typeof cols === 'number' && typeof rows === 'number') {
|
|
441
|
+
session.resize(cols, rows);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else if (msg.type === 'ping') {
|
|
445
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
446
|
+
ws.send(encodeControl({ type: 'pong', payload: {} }));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// If decode fails, try treating as raw UTF-8 input (for simpler clients)
|
|
453
|
+
try {
|
|
454
|
+
session.write(rawData.toString('utf-8'));
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// Ignore malformed input
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
ws.on('close', () => {
|
|
462
|
+
session.detach(client);
|
|
463
|
+
});
|
|
464
|
+
ws.on('error', () => {
|
|
465
|
+
session.detach(client);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
21
468
|
// Parse arguments with Commander
|
|
22
469
|
const program = new Command()
|
|
23
470
|
.name('tower-server')
|
|
@@ -52,6 +499,41 @@ function log(level, message) {
|
|
|
52
499
|
}
|
|
53
500
|
}
|
|
54
501
|
}
|
|
502
|
+
// Global exception handlers to catch uncaught errors
|
|
503
|
+
process.on('uncaughtException', (err) => {
|
|
504
|
+
log('ERROR', `Uncaught exception: ${err.message}\n${err.stack}`);
|
|
505
|
+
process.exit(1);
|
|
506
|
+
});
|
|
507
|
+
process.on('unhandledRejection', (reason) => {
|
|
508
|
+
const message = reason instanceof Error ? `${reason.message}\n${reason.stack}` : String(reason);
|
|
509
|
+
log('ERROR', `Unhandled rejection: ${message}`);
|
|
510
|
+
process.exit(1);
|
|
511
|
+
});
|
|
512
|
+
// Graceful shutdown handler (Phase 2 - Spec 0090)
|
|
513
|
+
async function gracefulShutdown(signal) {
|
|
514
|
+
log('INFO', `Received ${signal}, starting graceful shutdown...`);
|
|
515
|
+
// 1. Stop accepting new connections
|
|
516
|
+
server?.close();
|
|
517
|
+
// 2. Close all WebSocket connections
|
|
518
|
+
if (terminalWss) {
|
|
519
|
+
for (const client of terminalWss.clients) {
|
|
520
|
+
client.close(1001, 'Server shutting down');
|
|
521
|
+
}
|
|
522
|
+
terminalWss.close();
|
|
523
|
+
}
|
|
524
|
+
// 3. Kill all PTY sessions
|
|
525
|
+
if (terminalManager) {
|
|
526
|
+
log('INFO', 'Shutting down terminal manager...');
|
|
527
|
+
terminalManager.shutdown();
|
|
528
|
+
}
|
|
529
|
+
// 4. Stop cloudflared tunnel if running
|
|
530
|
+
stopTunnel();
|
|
531
|
+
log('INFO', 'Graceful shutdown complete');
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
// Catch signals for clean shutdown
|
|
535
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
536
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
55
537
|
if (isNaN(port) || port < 1 || port > 65535) {
|
|
56
538
|
log('ERROR', `Invalid port "${portArg}". Must be a number between 1 and 65535.`);
|
|
57
539
|
process.exit(1);
|
|
@@ -97,6 +579,293 @@ async function isPortListening(port) {
|
|
|
97
579
|
function getProjectName(projectPath) {
|
|
98
580
|
return path.basename(projectPath);
|
|
99
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* Get the base port for a project from global.db
|
|
584
|
+
* Returns null if project not found or not running
|
|
585
|
+
*/
|
|
586
|
+
async function getBasePortForProject(projectPath) {
|
|
587
|
+
try {
|
|
588
|
+
const db = getGlobalDb();
|
|
589
|
+
const row = db.prepare('SELECT base_port FROM port_allocations WHERE project_path = ?').get(projectPath);
|
|
590
|
+
if (!row)
|
|
591
|
+
return null;
|
|
592
|
+
// Check if actually running
|
|
593
|
+
const isRunning = await isPortListening(row.base_port);
|
|
594
|
+
return isRunning ? row.base_port : null;
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Cloudflared tunnel management
|
|
601
|
+
let tunnelProcess = null;
|
|
602
|
+
let tunnelUrl = null;
|
|
603
|
+
function isCloudflaredInstalled() {
|
|
604
|
+
try {
|
|
605
|
+
execSync('which cloudflared', { stdio: 'ignore' });
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function getTunnelStatus() {
|
|
613
|
+
return {
|
|
614
|
+
available: isCloudflaredInstalled(),
|
|
615
|
+
running: tunnelProcess !== null && tunnelUrl !== null,
|
|
616
|
+
url: tunnelUrl,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
async function startTunnel(port) {
|
|
620
|
+
if (!isCloudflaredInstalled()) {
|
|
621
|
+
return { success: false, error: 'cloudflared not installed. Install with: brew install cloudflared' };
|
|
622
|
+
}
|
|
623
|
+
if (tunnelProcess) {
|
|
624
|
+
return { success: true, url: tunnelUrl || undefined };
|
|
625
|
+
}
|
|
626
|
+
return new Promise((resolve) => {
|
|
627
|
+
tunnelProcess = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
628
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
629
|
+
});
|
|
630
|
+
const handleOutput = (data) => {
|
|
631
|
+
const text = data.toString();
|
|
632
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
633
|
+
if (match && !tunnelUrl) {
|
|
634
|
+
tunnelUrl = match[0];
|
|
635
|
+
log('INFO', `Cloudflared tunnel started: ${tunnelUrl}`);
|
|
636
|
+
resolve({ success: true, url: tunnelUrl });
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
tunnelProcess.stdout?.on('data', handleOutput);
|
|
640
|
+
tunnelProcess.stderr?.on('data', handleOutput);
|
|
641
|
+
tunnelProcess.on('close', (code) => {
|
|
642
|
+
log('INFO', `Cloudflared tunnel closed with code ${code}`);
|
|
643
|
+
tunnelProcess = null;
|
|
644
|
+
tunnelUrl = null;
|
|
645
|
+
});
|
|
646
|
+
// Timeout after 30 seconds
|
|
647
|
+
setTimeout(() => {
|
|
648
|
+
if (!tunnelUrl) {
|
|
649
|
+
tunnelProcess?.kill();
|
|
650
|
+
tunnelProcess = null;
|
|
651
|
+
resolve({ success: false, error: 'Tunnel startup timed out' });
|
|
652
|
+
}
|
|
653
|
+
}, 30000);
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
function stopTunnel() {
|
|
657
|
+
if (tunnelProcess) {
|
|
658
|
+
tunnelProcess.kill();
|
|
659
|
+
tunnelProcess = null;
|
|
660
|
+
tunnelUrl = null;
|
|
661
|
+
log('INFO', 'Cloudflared tunnel stopped');
|
|
662
|
+
}
|
|
663
|
+
return { success: true };
|
|
664
|
+
}
|
|
665
|
+
const sseClients = [];
|
|
666
|
+
let notificationIdCounter = 0;
|
|
667
|
+
/**
|
|
668
|
+
* Broadcast a notification to all connected SSE clients
|
|
669
|
+
*/
|
|
670
|
+
function broadcastNotification(notification) {
|
|
671
|
+
const id = ++notificationIdCounter;
|
|
672
|
+
const data = JSON.stringify({ ...notification, id });
|
|
673
|
+
const message = `id: ${id}\ndata: ${data}\n\n`;
|
|
674
|
+
for (const client of sseClients) {
|
|
675
|
+
try {
|
|
676
|
+
client.res.write(message);
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
// Client disconnected, will be cleaned up on next iteration
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Get gate status for a project by querying its dashboard API.
|
|
685
|
+
* Uses timeout to prevent hung projects from stalling tower status.
|
|
686
|
+
*/
|
|
687
|
+
async function getGateStatusForProject(basePort) {
|
|
688
|
+
const controller = new AbortController();
|
|
689
|
+
const timeout = setTimeout(() => controller.abort(), 2000); // 2-second timeout
|
|
690
|
+
try {
|
|
691
|
+
const response = await fetch(`http://localhost:${basePort}/api/status`, {
|
|
692
|
+
signal: controller.signal,
|
|
693
|
+
});
|
|
694
|
+
clearTimeout(timeout);
|
|
695
|
+
if (!response.ok)
|
|
696
|
+
return { hasGate: false };
|
|
697
|
+
const projectStatus = await response.json();
|
|
698
|
+
// Check if any builder has a pending gate
|
|
699
|
+
const builderWithGate = projectStatus.builders?.find((b) => b.gateStatus?.waiting || b.status === 'gate-pending');
|
|
700
|
+
if (builderWithGate) {
|
|
701
|
+
return {
|
|
702
|
+
hasGate: true,
|
|
703
|
+
gateName: builderWithGate.gateStatus?.gateName || builderWithGate.currentGate,
|
|
704
|
+
builderId: builderWithGate.id,
|
|
705
|
+
timestamp: builderWithGate.gateStatus?.timestamp || Date.now(),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
// Project dashboard not responding or timeout
|
|
711
|
+
}
|
|
712
|
+
return { hasGate: false };
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Get terminal list for a project from tower's registry.
|
|
716
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server fetch.
|
|
717
|
+
* Returns architect, builders, and shells with their URLs.
|
|
718
|
+
*/
|
|
719
|
+
async function getTerminalsForProject(projectPath, proxyUrl) {
|
|
720
|
+
const manager = getTerminalManager();
|
|
721
|
+
const terminals = [];
|
|
722
|
+
// SQLite is authoritative - query it first (Spec 0090 requirement)
|
|
723
|
+
const dbSessions = getTerminalSessionsForProject(projectPath);
|
|
724
|
+
// Use normalized path for cache consistency
|
|
725
|
+
const normalizedPath = normalizeProjectPath(projectPath);
|
|
726
|
+
// Build a fresh entry from SQLite, then replace atomically to avoid
|
|
727
|
+
// destroying in-memory state that was registered via POST /api/terminals.
|
|
728
|
+
// Previous approach cleared the cache then rebuilt, which lost terminals
|
|
729
|
+
// if their SQLite rows were deleted by external interference (e.g., tests).
|
|
730
|
+
const freshEntry = { builders: new Map(), shells: new Map(), fileTabs: new Map() };
|
|
731
|
+
// Preserve file tabs from existing entry (not stored in SQLite)
|
|
732
|
+
const existingEntry = projectTerminals.get(normalizedPath);
|
|
733
|
+
if (existingEntry) {
|
|
734
|
+
freshEntry.fileTabs = existingEntry.fileTabs;
|
|
735
|
+
}
|
|
736
|
+
for (const dbSession of dbSessions) {
|
|
737
|
+
// Verify session still exists in TerminalManager (runtime state)
|
|
738
|
+
let session = manager.getSession(dbSession.id);
|
|
739
|
+
if (!session && dbSession.tmux_session && tmuxAvailable && tmuxSessionExists(dbSession.tmux_session)) {
|
|
740
|
+
// PTY session gone but tmux session survives — reconnect on-the-fly
|
|
741
|
+
try {
|
|
742
|
+
const newSession = await manager.createSession({
|
|
743
|
+
command: 'tmux',
|
|
744
|
+
args: ['attach-session', '-t', dbSession.tmux_session],
|
|
745
|
+
cwd: dbSession.project_path,
|
|
746
|
+
label: dbSession.type === 'architect' ? 'Architect' : `${dbSession.type} ${dbSession.role_id || dbSession.id}`,
|
|
747
|
+
env: process.env,
|
|
748
|
+
});
|
|
749
|
+
// Update SQLite with new terminal ID
|
|
750
|
+
deleteTerminalSession(dbSession.id);
|
|
751
|
+
saveTerminalSession(newSession.id, dbSession.project_path, dbSession.type, dbSession.role_id, newSession.pid, dbSession.tmux_session);
|
|
752
|
+
dbSession.id = newSession.id;
|
|
753
|
+
session = manager.getSession(newSession.id);
|
|
754
|
+
log('INFO', `Reconnected to tmux "${dbSession.tmux_session}" on-the-fly → ${newSession.id}`);
|
|
755
|
+
}
|
|
756
|
+
catch (err) {
|
|
757
|
+
log('WARN', `Failed to reconnect to tmux "${dbSession.tmux_session}": ${err.message} — will retry on next poll`);
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
else if (!session) {
|
|
762
|
+
// Stale row in SQLite, no tmux to reconnect — clean it up
|
|
763
|
+
deleteTerminalSession(dbSession.id);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (dbSession.type === 'architect') {
|
|
767
|
+
freshEntry.architect = dbSession.id;
|
|
768
|
+
terminals.push({
|
|
769
|
+
type: 'architect',
|
|
770
|
+
id: 'architect',
|
|
771
|
+
label: 'Architect',
|
|
772
|
+
url: `${proxyUrl}?tab=architect`,
|
|
773
|
+
active: true,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
else if (dbSession.type === 'builder') {
|
|
777
|
+
const builderId = dbSession.role_id || dbSession.id;
|
|
778
|
+
freshEntry.builders.set(builderId, dbSession.id);
|
|
779
|
+
terminals.push({
|
|
780
|
+
type: 'builder',
|
|
781
|
+
id: builderId,
|
|
782
|
+
label: `Builder ${builderId}`,
|
|
783
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
784
|
+
active: true,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
else if (dbSession.type === 'shell') {
|
|
788
|
+
const shellId = dbSession.role_id || dbSession.id;
|
|
789
|
+
freshEntry.shells.set(shellId, dbSession.id);
|
|
790
|
+
terminals.push({
|
|
791
|
+
type: 'shell',
|
|
792
|
+
id: shellId,
|
|
793
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
794
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
795
|
+
active: true,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// Also merge in-memory entries that may not be in SQLite yet
|
|
800
|
+
// (e.g., registered via POST /api/terminals but SQLite row was lost)
|
|
801
|
+
if (existingEntry) {
|
|
802
|
+
if (existingEntry.architect && !freshEntry.architect) {
|
|
803
|
+
const session = manager.getSession(existingEntry.architect);
|
|
804
|
+
if (session) {
|
|
805
|
+
freshEntry.architect = existingEntry.architect;
|
|
806
|
+
terminals.push({
|
|
807
|
+
type: 'architect',
|
|
808
|
+
id: 'architect',
|
|
809
|
+
label: 'Architect',
|
|
810
|
+
url: `${proxyUrl}?tab=architect`,
|
|
811
|
+
active: true,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const [builderId, terminalId] of existingEntry.builders) {
|
|
816
|
+
if (!freshEntry.builders.has(builderId)) {
|
|
817
|
+
const session = manager.getSession(terminalId);
|
|
818
|
+
if (session) {
|
|
819
|
+
freshEntry.builders.set(builderId, terminalId);
|
|
820
|
+
terminals.push({
|
|
821
|
+
type: 'builder',
|
|
822
|
+
id: builderId,
|
|
823
|
+
label: `Builder ${builderId}`,
|
|
824
|
+
url: `${proxyUrl}?tab=builder-${builderId}`,
|
|
825
|
+
active: true,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
for (const [shellId, terminalId] of existingEntry.shells) {
|
|
831
|
+
if (!freshEntry.shells.has(shellId)) {
|
|
832
|
+
const session = manager.getSession(terminalId);
|
|
833
|
+
if (session) {
|
|
834
|
+
freshEntry.shells.set(shellId, terminalId);
|
|
835
|
+
terminals.push({
|
|
836
|
+
type: 'shell',
|
|
837
|
+
id: shellId,
|
|
838
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
839
|
+
url: `${proxyUrl}?tab=shell-${shellId}`,
|
|
840
|
+
active: true,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Atomically replace the cache entry
|
|
847
|
+
projectTerminals.set(normalizedPath, freshEntry);
|
|
848
|
+
// Gate status - builders don't have gate tracking yet in tower
|
|
849
|
+
// TODO: Add gate status tracking when porch integration is updated
|
|
850
|
+
const gateStatus = { hasGate: false };
|
|
851
|
+
return { terminals, gateStatus };
|
|
852
|
+
}
|
|
853
|
+
// Resolve once at module load: both symlinked and real temp dir paths
|
|
854
|
+
const _tmpDir = tmpdir();
|
|
855
|
+
const _tmpDirResolved = (() => {
|
|
856
|
+
try {
|
|
857
|
+
return fs.realpathSync(_tmpDir);
|
|
858
|
+
}
|
|
859
|
+
catch {
|
|
860
|
+
return _tmpDir;
|
|
861
|
+
}
|
|
862
|
+
})();
|
|
863
|
+
function isTempDirectory(projectPath) {
|
|
864
|
+
return (projectPath.startsWith(_tmpDir + '/') ||
|
|
865
|
+
projectPath.startsWith(_tmpDirResolved + '/') ||
|
|
866
|
+
projectPath.startsWith('/tmp/') ||
|
|
867
|
+
projectPath.startsWith('/private/tmp/'));
|
|
868
|
+
}
|
|
100
869
|
/**
|
|
101
870
|
* Get all instances with their status
|
|
102
871
|
*/
|
|
@@ -108,25 +877,31 @@ async function getInstances() {
|
|
|
108
877
|
if (allocation.project_path.includes('/.builders/')) {
|
|
109
878
|
continue;
|
|
110
879
|
}
|
|
880
|
+
// Skip projects in temp directories (e.g. test artifacts) or whose directories no longer exist
|
|
881
|
+
if (!allocation.project_path.startsWith('remote:')) {
|
|
882
|
+
if (!fs.existsSync(allocation.project_path)) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (isTempDirectory(allocation.project_path)) {
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
111
889
|
const basePort = allocation.base_port;
|
|
112
890
|
const dashboardPort = basePort;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
//
|
|
117
|
-
|
|
891
|
+
// Encode project path for proxy URL
|
|
892
|
+
const encodedPath = Buffer.from(allocation.project_path).toString('base64url');
|
|
893
|
+
const proxyUrl = `/project/${encodedPath}/`;
|
|
894
|
+
// Get terminals and gate status from tower's registry
|
|
895
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly - no separate dashboard server
|
|
896
|
+
const { terminals, gateStatus } = await getTerminalsForProject(allocation.project_path, proxyUrl);
|
|
897
|
+
// Project is active if it has any terminals (Phase 4: no port check needed)
|
|
898
|
+
const isActive = terminals.length > 0;
|
|
118
899
|
const ports = [
|
|
119
900
|
{
|
|
120
901
|
type: 'Dashboard',
|
|
121
902
|
port: dashboardPort,
|
|
122
|
-
url:
|
|
123
|
-
active:
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
type: 'Architect',
|
|
127
|
-
port: architectPort,
|
|
128
|
-
url: `http://localhost:${architectPort}`,
|
|
129
|
-
active: architectActive,
|
|
903
|
+
url: proxyUrl, // Use tower proxy URL, not raw localhost
|
|
904
|
+
active: isActive,
|
|
130
905
|
},
|
|
131
906
|
];
|
|
132
907
|
instances.push({
|
|
@@ -134,11 +909,15 @@ async function getInstances() {
|
|
|
134
909
|
projectName: getProjectName(allocation.project_path),
|
|
135
910
|
basePort,
|
|
136
911
|
dashboardPort,
|
|
137
|
-
architectPort,
|
|
912
|
+
architectPort: basePort + 1, // Legacy field for backward compat
|
|
138
913
|
registered: allocation.registered_at,
|
|
139
914
|
lastUsed: allocation.last_used_at,
|
|
140
|
-
running:
|
|
915
|
+
running: isActive,
|
|
916
|
+
proxyUrl, // Tower proxy URL for dashboard
|
|
917
|
+
architectUrl: `${proxyUrl}?tab=architect`, // Direct URL to architect terminal
|
|
918
|
+
terminals, // All available terminals
|
|
141
919
|
ports,
|
|
920
|
+
gateStatus,
|
|
142
921
|
});
|
|
143
922
|
}
|
|
144
923
|
// Sort: running first, then by last used (most recent first)
|
|
@@ -213,8 +992,8 @@ async function getDirectorySuggestions(inputPath) {
|
|
|
213
992
|
}
|
|
214
993
|
/**
|
|
215
994
|
* Launch a new agent-farm instance
|
|
216
|
-
*
|
|
217
|
-
* Auto-adopts non-codev directories
|
|
995
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly, no dashboard-server
|
|
996
|
+
* Auto-adopts non-codev directories and creates architect terminal
|
|
218
997
|
*/
|
|
219
998
|
async function launchInstance(projectPath) {
|
|
220
999
|
// Clean up stale port allocations before launching (handles machine restarts)
|
|
@@ -246,74 +1025,122 @@ async function launchInstance(projectPath) {
|
|
|
246
1025
|
return { success: false, error: `Failed to adopt codev: ${err.message}` };
|
|
247
1026
|
}
|
|
248
1027
|
}
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// SECURITY: Use spawn with cwd option to avoid command injection
|
|
252
|
-
// Do NOT use bash -c with string concatenation
|
|
1028
|
+
// Phase 4 (Spec 0090): Tower manages terminals directly
|
|
1029
|
+
// No dashboard-server spawning - tower handles everything
|
|
253
1030
|
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)
|
|
1031
|
+
// Clear any stale state file
|
|
1032
|
+
const stateFile = path.join(projectPath, '.agent-farm', 'state.json');
|
|
1033
|
+
if (fs.existsSync(stateFile)) {
|
|
1034
|
+
try {
|
|
1035
|
+
fs.unlinkSync(stateFile);
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
// Ignore - file might not exist or be locked
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
// Ensure project has port allocation
|
|
287
1042
|
const resolvedPath = fs.realpathSync(projectPath);
|
|
288
1043
|
const db = getGlobalDb();
|
|
289
|
-
|
|
1044
|
+
let allocation = db
|
|
290
1045
|
.prepare('SELECT base_port FROM port_allocations WHERE project_path = ? OR project_path = ?')
|
|
291
1046
|
.get(projectPath, resolvedPath);
|
|
292
|
-
if (allocation) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
1047
|
+
if (!allocation) {
|
|
1048
|
+
// Allocate a new port for this project
|
|
1049
|
+
// Find the next available port block (starting at 4200, incrementing by 100)
|
|
1050
|
+
const existingPorts = db
|
|
1051
|
+
.prepare('SELECT base_port FROM port_allocations ORDER BY base_port')
|
|
1052
|
+
.all();
|
|
1053
|
+
let nextPort = 4200;
|
|
1054
|
+
for (const { base_port } of existingPorts) {
|
|
1055
|
+
if (base_port >= nextPort) {
|
|
1056
|
+
nextPort = base_port + 100;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
db.prepare("INSERT INTO port_allocations (project_path, project_name, base_port, created_at) VALUES (?, ?, ?, datetime('now'))").run(resolvedPath, path.basename(projectPath), nextPort);
|
|
1060
|
+
allocation = { base_port: nextPort };
|
|
1061
|
+
log('INFO', `Allocated port ${nextPort} for project: ${projectPath}`);
|
|
1062
|
+
}
|
|
1063
|
+
// Initialize project terminal entry
|
|
1064
|
+
const entry = getProjectTerminalsEntry(resolvedPath);
|
|
1065
|
+
// Create architect terminal if not already present
|
|
1066
|
+
if (!entry.architect) {
|
|
1067
|
+
const manager = getTerminalManager();
|
|
1068
|
+
// Read af-config.json to get the architect command
|
|
1069
|
+
let architectCmd = 'claude';
|
|
1070
|
+
const configPath = path.join(projectPath, 'af-config.json');
|
|
1071
|
+
if (fs.existsSync(configPath)) {
|
|
1072
|
+
try {
|
|
1073
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
1074
|
+
if (config.shell?.architect) {
|
|
1075
|
+
architectCmd = config.shell.architect;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
// Ignore config read errors, use default
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
try {
|
|
1083
|
+
// Parse command string to separate command and args
|
|
1084
|
+
const cmdParts = architectCmd.split(/\s+/);
|
|
1085
|
+
let cmd = cmdParts[0];
|
|
1086
|
+
let cmdArgs = cmdParts.slice(1);
|
|
1087
|
+
// Wrap in tmux for session persistence across Tower restarts
|
|
1088
|
+
const tmuxName = `architect-${path.basename(projectPath)}`;
|
|
1089
|
+
let activeTmuxSession = null;
|
|
1090
|
+
if (tmuxAvailable) {
|
|
1091
|
+
const tmuxCreated = createTmuxSession(tmuxName, cmd, cmdArgs, projectPath, 200, 50);
|
|
1092
|
+
if (tmuxCreated) {
|
|
1093
|
+
cmd = 'tmux';
|
|
1094
|
+
cmdArgs = ['attach-session', '-t', tmuxName];
|
|
1095
|
+
activeTmuxSession = tmuxName;
|
|
1096
|
+
log('INFO', `Created tmux session "${tmuxName}" for architect`);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
const session = await manager.createSession({
|
|
1100
|
+
command: cmd,
|
|
1101
|
+
args: cmdArgs,
|
|
1102
|
+
cwd: projectPath,
|
|
1103
|
+
label: 'Architect',
|
|
1104
|
+
env: process.env,
|
|
1105
|
+
});
|
|
1106
|
+
entry.architect = session.id;
|
|
1107
|
+
// TICK-001: Save to SQLite for persistence (with tmux session name)
|
|
1108
|
+
saveTerminalSession(session.id, resolvedPath, 'architect', null, session.pid, activeTmuxSession);
|
|
1109
|
+
// Auto-restart architect on exit (restored from pre-Phase 4 dashboard-server.ts)
|
|
1110
|
+
const ptySession = manager.getSession(session.id);
|
|
1111
|
+
if (ptySession) {
|
|
1112
|
+
const startedAt = Date.now();
|
|
1113
|
+
ptySession.on('exit', () => {
|
|
1114
|
+
entry.architect = undefined;
|
|
1115
|
+
deleteTerminalSession(session.id);
|
|
1116
|
+
// Kill stale tmux session so restart can create a fresh one
|
|
1117
|
+
if (activeTmuxSession) {
|
|
1118
|
+
try {
|
|
1119
|
+
execSync(`tmux kill-session -t "${activeTmuxSession}" 2>/dev/null`, { stdio: 'ignore' });
|
|
1120
|
+
}
|
|
1121
|
+
catch { /* already gone */ }
|
|
1122
|
+
}
|
|
1123
|
+
// Only restart if the architect ran for at least 5s (prevents crash loops)
|
|
1124
|
+
const uptime = Date.now() - startedAt;
|
|
1125
|
+
if (uptime < 5000) {
|
|
1126
|
+
log('INFO', `Architect exited after ${uptime}ms for ${projectPath}, not restarting (too short)`);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
log('INFO', `Architect exited for ${projectPath}, restarting in 2s...`);
|
|
1130
|
+
setTimeout(() => {
|
|
1131
|
+
launchInstance(projectPath).catch((err) => {
|
|
1132
|
+
log('WARN', `Failed to restart architect for ${projectPath}: ${err.message}`);
|
|
1133
|
+
});
|
|
1134
|
+
}, 2000);
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
log('INFO', `Created architect terminal for project: ${projectPath}`);
|
|
1138
|
+
}
|
|
1139
|
+
catch (err) {
|
|
1140
|
+
log('WARN', `Failed to create architect terminal: ${err.message}`);
|
|
1141
|
+
// Don't fail the launch - project is still active, just without architect
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
317
1144
|
return { success: true, adopted };
|
|
318
1145
|
}
|
|
319
1146
|
catch (err) {
|
|
@@ -321,40 +1148,69 @@ async function launchInstance(projectPath) {
|
|
|
321
1148
|
}
|
|
322
1149
|
}
|
|
323
1150
|
/**
|
|
324
|
-
*
|
|
1151
|
+
* Stop an agent-farm instance by killing all its terminals
|
|
1152
|
+
* Phase 4 (Spec 0090): Tower manages terminals directly
|
|
325
1153
|
*/
|
|
326
|
-
function
|
|
1154
|
+
async function stopInstance(projectPath) {
|
|
1155
|
+
const stopped = [];
|
|
1156
|
+
const manager = getTerminalManager();
|
|
1157
|
+
// Resolve symlinks for consistent lookup
|
|
1158
|
+
let resolvedPath = projectPath;
|
|
327
1159
|
try {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1160
|
+
if (fs.existsSync(projectPath)) {
|
|
1161
|
+
resolvedPath = fs.realpathSync(projectPath);
|
|
1162
|
+
}
|
|
331
1163
|
}
|
|
332
1164
|
catch {
|
|
333
|
-
|
|
1165
|
+
// Ignore - use original path
|
|
334
1166
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
stopped.push(p);
|
|
1167
|
+
// Get project terminals
|
|
1168
|
+
const entry = projectTerminals.get(resolvedPath) || projectTerminals.get(projectPath);
|
|
1169
|
+
if (entry) {
|
|
1170
|
+
// Query SQLite for tmux session names BEFORE deleting rows
|
|
1171
|
+
const dbSessions = getTerminalSessionsForProject(resolvedPath);
|
|
1172
|
+
const tmuxSessions = dbSessions
|
|
1173
|
+
.filter(s => s.tmux_session)
|
|
1174
|
+
.map(s => s.tmux_session);
|
|
1175
|
+
// Kill architect
|
|
1176
|
+
if (entry.architect) {
|
|
1177
|
+
const session = manager.getSession(entry.architect);
|
|
1178
|
+
if (session) {
|
|
1179
|
+
manager.killSession(entry.architect);
|
|
1180
|
+
stopped.push(session.pid);
|
|
350
1181
|
}
|
|
351
|
-
|
|
352
|
-
|
|
1182
|
+
}
|
|
1183
|
+
// Kill all shells
|
|
1184
|
+
for (const terminalId of entry.shells.values()) {
|
|
1185
|
+
const session = manager.getSession(terminalId);
|
|
1186
|
+
if (session) {
|
|
1187
|
+
manager.killSession(terminalId);
|
|
1188
|
+
stopped.push(session.pid);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// Kill all builders
|
|
1192
|
+
for (const terminalId of entry.builders.values()) {
|
|
1193
|
+
const session = manager.getSession(terminalId);
|
|
1194
|
+
if (session) {
|
|
1195
|
+
manager.killSession(terminalId);
|
|
1196
|
+
stopped.push(session.pid);
|
|
353
1197
|
}
|
|
354
1198
|
}
|
|
1199
|
+
// Kill tmux sessions (node-pty kill only detaches, tmux keeps running)
|
|
1200
|
+
for (const tmuxName of tmuxSessions) {
|
|
1201
|
+
killTmuxSession(tmuxName);
|
|
1202
|
+
}
|
|
1203
|
+
// Clear project from registry
|
|
1204
|
+
projectTerminals.delete(resolvedPath);
|
|
1205
|
+
projectTerminals.delete(projectPath);
|
|
1206
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
1207
|
+
deleteProjectTerminalSessions(resolvedPath);
|
|
1208
|
+
if (resolvedPath !== projectPath) {
|
|
1209
|
+
deleteProjectTerminalSessions(projectPath);
|
|
1210
|
+
}
|
|
355
1211
|
}
|
|
356
1212
|
if (stopped.length === 0) {
|
|
357
|
-
return { success: true, error: 'No
|
|
1213
|
+
return { success: true, error: 'No terminals found to stop', stopped };
|
|
358
1214
|
}
|
|
359
1215
|
return { success: true, stopped };
|
|
360
1216
|
}
|
|
@@ -375,6 +1231,54 @@ function findTemplatePath() {
|
|
|
375
1231
|
// escapeHtml, parseJsonBody, isRequestAllowed imported from ../utils/server-utils.js
|
|
376
1232
|
// Find template path
|
|
377
1233
|
const templatePath = findTemplatePath();
|
|
1234
|
+
// WebSocket server for terminal connections (Phase 2 - Spec 0090)
|
|
1235
|
+
let terminalWss = null;
|
|
1236
|
+
// React dashboard dist path (for serving directly from tower)
|
|
1237
|
+
// React dashboard dist path (for serving directly from tower)
|
|
1238
|
+
// Phase 4 (Spec 0090): Tower serves everything directly, no dashboard-server
|
|
1239
|
+
const reactDashboardPath = path.resolve(__dirname, '../../../dashboard/dist');
|
|
1240
|
+
const hasReactDashboard = fs.existsSync(reactDashboardPath);
|
|
1241
|
+
if (hasReactDashboard) {
|
|
1242
|
+
log('INFO', `React dashboard found at: ${reactDashboardPath}`);
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
log('WARN', 'React dashboard not found - project dashboards will not work');
|
|
1246
|
+
}
|
|
1247
|
+
// MIME types for static file serving
|
|
1248
|
+
const MIME_TYPES = {
|
|
1249
|
+
'.html': 'text/html',
|
|
1250
|
+
'.js': 'application/javascript',
|
|
1251
|
+
'.css': 'text/css',
|
|
1252
|
+
'.json': 'application/json',
|
|
1253
|
+
'.png': 'image/png',
|
|
1254
|
+
'.jpg': 'image/jpeg',
|
|
1255
|
+
'.gif': 'image/gif',
|
|
1256
|
+
'.svg': 'image/svg+xml',
|
|
1257
|
+
'.ico': 'image/x-icon',
|
|
1258
|
+
'.woff': 'font/woff',
|
|
1259
|
+
'.woff2': 'font/woff2',
|
|
1260
|
+
'.ttf': 'font/ttf',
|
|
1261
|
+
'.map': 'application/json',
|
|
1262
|
+
};
|
|
1263
|
+
/**
|
|
1264
|
+
* Serve a static file from the React dashboard dist
|
|
1265
|
+
*/
|
|
1266
|
+
function serveStaticFile(filePath, res) {
|
|
1267
|
+
if (!fs.existsSync(filePath)) {
|
|
1268
|
+
return false;
|
|
1269
|
+
}
|
|
1270
|
+
const ext = path.extname(filePath);
|
|
1271
|
+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
1272
|
+
try {
|
|
1273
|
+
const content = fs.readFileSync(filePath);
|
|
1274
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
1275
|
+
res.end(content);
|
|
1276
|
+
return true;
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
378
1282
|
// Create server
|
|
379
1283
|
const server = http.createServer(async (req, res) => {
|
|
380
1284
|
// Security: Validate Host and Origin headers
|
|
@@ -398,13 +1302,320 @@ const server = http.createServer(async (req, res) => {
|
|
|
398
1302
|
}
|
|
399
1303
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
400
1304
|
try {
|
|
401
|
-
//
|
|
1305
|
+
// =========================================================================
|
|
1306
|
+
// NEW API ENDPOINTS (Spec 0090 - Tower as Single Daemon)
|
|
1307
|
+
// =========================================================================
|
|
1308
|
+
// Health check endpoint (Spec 0090 Phase 1)
|
|
1309
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
1310
|
+
const instances = await getInstances();
|
|
1311
|
+
const activeCount = instances.filter((i) => i.running).length;
|
|
1312
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1313
|
+
res.end(JSON.stringify({
|
|
1314
|
+
status: 'healthy',
|
|
1315
|
+
uptime: process.uptime(),
|
|
1316
|
+
activeProjects: activeCount,
|
|
1317
|
+
totalProjects: instances.length,
|
|
1318
|
+
memoryUsage: process.memoryUsage().heapUsed,
|
|
1319
|
+
timestamp: new Date().toISOString(),
|
|
1320
|
+
}));
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
// API: List all projects (Spec 0090 Phase 1)
|
|
1324
|
+
if (req.method === 'GET' && url.pathname === '/api/projects') {
|
|
1325
|
+
const instances = await getInstances();
|
|
1326
|
+
const projects = instances.map((i) => ({
|
|
1327
|
+
path: i.projectPath,
|
|
1328
|
+
name: i.projectName,
|
|
1329
|
+
basePort: i.basePort,
|
|
1330
|
+
active: i.running,
|
|
1331
|
+
proxyUrl: i.proxyUrl,
|
|
1332
|
+
terminals: i.terminals.length,
|
|
1333
|
+
lastUsed: i.lastUsed,
|
|
1334
|
+
}));
|
|
1335
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1336
|
+
res.end(JSON.stringify({ projects }));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
// API: Project-specific endpoints (Spec 0090 Phase 1)
|
|
1340
|
+
// Routes: /api/projects/:encodedPath/activate, /deactivate, /status
|
|
1341
|
+
const projectApiMatch = url.pathname.match(/^\/api\/projects\/([^/]+)\/(activate|deactivate|status)$/);
|
|
1342
|
+
if (projectApiMatch) {
|
|
1343
|
+
const [, encodedPath, action] = projectApiMatch;
|
|
1344
|
+
let projectPath;
|
|
1345
|
+
try {
|
|
1346
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1347
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1348
|
+
throw new Error('Invalid path');
|
|
1349
|
+
}
|
|
1350
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
1351
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
1352
|
+
}
|
|
1353
|
+
catch {
|
|
1354
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1355
|
+
res.end(JSON.stringify({ error: 'Invalid project path encoding' }));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
// GET /api/projects/:path/status
|
|
1359
|
+
if (req.method === 'GET' && action === 'status') {
|
|
1360
|
+
const instances = await getInstances();
|
|
1361
|
+
const instance = instances.find((i) => i.projectPath === projectPath);
|
|
1362
|
+
if (!instance) {
|
|
1363
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1364
|
+
res.end(JSON.stringify({ error: 'Project not found' }));
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1368
|
+
res.end(JSON.stringify({
|
|
1369
|
+
path: instance.projectPath,
|
|
1370
|
+
name: instance.projectName,
|
|
1371
|
+
active: instance.running,
|
|
1372
|
+
basePort: instance.basePort,
|
|
1373
|
+
terminals: instance.terminals,
|
|
1374
|
+
gateStatus: instance.gateStatus,
|
|
1375
|
+
}));
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
// POST /api/projects/:path/activate
|
|
1379
|
+
if (req.method === 'POST' && action === 'activate') {
|
|
1380
|
+
// Rate limiting: 10 activations per minute per client
|
|
1381
|
+
const clientIp = req.socket.remoteAddress || '127.0.0.1';
|
|
1382
|
+
if (isRateLimited(clientIp)) {
|
|
1383
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
|
1384
|
+
res.end(JSON.stringify({ error: 'Too many activations, try again later' }));
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
const result = await launchInstance(projectPath);
|
|
1388
|
+
if (result.success) {
|
|
1389
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1390
|
+
res.end(JSON.stringify({ success: true, adopted: result.adopted }));
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1394
|
+
res.end(JSON.stringify({ success: false, error: result.error }));
|
|
1395
|
+
}
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
// POST /api/projects/:path/deactivate
|
|
1399
|
+
if (req.method === 'POST' && action === 'deactivate') {
|
|
1400
|
+
// Check if project exists in port allocations
|
|
1401
|
+
const allocations = loadPortAllocations();
|
|
1402
|
+
const resolvedPath = fs.existsSync(projectPath) ? fs.realpathSync(projectPath) : projectPath;
|
|
1403
|
+
const allocation = allocations.find((a) => a.project_path === projectPath || a.project_path === resolvedPath);
|
|
1404
|
+
if (!allocation) {
|
|
1405
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1406
|
+
res.end(JSON.stringify({ ok: false, error: 'Project not found' }));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
// Phase 4: Stop terminals directly via tower
|
|
1410
|
+
const result = await stopInstance(projectPath);
|
|
1411
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1412
|
+
res.end(JSON.stringify(result));
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
// =========================================================================
|
|
1417
|
+
// TERMINAL API (Phase 2 - Spec 0090)
|
|
1418
|
+
// =========================================================================
|
|
1419
|
+
// POST /api/terminals - Create a new terminal
|
|
1420
|
+
if (req.method === 'POST' && url.pathname === '/api/terminals') {
|
|
1421
|
+
try {
|
|
1422
|
+
const body = await parseJsonBody(req);
|
|
1423
|
+
const manager = getTerminalManager();
|
|
1424
|
+
// Parse request fields
|
|
1425
|
+
let command = typeof body.command === 'string' ? body.command : undefined;
|
|
1426
|
+
let args = Array.isArray(body.args) ? body.args : undefined;
|
|
1427
|
+
const cols = typeof body.cols === 'number' ? body.cols : undefined;
|
|
1428
|
+
const rows = typeof body.rows === 'number' ? body.rows : undefined;
|
|
1429
|
+
const cwd = typeof body.cwd === 'string' ? body.cwd : undefined;
|
|
1430
|
+
const env = typeof body.env === 'object' && body.env !== null ? body.env : undefined;
|
|
1431
|
+
const label = typeof body.label === 'string' ? body.label : undefined;
|
|
1432
|
+
// Optional tmux wrapping: create tmux session, then node-pty attaches to it
|
|
1433
|
+
const tmuxSession = typeof body.tmuxSession === 'string' ? body.tmuxSession : null;
|
|
1434
|
+
let activeTmuxSession = null;
|
|
1435
|
+
if (tmuxSession && tmuxAvailable && command && cwd) {
|
|
1436
|
+
const tmuxCreated = createTmuxSession(tmuxSession, command, args || [], cwd, cols || 200, rows || 50);
|
|
1437
|
+
if (tmuxCreated) {
|
|
1438
|
+
// Override: node-pty attaches to the tmux session
|
|
1439
|
+
command = 'tmux';
|
|
1440
|
+
args = ['attach-session', '-t', tmuxSession];
|
|
1441
|
+
activeTmuxSession = tmuxSession;
|
|
1442
|
+
log('INFO', `Created tmux session "${tmuxSession}" for terminal`);
|
|
1443
|
+
}
|
|
1444
|
+
// If tmux creation failed, fall through to bare node-pty
|
|
1445
|
+
}
|
|
1446
|
+
let info;
|
|
1447
|
+
try {
|
|
1448
|
+
info = await manager.createSession({ command, args, cols, rows, cwd, env, label });
|
|
1449
|
+
}
|
|
1450
|
+
catch (createErr) {
|
|
1451
|
+
// Clean up orphaned tmux session if node-pty creation failed
|
|
1452
|
+
if (activeTmuxSession) {
|
|
1453
|
+
killTmuxSession(activeTmuxSession);
|
|
1454
|
+
log('WARN', `Cleaned up orphaned tmux session "${activeTmuxSession}" after node-pty failure`);
|
|
1455
|
+
}
|
|
1456
|
+
throw createErr;
|
|
1457
|
+
}
|
|
1458
|
+
// Optional project association: register terminal with project state
|
|
1459
|
+
const projectPath = typeof body.projectPath === 'string' ? body.projectPath : null;
|
|
1460
|
+
const termType = typeof body.type === 'string' && ['builder', 'shell'].includes(body.type) ? body.type : null;
|
|
1461
|
+
const roleId = typeof body.roleId === 'string' ? body.roleId : null;
|
|
1462
|
+
if (projectPath && termType && roleId) {
|
|
1463
|
+
const entry = getProjectTerminalsEntry(normalizeProjectPath(projectPath));
|
|
1464
|
+
if (termType === 'builder') {
|
|
1465
|
+
entry.builders.set(roleId, info.id);
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
entry.shells.set(roleId, info.id);
|
|
1469
|
+
}
|
|
1470
|
+
saveTerminalSession(info.id, projectPath, termType, roleId, info.pid, activeTmuxSession);
|
|
1471
|
+
log('INFO', `Registered terminal ${info.id} as ${termType} "${roleId}" for project ${projectPath}${activeTmuxSession ? ` (tmux: ${activeTmuxSession})` : ''}`);
|
|
1472
|
+
}
|
|
1473
|
+
// Return tmuxSession so caller knows whether tmux is backing this terminal
|
|
1474
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1475
|
+
res.end(JSON.stringify({ ...info, wsPath: `/ws/terminal/${info.id}`, tmuxSession: activeTmuxSession }));
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
1479
|
+
log('ERROR', `Failed to create terminal: ${message}`);
|
|
1480
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1481
|
+
res.end(JSON.stringify({ error: 'INTERNAL_ERROR', message }));
|
|
1482
|
+
}
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
// GET /api/terminals - List all terminals
|
|
1486
|
+
if (req.method === 'GET' && url.pathname === '/api/terminals') {
|
|
1487
|
+
const manager = getTerminalManager();
|
|
1488
|
+
const terminals = manager.listSessions();
|
|
1489
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1490
|
+
res.end(JSON.stringify({ terminals }));
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
// Terminal-specific routes: /api/terminals/:id/*
|
|
1494
|
+
const terminalRouteMatch = url.pathname.match(/^\/api\/terminals\/([^/]+)(\/.*)?$/);
|
|
1495
|
+
if (terminalRouteMatch) {
|
|
1496
|
+
const [, terminalId, subpath] = terminalRouteMatch;
|
|
1497
|
+
const manager = getTerminalManager();
|
|
1498
|
+
// GET /api/terminals/:id - Get terminal info
|
|
1499
|
+
if (req.method === 'GET' && (!subpath || subpath === '')) {
|
|
1500
|
+
const session = manager.getSession(terminalId);
|
|
1501
|
+
if (!session) {
|
|
1502
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1503
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1507
|
+
res.end(JSON.stringify(session.info));
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
// DELETE /api/terminals/:id - Kill terminal
|
|
1511
|
+
if (req.method === 'DELETE' && (!subpath || subpath === '')) {
|
|
1512
|
+
if (!manager.killSession(terminalId)) {
|
|
1513
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1514
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
// TICK-001: Delete from SQLite
|
|
1518
|
+
deleteTerminalSession(terminalId);
|
|
1519
|
+
res.writeHead(204);
|
|
1520
|
+
res.end();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
// POST /api/terminals/:id/resize - Resize terminal
|
|
1524
|
+
if (req.method === 'POST' && subpath === '/resize') {
|
|
1525
|
+
try {
|
|
1526
|
+
const body = await parseJsonBody(req);
|
|
1527
|
+
if (typeof body.cols !== 'number' || typeof body.rows !== 'number') {
|
|
1528
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1529
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'cols and rows must be numbers' }));
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
const info = manager.resizeSession(terminalId, body.cols, body.rows);
|
|
1533
|
+
if (!info) {
|
|
1534
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1535
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1539
|
+
res.end(JSON.stringify(info));
|
|
1540
|
+
}
|
|
1541
|
+
catch {
|
|
1542
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1543
|
+
res.end(JSON.stringify({ error: 'INVALID_PARAMS', message: 'Invalid JSON body' }));
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
// GET /api/terminals/:id/output - Get terminal output
|
|
1548
|
+
if (req.method === 'GET' && subpath === '/output') {
|
|
1549
|
+
const lines = parseInt(url.searchParams.get('lines') ?? '100', 10);
|
|
1550
|
+
const offset = parseInt(url.searchParams.get('offset') ?? '0', 10);
|
|
1551
|
+
const output = manager.getOutput(terminalId, lines, offset);
|
|
1552
|
+
if (!output) {
|
|
1553
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1554
|
+
res.end(JSON.stringify({ error: 'NOT_FOUND', message: `Session ${terminalId} not found` }));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1558
|
+
res.end(JSON.stringify(output));
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
// =========================================================================
|
|
1563
|
+
// EXISTING API ENDPOINTS
|
|
1564
|
+
// =========================================================================
|
|
1565
|
+
// API: Get status of all instances (legacy - kept for backward compat)
|
|
402
1566
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
403
1567
|
const instances = await getInstances();
|
|
404
1568
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
405
1569
|
res.end(JSON.stringify({ instances }));
|
|
406
1570
|
return;
|
|
407
1571
|
}
|
|
1572
|
+
// API: Server-Sent Events for push notifications
|
|
1573
|
+
if (req.method === 'GET' && url.pathname === '/api/events') {
|
|
1574
|
+
const clientId = crypto.randomBytes(8).toString('hex');
|
|
1575
|
+
res.writeHead(200, {
|
|
1576
|
+
'Content-Type': 'text/event-stream',
|
|
1577
|
+
'Cache-Control': 'no-cache',
|
|
1578
|
+
Connection: 'keep-alive',
|
|
1579
|
+
});
|
|
1580
|
+
// Send initial connection event
|
|
1581
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', id: clientId })}\n\n`);
|
|
1582
|
+
const client = { res, id: clientId };
|
|
1583
|
+
sseClients.push(client);
|
|
1584
|
+
log('INFO', `SSE client connected: ${clientId} (total: ${sseClients.length})`);
|
|
1585
|
+
// Clean up on disconnect
|
|
1586
|
+
req.on('close', () => {
|
|
1587
|
+
const index = sseClients.findIndex((c) => c.id === clientId);
|
|
1588
|
+
if (index !== -1) {
|
|
1589
|
+
sseClients.splice(index, 1);
|
|
1590
|
+
}
|
|
1591
|
+
log('INFO', `SSE client disconnected: ${clientId} (total: ${sseClients.length})`);
|
|
1592
|
+
});
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
// API: Receive notification from builder
|
|
1596
|
+
if (req.method === 'POST' && url.pathname === '/api/notify') {
|
|
1597
|
+
const body = await parseJsonBody(req);
|
|
1598
|
+
const type = typeof body.type === 'string' ? body.type : 'info';
|
|
1599
|
+
const title = typeof body.title === 'string' ? body.title : '';
|
|
1600
|
+
const messageBody = typeof body.body === 'string' ? body.body : '';
|
|
1601
|
+
const project = typeof body.project === 'string' ? body.project : undefined;
|
|
1602
|
+
if (!title || !messageBody) {
|
|
1603
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1604
|
+
res.end(JSON.stringify({ success: false, error: 'Missing title or body' }));
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
// Broadcast to all connected SSE clients
|
|
1608
|
+
broadcastNotification({
|
|
1609
|
+
type,
|
|
1610
|
+
title,
|
|
1611
|
+
body: messageBody,
|
|
1612
|
+
project,
|
|
1613
|
+
});
|
|
1614
|
+
log('INFO', `Notification broadcast: ${title}`);
|
|
1615
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1616
|
+
res.end(JSON.stringify({ success: true }));
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
408
1619
|
// API: Browse directories for autocomplete
|
|
409
1620
|
if (req.method === 'GET' && url.pathname === '/api/browse') {
|
|
410
1621
|
const inputPath = url.searchParams.get('path') || '';
|
|
@@ -499,16 +1710,44 @@ const server = http.createServer(async (req, res) => {
|
|
|
499
1710
|
res.end(JSON.stringify(result));
|
|
500
1711
|
return;
|
|
501
1712
|
}
|
|
1713
|
+
// API: Get tunnel status (cloudflared availability and running tunnel)
|
|
1714
|
+
if (req.method === 'GET' && url.pathname === '/api/tunnel/status') {
|
|
1715
|
+
const status = getTunnelStatus();
|
|
1716
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1717
|
+
res.end(JSON.stringify(status));
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
// API: Start cloudflared tunnel
|
|
1721
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/start') {
|
|
1722
|
+
const result = await startTunnel(port);
|
|
1723
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1724
|
+
res.end(JSON.stringify(result));
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
// API: Stop cloudflared tunnel
|
|
1728
|
+
if (req.method === 'POST' && url.pathname === '/api/tunnel/stop') {
|
|
1729
|
+
const result = stopTunnel();
|
|
1730
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1731
|
+
res.end(JSON.stringify(result));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
502
1734
|
// API: Stop an instance
|
|
1735
|
+
// Phase 4 (Spec 0090): Accept projectPath or basePort for backwards compat
|
|
503
1736
|
if (req.method === 'POST' && url.pathname === '/api/stop') {
|
|
504
1737
|
const body = await parseJsonBody(req);
|
|
505
|
-
|
|
506
|
-
if
|
|
1738
|
+
let targetPath = body.projectPath;
|
|
1739
|
+
// Backwards compat: if basePort provided, find the project path
|
|
1740
|
+
if (!targetPath && body.basePort) {
|
|
1741
|
+
const allocations = loadPortAllocations();
|
|
1742
|
+
const allocation = allocations.find((a) => a.base_port === body.basePort);
|
|
1743
|
+
targetPath = allocation?.project_path || '';
|
|
1744
|
+
}
|
|
1745
|
+
if (!targetPath) {
|
|
507
1746
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
508
|
-
res.end(JSON.stringify({ success: false, error: 'Missing basePort' }));
|
|
1747
|
+
res.end(JSON.stringify({ success: false, error: 'Missing projectPath or basePort' }));
|
|
509
1748
|
return;
|
|
510
1749
|
}
|
|
511
|
-
const result = await stopInstance(
|
|
1750
|
+
const result = await stopInstance(targetPath);
|
|
512
1751
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
513
1752
|
res.end(JSON.stringify(result));
|
|
514
1753
|
return;
|
|
@@ -531,6 +1770,714 @@ const server = http.createServer(async (req, res) => {
|
|
|
531
1770
|
}
|
|
532
1771
|
return;
|
|
533
1772
|
}
|
|
1773
|
+
// Project routes: /project/:base64urlPath/*
|
|
1774
|
+
// Phase 4 (Spec 0090): Tower serves React dashboard and handles APIs directly
|
|
1775
|
+
// Uses Base64URL (RFC 4648) encoding to avoid issues with slashes in paths
|
|
1776
|
+
if (url.pathname.startsWith('/project/')) {
|
|
1777
|
+
const pathParts = url.pathname.split('/');
|
|
1778
|
+
// ['', 'project', base64urlPath, ...rest]
|
|
1779
|
+
const encodedPath = pathParts[2];
|
|
1780
|
+
const subPath = pathParts.slice(3).join('/');
|
|
1781
|
+
if (!encodedPath) {
|
|
1782
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1783
|
+
res.end('Missing project path');
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
// Decode Base64URL (RFC 4648)
|
|
1787
|
+
let projectPath;
|
|
1788
|
+
try {
|
|
1789
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
1790
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
1791
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
1792
|
+
throw new Error('Invalid project path');
|
|
1793
|
+
}
|
|
1794
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
1795
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
1796
|
+
}
|
|
1797
|
+
catch {
|
|
1798
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
1799
|
+
res.end('Invalid project path encoding');
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
const basePort = await getBasePortForProject(projectPath);
|
|
1803
|
+
// Phase 4 (Spec 0090): Tower handles everything directly
|
|
1804
|
+
const isApiCall = subPath.startsWith('api/') || subPath === 'api';
|
|
1805
|
+
const isWsPath = subPath.startsWith('ws/') || subPath === 'ws';
|
|
1806
|
+
// GET /file?path=<relative-path> — Read project file by path (for StatusPanel project list)
|
|
1807
|
+
if (req.method === 'GET' && subPath === 'file' && url.searchParams.has('path')) {
|
|
1808
|
+
const relPath = url.searchParams.get('path');
|
|
1809
|
+
const fullPath = path.resolve(projectPath, relPath);
|
|
1810
|
+
// Security: ensure resolved path stays within project directory
|
|
1811
|
+
if (!fullPath.startsWith(projectPath + path.sep) && fullPath !== projectPath) {
|
|
1812
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
1813
|
+
res.end('Forbidden');
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
try {
|
|
1817
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
1818
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
1819
|
+
res.end(content);
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
1823
|
+
res.end('Not found');
|
|
1824
|
+
}
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
// Serve React dashboard static files directly if:
|
|
1828
|
+
// 1. Not an API call
|
|
1829
|
+
// 2. Not a WebSocket path
|
|
1830
|
+
// 3. React dashboard is available
|
|
1831
|
+
// 4. Project doesn't need to be running for static files
|
|
1832
|
+
if (!isApiCall && !isWsPath && hasReactDashboard) {
|
|
1833
|
+
// Determine which static file to serve
|
|
1834
|
+
let staticPath;
|
|
1835
|
+
if (!subPath || subPath === '' || subPath === 'index.html') {
|
|
1836
|
+
staticPath = path.join(reactDashboardPath, 'index.html');
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
// Check if it's a static asset
|
|
1840
|
+
staticPath = path.join(reactDashboardPath, subPath);
|
|
1841
|
+
}
|
|
1842
|
+
// Try to serve the static file
|
|
1843
|
+
if (serveStaticFile(staticPath, res)) {
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
// SPA fallback: serve index.html for client-side routing
|
|
1847
|
+
const indexPath = path.join(reactDashboardPath, 'index.html');
|
|
1848
|
+
if (serveStaticFile(indexPath, res)) {
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
// Phase 4 (Spec 0090): Handle project APIs directly instead of proxying to dashboard-server
|
|
1853
|
+
if (isApiCall) {
|
|
1854
|
+
const apiPath = subPath.replace(/^api\/?/, '');
|
|
1855
|
+
// GET /api/state - Return project state (architect, builders, shells)
|
|
1856
|
+
if (req.method === 'GET' && (apiPath === 'state' || apiPath === '')) {
|
|
1857
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1858
|
+
const manager = getTerminalManager();
|
|
1859
|
+
// Build state response compatible with React dashboard
|
|
1860
|
+
const state = {
|
|
1861
|
+
architect: null,
|
|
1862
|
+
builders: [],
|
|
1863
|
+
utils: [],
|
|
1864
|
+
annotations: [],
|
|
1865
|
+
projectName: path.basename(projectPath),
|
|
1866
|
+
};
|
|
1867
|
+
// Add architect if exists
|
|
1868
|
+
if (entry.architect) {
|
|
1869
|
+
const session = manager.getSession(entry.architect);
|
|
1870
|
+
state.architect = {
|
|
1871
|
+
port: basePort || 0,
|
|
1872
|
+
pid: session?.pid || 0,
|
|
1873
|
+
terminalId: entry.architect,
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
// Add shells (skip stale entries whose terminal session is gone or exited)
|
|
1877
|
+
const staleShellIds = [];
|
|
1878
|
+
for (const [shellId, terminalId] of entry.shells) {
|
|
1879
|
+
const session = manager.getSession(terminalId);
|
|
1880
|
+
if (!session || session.status === 'exited') {
|
|
1881
|
+
staleShellIds.push(shellId);
|
|
1882
|
+
continue;
|
|
1883
|
+
}
|
|
1884
|
+
state.utils.push({
|
|
1885
|
+
id: shellId,
|
|
1886
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1887
|
+
port: basePort || 0,
|
|
1888
|
+
pid: session?.pid || 0,
|
|
1889
|
+
terminalId,
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
for (const id of staleShellIds)
|
|
1893
|
+
entry.shells.delete(id);
|
|
1894
|
+
// Add builders (skip stale entries whose terminal session is gone or exited)
|
|
1895
|
+
const staleBuilderIds = [];
|
|
1896
|
+
for (const [builderId, terminalId] of entry.builders) {
|
|
1897
|
+
const session = manager.getSession(terminalId);
|
|
1898
|
+
if (!session || session.status === 'exited') {
|
|
1899
|
+
staleBuilderIds.push(builderId);
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
state.builders.push({
|
|
1903
|
+
id: builderId,
|
|
1904
|
+
name: `Builder ${builderId}`,
|
|
1905
|
+
port: basePort || 0,
|
|
1906
|
+
pid: session?.pid || 0,
|
|
1907
|
+
status: 'running',
|
|
1908
|
+
phase: '',
|
|
1909
|
+
worktree: '',
|
|
1910
|
+
branch: '',
|
|
1911
|
+
type: 'spec',
|
|
1912
|
+
terminalId,
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
for (const id of staleBuilderIds)
|
|
1916
|
+
entry.builders.delete(id);
|
|
1917
|
+
// Add file tabs (Spec 0092 - served through Tower, no separate ports)
|
|
1918
|
+
for (const [tabId, tab] of entry.fileTabs) {
|
|
1919
|
+
state.annotations.push({
|
|
1920
|
+
id: tabId,
|
|
1921
|
+
file: tab.path,
|
|
1922
|
+
port: 0, // No separate port - served through Tower
|
|
1923
|
+
pid: 0, // No separate process
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1926
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1927
|
+
res.end(JSON.stringify(state));
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
// POST /api/tabs/shell - Create a new shell terminal
|
|
1931
|
+
if (req.method === 'POST' && apiPath === 'tabs/shell') {
|
|
1932
|
+
try {
|
|
1933
|
+
const manager = getTerminalManager();
|
|
1934
|
+
const shellId = getNextShellId(projectPath);
|
|
1935
|
+
// Wrap in tmux for session persistence
|
|
1936
|
+
let shellCmd = process.env.SHELL || '/bin/bash';
|
|
1937
|
+
let shellArgs = [];
|
|
1938
|
+
const tmuxName = `shell-${path.basename(projectPath)}-${shellId}`;
|
|
1939
|
+
let activeTmuxSession = null;
|
|
1940
|
+
if (tmuxAvailable) {
|
|
1941
|
+
const tmuxCreated = createTmuxSession(tmuxName, shellCmd, shellArgs, projectPath, 200, 50);
|
|
1942
|
+
if (tmuxCreated) {
|
|
1943
|
+
shellCmd = 'tmux';
|
|
1944
|
+
shellArgs = ['attach-session', '-t', tmuxName];
|
|
1945
|
+
activeTmuxSession = tmuxName;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// Create terminal session
|
|
1949
|
+
const session = await manager.createSession({
|
|
1950
|
+
command: shellCmd,
|
|
1951
|
+
args: shellArgs,
|
|
1952
|
+
cwd: projectPath,
|
|
1953
|
+
label: `Shell ${shellId.replace('shell-', '')}`,
|
|
1954
|
+
env: process.env,
|
|
1955
|
+
});
|
|
1956
|
+
// Register terminal with project
|
|
1957
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
1958
|
+
entry.shells.set(shellId, session.id);
|
|
1959
|
+
// TICK-001: Save to SQLite for persistence
|
|
1960
|
+
saveTerminalSession(session.id, projectPath, 'shell', shellId, session.pid, activeTmuxSession);
|
|
1961
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1962
|
+
res.end(JSON.stringify({
|
|
1963
|
+
id: shellId,
|
|
1964
|
+
port: basePort || 0,
|
|
1965
|
+
name: `Shell ${shellId.replace('shell-', '')}`,
|
|
1966
|
+
terminalId: session.id,
|
|
1967
|
+
}));
|
|
1968
|
+
}
|
|
1969
|
+
catch (err) {
|
|
1970
|
+
log('ERROR', `Failed to create shell: ${err.message}`);
|
|
1971
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1972
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1973
|
+
}
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
// POST /api/tabs/file - Create a file tab (Spec 0092)
|
|
1977
|
+
if (req.method === 'POST' && apiPath === 'tabs/file') {
|
|
1978
|
+
try {
|
|
1979
|
+
const body = await new Promise((resolve) => {
|
|
1980
|
+
let data = '';
|
|
1981
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
1982
|
+
req.on('end', () => resolve(data));
|
|
1983
|
+
});
|
|
1984
|
+
const { path: filePath, line } = JSON.parse(body || '{}');
|
|
1985
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
1986
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1987
|
+
res.end(JSON.stringify({ error: 'Missing path parameter' }));
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
// Resolve path relative to project
|
|
1991
|
+
const fullPath = path.isAbsolute(filePath)
|
|
1992
|
+
? filePath
|
|
1993
|
+
: path.join(projectPath, filePath);
|
|
1994
|
+
// Security: ensure path is within project or is absolute path user provided
|
|
1995
|
+
const normalizedFull = path.normalize(fullPath);
|
|
1996
|
+
const normalizedProject = path.normalize(projectPath);
|
|
1997
|
+
if (!normalizedFull.startsWith(normalizedProject) && !path.isAbsolute(filePath)) {
|
|
1998
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1999
|
+
res.end(JSON.stringify({ error: 'Path outside project' }));
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
// Check file exists
|
|
2003
|
+
if (!fs.existsSync(fullPath)) {
|
|
2004
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2005
|
+
res.end(JSON.stringify({ error: 'File not found' }));
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2009
|
+
// Check if already open
|
|
2010
|
+
for (const [id, tab] of entry.fileTabs) {
|
|
2011
|
+
if (tab.path === fullPath) {
|
|
2012
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2013
|
+
res.end(JSON.stringify({ id, existing: true, line }));
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
// Create new file tab
|
|
2018
|
+
const id = `file-${Date.now().toString(36)}`;
|
|
2019
|
+
entry.fileTabs.set(id, { id, path: fullPath, createdAt: Date.now() });
|
|
2020
|
+
log('INFO', `Created file tab: ${id} for ${path.basename(fullPath)}`);
|
|
2021
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2022
|
+
res.end(JSON.stringify({ id, existing: false, line }));
|
|
2023
|
+
}
|
|
2024
|
+
catch (err) {
|
|
2025
|
+
log('ERROR', `Failed to create file tab: ${err.message}`);
|
|
2026
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2027
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2028
|
+
}
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
// GET /api/file/:id - Get file content as JSON (Spec 0092)
|
|
2032
|
+
const fileGetMatch = apiPath.match(/^file\/([^/]+)$/);
|
|
2033
|
+
if (req.method === 'GET' && fileGetMatch) {
|
|
2034
|
+
const tabId = fileGetMatch[1];
|
|
2035
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2036
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2037
|
+
if (!tab) {
|
|
2038
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2039
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
try {
|
|
2043
|
+
const ext = path.extname(tab.path).slice(1).toLowerCase();
|
|
2044
|
+
const isText = !['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'mp4', 'webm', 'mov', 'pdf'].includes(ext);
|
|
2045
|
+
if (isText) {
|
|
2046
|
+
const content = fs.readFileSync(tab.path, 'utf-8');
|
|
2047
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2048
|
+
res.end(JSON.stringify({
|
|
2049
|
+
path: tab.path,
|
|
2050
|
+
name: path.basename(tab.path),
|
|
2051
|
+
content,
|
|
2052
|
+
language: getLanguageForExt(ext),
|
|
2053
|
+
isMarkdown: ext === 'md',
|
|
2054
|
+
isImage: false,
|
|
2055
|
+
isVideo: false,
|
|
2056
|
+
}));
|
|
2057
|
+
}
|
|
2058
|
+
else {
|
|
2059
|
+
// For binary files, just return metadata
|
|
2060
|
+
const stat = fs.statSync(tab.path);
|
|
2061
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2062
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2063
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2064
|
+
res.end(JSON.stringify({
|
|
2065
|
+
path: tab.path,
|
|
2066
|
+
name: path.basename(tab.path),
|
|
2067
|
+
content: null,
|
|
2068
|
+
language: ext,
|
|
2069
|
+
isMarkdown: false,
|
|
2070
|
+
isImage,
|
|
2071
|
+
isVideo,
|
|
2072
|
+
size: stat.size,
|
|
2073
|
+
}));
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
catch (err) {
|
|
2077
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2078
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2079
|
+
}
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
// GET /api/file/:id/raw - Get raw file content (for images/video) (Spec 0092)
|
|
2083
|
+
const fileRawMatch = apiPath.match(/^file\/([^/]+)\/raw$/);
|
|
2084
|
+
if (req.method === 'GET' && fileRawMatch) {
|
|
2085
|
+
const tabId = fileRawMatch[1];
|
|
2086
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2087
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2088
|
+
if (!tab) {
|
|
2089
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2090
|
+
res.end('File tab not found');
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
try {
|
|
2094
|
+
const data = fs.readFileSync(tab.path);
|
|
2095
|
+
const mimeType = getMimeTypeForFile(tab.path);
|
|
2096
|
+
res.writeHead(200, {
|
|
2097
|
+
'Content-Type': mimeType,
|
|
2098
|
+
'Content-Length': data.length,
|
|
2099
|
+
'Cache-Control': 'no-cache',
|
|
2100
|
+
});
|
|
2101
|
+
res.end(data);
|
|
2102
|
+
}
|
|
2103
|
+
catch (err) {
|
|
2104
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2105
|
+
res.end(err.message);
|
|
2106
|
+
}
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
// POST /api/file/:id/save - Save file content (Spec 0092)
|
|
2110
|
+
const fileSaveMatch = apiPath.match(/^file\/([^/]+)\/save$/);
|
|
2111
|
+
if (req.method === 'POST' && fileSaveMatch) {
|
|
2112
|
+
const tabId = fileSaveMatch[1];
|
|
2113
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2114
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2115
|
+
if (!tab) {
|
|
2116
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2117
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2118
|
+
return;
|
|
2119
|
+
}
|
|
2120
|
+
try {
|
|
2121
|
+
const body = await new Promise((resolve) => {
|
|
2122
|
+
let data = '';
|
|
2123
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2124
|
+
req.on('end', () => resolve(data));
|
|
2125
|
+
});
|
|
2126
|
+
const { content } = JSON.parse(body || '{}');
|
|
2127
|
+
if (typeof content !== 'string') {
|
|
2128
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2129
|
+
res.end(JSON.stringify({ error: 'Missing content parameter' }));
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
fs.writeFileSync(tab.path, content, 'utf-8');
|
|
2133
|
+
log('INFO', `Saved file: ${tab.path}`);
|
|
2134
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2135
|
+
res.end(JSON.stringify({ success: true }));
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
2139
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2140
|
+
}
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
// DELETE /api/tabs/:id - Delete a terminal or file tab
|
|
2144
|
+
const deleteMatch = apiPath.match(/^tabs\/(.+)$/);
|
|
2145
|
+
if (req.method === 'DELETE' && deleteMatch) {
|
|
2146
|
+
const tabId = deleteMatch[1];
|
|
2147
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2148
|
+
const manager = getTerminalManager();
|
|
2149
|
+
// Check if it's a file tab first (Spec 0092)
|
|
2150
|
+
if (tabId.startsWith('file-')) {
|
|
2151
|
+
if (entry.fileTabs.has(tabId)) {
|
|
2152
|
+
entry.fileTabs.delete(tabId);
|
|
2153
|
+
log('INFO', `Deleted file tab: ${tabId}`);
|
|
2154
|
+
res.writeHead(204);
|
|
2155
|
+
res.end();
|
|
2156
|
+
}
|
|
2157
|
+
else {
|
|
2158
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2159
|
+
res.end(JSON.stringify({ error: 'File tab not found' }));
|
|
2160
|
+
}
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
// Find and delete the terminal
|
|
2164
|
+
let terminalId;
|
|
2165
|
+
if (tabId.startsWith('shell-')) {
|
|
2166
|
+
terminalId = entry.shells.get(tabId);
|
|
2167
|
+
if (terminalId) {
|
|
2168
|
+
entry.shells.delete(tabId);
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
else if (tabId.startsWith('builder-')) {
|
|
2172
|
+
terminalId = entry.builders.get(tabId);
|
|
2173
|
+
if (terminalId) {
|
|
2174
|
+
entry.builders.delete(tabId);
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
else if (tabId === 'architect') {
|
|
2178
|
+
terminalId = entry.architect;
|
|
2179
|
+
if (terminalId) {
|
|
2180
|
+
entry.architect = undefined;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
if (terminalId) {
|
|
2184
|
+
manager.killSession(terminalId);
|
|
2185
|
+
// TICK-001: Delete from SQLite
|
|
2186
|
+
deleteTerminalSession(terminalId);
|
|
2187
|
+
res.writeHead(204);
|
|
2188
|
+
res.end();
|
|
2189
|
+
}
|
|
2190
|
+
else {
|
|
2191
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2192
|
+
res.end(JSON.stringify({ error: 'Tab not found' }));
|
|
2193
|
+
}
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
// POST /api/stop - Stop all terminals for project
|
|
2197
|
+
if (req.method === 'POST' && apiPath === 'stop') {
|
|
2198
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2199
|
+
const manager = getTerminalManager();
|
|
2200
|
+
// Kill all terminals
|
|
2201
|
+
if (entry.architect) {
|
|
2202
|
+
manager.killSession(entry.architect);
|
|
2203
|
+
}
|
|
2204
|
+
for (const terminalId of entry.shells.values()) {
|
|
2205
|
+
manager.killSession(terminalId);
|
|
2206
|
+
}
|
|
2207
|
+
for (const terminalId of entry.builders.values()) {
|
|
2208
|
+
manager.killSession(terminalId);
|
|
2209
|
+
}
|
|
2210
|
+
// Clear registry
|
|
2211
|
+
projectTerminals.delete(projectPath);
|
|
2212
|
+
// TICK-001: Delete all terminal sessions from SQLite
|
|
2213
|
+
deleteProjectTerminalSessions(projectPath);
|
|
2214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2215
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
// GET /api/files - Return project directory tree for file browser (Spec 0092)
|
|
2219
|
+
if (req.method === 'GET' && apiPath === 'files') {
|
|
2220
|
+
const maxDepth = parseInt(url.searchParams.get('depth') || '3', 10);
|
|
2221
|
+
const ignore = new Set(['.git', 'node_modules', '.builders', 'dist', '.agent-farm', '.next', '.cache', '__pycache__']);
|
|
2222
|
+
function readTree(dir, depth) {
|
|
2223
|
+
if (depth <= 0)
|
|
2224
|
+
return [];
|
|
2225
|
+
try {
|
|
2226
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2227
|
+
return entries
|
|
2228
|
+
.filter(e => !e.name.startsWith('.') || e.name === '.env.example')
|
|
2229
|
+
.filter(e => !ignore.has(e.name))
|
|
2230
|
+
.sort((a, b) => {
|
|
2231
|
+
// Directories first, then alphabetical
|
|
2232
|
+
if (a.isDirectory() && !b.isDirectory())
|
|
2233
|
+
return -1;
|
|
2234
|
+
if (!a.isDirectory() && b.isDirectory())
|
|
2235
|
+
return 1;
|
|
2236
|
+
return a.name.localeCompare(b.name);
|
|
2237
|
+
})
|
|
2238
|
+
.map(e => {
|
|
2239
|
+
const fullPath = path.join(dir, e.name);
|
|
2240
|
+
const relativePath = path.relative(projectPath, fullPath);
|
|
2241
|
+
if (e.isDirectory()) {
|
|
2242
|
+
return { name: e.name, path: relativePath, type: 'directory', children: readTree(fullPath, depth - 1) };
|
|
2243
|
+
}
|
|
2244
|
+
return { name: e.name, path: relativePath, type: 'file' };
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
catch {
|
|
2248
|
+
return [];
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
const tree = readTree(projectPath, maxDepth);
|
|
2252
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2253
|
+
res.end(JSON.stringify(tree));
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
// GET /api/git/status - Return git status for file browser (Spec 0092)
|
|
2257
|
+
if (req.method === 'GET' && apiPath === 'git/status') {
|
|
2258
|
+
try {
|
|
2259
|
+
// Get git status in porcelain format for parsing
|
|
2260
|
+
const result = execSync('git status --porcelain', {
|
|
2261
|
+
cwd: projectPath,
|
|
2262
|
+
encoding: 'utf-8',
|
|
2263
|
+
timeout: 5000,
|
|
2264
|
+
});
|
|
2265
|
+
// Parse porcelain output: XY filename
|
|
2266
|
+
// X = staging area status, Y = working tree status
|
|
2267
|
+
const modified = [];
|
|
2268
|
+
const staged = [];
|
|
2269
|
+
const untracked = [];
|
|
2270
|
+
for (const line of result.split('\n')) {
|
|
2271
|
+
if (!line)
|
|
2272
|
+
continue;
|
|
2273
|
+
const x = line[0]; // staging area
|
|
2274
|
+
const y = line[1]; // working tree
|
|
2275
|
+
const filepath = line.slice(3);
|
|
2276
|
+
if (x === '?' && y === '?') {
|
|
2277
|
+
untracked.push(filepath);
|
|
2278
|
+
}
|
|
2279
|
+
else {
|
|
2280
|
+
if (x !== ' ' && x !== '?') {
|
|
2281
|
+
staged.push(filepath);
|
|
2282
|
+
}
|
|
2283
|
+
if (y !== ' ' && y !== '?') {
|
|
2284
|
+
modified.push(filepath);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2289
|
+
res.end(JSON.stringify({ modified, staged, untracked }));
|
|
2290
|
+
}
|
|
2291
|
+
catch (err) {
|
|
2292
|
+
// Not a git repo or git command failed
|
|
2293
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2294
|
+
res.end(JSON.stringify({ modified: [], staged: [], untracked: [], error: err.message }));
|
|
2295
|
+
}
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
// GET /api/files/recent - Return recently opened file tabs (Spec 0092)
|
|
2299
|
+
if (req.method === 'GET' && apiPath === 'files/recent') {
|
|
2300
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2301
|
+
// Get all file tabs sorted by creation time (most recent first)
|
|
2302
|
+
const recentFiles = Array.from(entry.fileTabs.values())
|
|
2303
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
2304
|
+
.slice(0, 10) // Limit to 10 most recent
|
|
2305
|
+
.map(tab => ({
|
|
2306
|
+
id: tab.id,
|
|
2307
|
+
path: tab.path,
|
|
2308
|
+
name: path.basename(tab.path),
|
|
2309
|
+
relativePath: path.relative(projectPath, tab.path),
|
|
2310
|
+
}));
|
|
2311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2312
|
+
res.end(JSON.stringify(recentFiles));
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
// GET /api/annotate/:tabId/* — Serve rich annotator template and sub-APIs
|
|
2316
|
+
const annotateMatch = apiPath.match(/^annotate\/([^/]+)(\/(.*))?$/);
|
|
2317
|
+
if (annotateMatch) {
|
|
2318
|
+
const tabId = annotateMatch[1];
|
|
2319
|
+
const subRoute = annotateMatch[3] || '';
|
|
2320
|
+
const entry = getProjectTerminalsEntry(projectPath);
|
|
2321
|
+
const tab = entry.fileTabs.get(tabId);
|
|
2322
|
+
if (!tab) {
|
|
2323
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2324
|
+
res.end('File tab not found');
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const filePath = tab.path;
|
|
2328
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
2329
|
+
const isImage = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext);
|
|
2330
|
+
const isVideo = ['mp4', 'webm', 'mov'].includes(ext);
|
|
2331
|
+
const is3D = ['stl', '3mf'].includes(ext);
|
|
2332
|
+
const isPdf = ext === 'pdf';
|
|
2333
|
+
const isMarkdown = ext === 'md';
|
|
2334
|
+
// Sub-route: GET /file — re-read file content from disk
|
|
2335
|
+
if (req.method === 'GET' && subRoute === 'file') {
|
|
2336
|
+
try {
|
|
2337
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
2338
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
2339
|
+
res.end(content);
|
|
2340
|
+
}
|
|
2341
|
+
catch (err) {
|
|
2342
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2343
|
+
res.end(err.message);
|
|
2344
|
+
}
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
// Sub-route: POST /save — save file content
|
|
2348
|
+
if (req.method === 'POST' && subRoute === 'save') {
|
|
2349
|
+
try {
|
|
2350
|
+
const body = await new Promise((resolve) => {
|
|
2351
|
+
let data = '';
|
|
2352
|
+
req.on('data', (chunk) => data += chunk.toString());
|
|
2353
|
+
req.on('end', () => resolve(data));
|
|
2354
|
+
});
|
|
2355
|
+
const parsed = JSON.parse(body || '{}');
|
|
2356
|
+
const fileContent = parsed.content;
|
|
2357
|
+
if (typeof fileContent !== 'string') {
|
|
2358
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2359
|
+
res.end('Missing content');
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
fs.writeFileSync(filePath, fileContent, 'utf-8');
|
|
2363
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2364
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2365
|
+
}
|
|
2366
|
+
catch (err) {
|
|
2367
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2368
|
+
res.end(err.message);
|
|
2369
|
+
}
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
// Sub-route: GET /api/mtime — file modification time
|
|
2373
|
+
if (req.method === 'GET' && subRoute === 'api/mtime') {
|
|
2374
|
+
try {
|
|
2375
|
+
const stat = fs.statSync(filePath);
|
|
2376
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2377
|
+
res.end(JSON.stringify({ mtime: stat.mtimeMs }));
|
|
2378
|
+
}
|
|
2379
|
+
catch (err) {
|
|
2380
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2381
|
+
res.end(err.message);
|
|
2382
|
+
}
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
// Sub-route: GET /api/image, /api/video, /api/model, /api/pdf — raw binary content
|
|
2386
|
+
if (req.method === 'GET' && (subRoute === 'api/image' || subRoute === 'api/video' || subRoute === 'api/model' || subRoute === 'api/pdf')) {
|
|
2387
|
+
try {
|
|
2388
|
+
const data = fs.readFileSync(filePath);
|
|
2389
|
+
const mimeType = getMimeTypeForFile(filePath);
|
|
2390
|
+
res.writeHead(200, {
|
|
2391
|
+
'Content-Type': mimeType,
|
|
2392
|
+
'Content-Length': data.length,
|
|
2393
|
+
'Cache-Control': 'no-cache',
|
|
2394
|
+
});
|
|
2395
|
+
res.end(data);
|
|
2396
|
+
}
|
|
2397
|
+
catch (err) {
|
|
2398
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2399
|
+
res.end(err.message);
|
|
2400
|
+
}
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
// Default: serve the annotator HTML template
|
|
2404
|
+
if (req.method === 'GET' && (subRoute === '' || subRoute === undefined)) {
|
|
2405
|
+
try {
|
|
2406
|
+
const templateFile = is3D ? '3d-viewer.html' : 'open.html';
|
|
2407
|
+
const tplPath = path.resolve(__dirname, `../../../templates/${templateFile}`);
|
|
2408
|
+
let html = fs.readFileSync(tplPath, 'utf-8');
|
|
2409
|
+
const fileName = path.basename(filePath);
|
|
2410
|
+
const fileSize = fs.statSync(filePath).size;
|
|
2411
|
+
if (is3D) {
|
|
2412
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2413
|
+
html = html.replace(/\{\{FILE_PATH_JSON\}\}/g, JSON.stringify(filePath));
|
|
2414
|
+
html = html.replace(/\{\{FORMAT\}\}/g, ext);
|
|
2415
|
+
}
|
|
2416
|
+
else {
|
|
2417
|
+
html = html.replace(/\{\{FILE\}\}/g, fileName);
|
|
2418
|
+
html = html.replace(/\{\{FILE_PATH\}\}/g, filePath);
|
|
2419
|
+
html = html.replace(/\{\{BUILDER_ID\}\}/g, '');
|
|
2420
|
+
html = html.replace(/\{\{LANG\}\}/g, getLanguageForExt(ext));
|
|
2421
|
+
html = html.replace(/\{\{IS_MARKDOWN\}\}/g, String(isMarkdown));
|
|
2422
|
+
html = html.replace(/\{\{IS_IMAGE\}\}/g, String(isImage));
|
|
2423
|
+
html = html.replace(/\{\{IS_VIDEO\}\}/g, String(isVideo));
|
|
2424
|
+
html = html.replace(/\{\{IS_PDF\}\}/g, String(isPdf));
|
|
2425
|
+
html = html.replace(/\{\{FILE_SIZE\}\}/g, String(fileSize));
|
|
2426
|
+
// Inject initialization script (template loads content via fetch)
|
|
2427
|
+
let initScript;
|
|
2428
|
+
if (isImage) {
|
|
2429
|
+
initScript = `initImage(${fileSize});`;
|
|
2430
|
+
}
|
|
2431
|
+
else if (isVideo) {
|
|
2432
|
+
initScript = `initVideo(${fileSize});`;
|
|
2433
|
+
}
|
|
2434
|
+
else if (isPdf) {
|
|
2435
|
+
initScript = `initPdf(${fileSize});`;
|
|
2436
|
+
}
|
|
2437
|
+
else {
|
|
2438
|
+
initScript = `fetch('file').then(r=>r.text()).then(init);`;
|
|
2439
|
+
}
|
|
2440
|
+
html = html.replace('// FILE_CONTENT will be injected by the server', initScript);
|
|
2441
|
+
}
|
|
2442
|
+
// Handle ?line= query param for scroll-to-line
|
|
2443
|
+
const lineParam = url.searchParams.get('line');
|
|
2444
|
+
if (lineParam) {
|
|
2445
|
+
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>`;
|
|
2446
|
+
html = html.replace('</body>', `${scrollScript}</body>`);
|
|
2447
|
+
}
|
|
2448
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
2449
|
+
res.end(html);
|
|
2450
|
+
}
|
|
2451
|
+
catch (err) {
|
|
2452
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2453
|
+
res.end(`Failed to serve annotator: ${err.message}`);
|
|
2454
|
+
}
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
// Unhandled API route
|
|
2459
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
2460
|
+
res.end(JSON.stringify({ error: 'API endpoint not found', path: apiPath }));
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
// For WebSocket paths, let the upgrade handler deal with it
|
|
2464
|
+
if (isWsPath) {
|
|
2465
|
+
// WebSocket paths are handled by the upgrade handler
|
|
2466
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
2467
|
+
res.end('WebSocket connections should use ws:// protocol');
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
// If we get here for non-API, non-WS paths and React dashboard is not available
|
|
2471
|
+
if (!hasReactDashboard) {
|
|
2472
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2473
|
+
res.end('Dashboard not available');
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
// Fallback for unmatched paths
|
|
2477
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2478
|
+
res.end('Not found');
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
534
2481
|
// 404 for everything else
|
|
535
2482
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
536
2483
|
res.end('Not found');
|
|
@@ -542,8 +2489,86 @@ const server = http.createServer(async (req, res) => {
|
|
|
542
2489
|
}
|
|
543
2490
|
});
|
|
544
2491
|
// SECURITY: Bind to localhost only to prevent network exposure
|
|
545
|
-
server.listen(port, '127.0.0.1', () => {
|
|
2492
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
546
2493
|
log('INFO', `Tower server listening at http://localhost:${port}`);
|
|
2494
|
+
// Check tmux availability once at startup
|
|
2495
|
+
tmuxAvailable = checkTmux();
|
|
2496
|
+
log('INFO', `tmux available: ${tmuxAvailable}${tmuxAvailable ? '' : ' (terminals will not persist across restarts)'}`);
|
|
2497
|
+
// TICK-001: Reconcile terminal sessions from previous run
|
|
2498
|
+
await reconcileTerminalSessions();
|
|
2499
|
+
});
|
|
2500
|
+
// Initialize terminal WebSocket server (Phase 2 - Spec 0090)
|
|
2501
|
+
terminalWss = new WebSocketServer({ noServer: true });
|
|
2502
|
+
// WebSocket upgrade handler for terminal connections and proxying
|
|
2503
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
2504
|
+
const reqUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
2505
|
+
// Phase 2: Handle /ws/terminal/:id routes directly
|
|
2506
|
+
const terminalMatch = reqUrl.pathname.match(/^\/ws\/terminal\/([^/]+)$/);
|
|
2507
|
+
if (terminalMatch) {
|
|
2508
|
+
const terminalId = terminalMatch[1];
|
|
2509
|
+
const manager = getTerminalManager();
|
|
2510
|
+
const session = manager.getSession(terminalId);
|
|
2511
|
+
if (!session) {
|
|
2512
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2513
|
+
socket.destroy();
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2517
|
+
handleTerminalWebSocket(ws, session, req);
|
|
2518
|
+
});
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
// Phase 4 (Spec 0090): Handle project WebSocket routes directly
|
|
2522
|
+
// Route: /project/:encodedPath/ws/terminal/:terminalId
|
|
2523
|
+
if (!reqUrl.pathname.startsWith('/project/')) {
|
|
2524
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2525
|
+
socket.destroy();
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
const pathParts = reqUrl.pathname.split('/');
|
|
2529
|
+
// ['', 'project', base64urlPath, 'ws', 'terminal', terminalId]
|
|
2530
|
+
const encodedPath = pathParts[2];
|
|
2531
|
+
if (!encodedPath) {
|
|
2532
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2533
|
+
socket.destroy();
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
// Decode Base64URL (RFC 4648) - NOT URL encoding
|
|
2537
|
+
// Wrap in try/catch to handle malformed Base64 input gracefully
|
|
2538
|
+
let projectPath;
|
|
2539
|
+
try {
|
|
2540
|
+
projectPath = Buffer.from(encodedPath, 'base64url').toString('utf-8');
|
|
2541
|
+
// Support both POSIX (/) and Windows (C:\) paths
|
|
2542
|
+
if (!projectPath || (!projectPath.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(projectPath))) {
|
|
2543
|
+
throw new Error('Invalid project path');
|
|
2544
|
+
}
|
|
2545
|
+
// Normalize to resolve symlinks (e.g. /var/folders → /private/var/folders on macOS)
|
|
2546
|
+
projectPath = normalizeProjectPath(projectPath);
|
|
2547
|
+
}
|
|
2548
|
+
catch {
|
|
2549
|
+
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
2550
|
+
socket.destroy();
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
// Check for terminal WebSocket route: /project/:path/ws/terminal/:id
|
|
2554
|
+
const wsMatch = reqUrl.pathname.match(/^\/project\/[^/]+\/ws\/terminal\/([^/]+)$/);
|
|
2555
|
+
if (wsMatch) {
|
|
2556
|
+
const terminalId = wsMatch[1];
|
|
2557
|
+
const manager = getTerminalManager();
|
|
2558
|
+
const session = manager.getSession(terminalId);
|
|
2559
|
+
if (!session) {
|
|
2560
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2561
|
+
socket.destroy();
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
terminalWss.handleUpgrade(req, socket, head, (ws) => {
|
|
2565
|
+
handleTerminalWebSocket(ws, session, req);
|
|
2566
|
+
});
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
// Unhandled WebSocket route
|
|
2570
|
+
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
2571
|
+
socket.destroy();
|
|
547
2572
|
});
|
|
548
2573
|
// Handle uncaught errors
|
|
549
2574
|
process.on('uncaughtException', (err) => {
|