tmux-ide 2.6.0 → 2.6.1

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.
Files changed (209) hide show
  1. package/bin/cli.js +13 -5
  2. package/bin/cli.ts +1 -1
  3. package/bunfig.toml +4 -0
  4. package/package.json +11 -3
  5. package/packages/contracts/package.json +22 -0
  6. package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
  7. package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
  8. package/packages/contracts/src/actions-contract.ts +310 -0
  9. package/packages/contracts/src/actions-errors.ts +41 -0
  10. package/packages/contracts/src/domain.ts +36 -0
  11. package/packages/contracts/src/ide-config.ts +170 -0
  12. package/packages/contracts/src/index.ts +24 -0
  13. package/packages/contracts/src/lib-internal/auth.ts +13 -0
  14. package/packages/contracts/src/lib-internal/hq.ts +38 -0
  15. package/packages/contracts/src/terminals.ts +116 -0
  16. package/packages/contracts/src/tmux.ts +60 -0
  17. package/packages/contracts/src/workspace.ts +67 -0
  18. package/packages/daemon/src/agent-explain.ts +270 -0
  19. package/packages/daemon/src/attach.ts +20 -0
  20. package/packages/daemon/src/bin.ts +4 -0
  21. package/packages/daemon/src/canonical.ts +7 -0
  22. package/packages/daemon/src/cli.ts +499 -0
  23. package/packages/daemon/src/command-center/actions/contract.ts +2 -0
  24. package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
  25. package/packages/daemon/src/command-center/actions/errors.ts +105 -0
  26. package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
  27. package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
  28. package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
  29. package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
  30. package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
  31. package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
  32. package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
  33. package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
  34. package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
  35. package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
  36. package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
  37. package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
  38. package/packages/daemon/src/command-center/actions/registry.ts +149 -0
  39. package/packages/daemon/src/command-center/discovery.ts +96 -0
  40. package/packages/daemon/src/command-center/index.ts +31 -0
  41. package/packages/daemon/src/command-center/schemas.ts +85 -0
  42. package/packages/daemon/src/command-center/server.ts +1260 -0
  43. package/packages/daemon/src/command-center/ws-events.ts +316 -0
  44. package/packages/daemon/src/config.ts +549 -0
  45. package/packages/daemon/src/detect.ts +248 -0
  46. package/packages/daemon/src/doctor.ts +242 -0
  47. package/packages/daemon/src/embed.ts +5 -0
  48. package/packages/daemon/src/index.ts +12 -0
  49. package/packages/daemon/src/init.ts +211 -0
  50. package/packages/daemon/src/inspect.ts +178 -0
  51. package/packages/daemon/src/js-yaml.d.ts +10 -0
  52. package/packages/daemon/src/launch.ts +349 -0
  53. package/packages/daemon/src/lib/active-projects.ts +49 -0
  54. package/packages/daemon/src/lib/agent-discovery.ts +121 -0
  55. package/packages/daemon/src/lib/app-config.ts +336 -0
  56. package/packages/daemon/src/lib/app-settings.ts +53 -0
  57. package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
  58. package/packages/daemon/src/lib/auth/middleware.ts +56 -0
  59. package/packages/daemon/src/lib/auth/types.ts +2 -0
  60. package/packages/daemon/src/lib/auth-token.ts +5 -0
  61. package/packages/daemon/src/lib/authorship.ts +280 -0
  62. package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
  63. package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
  64. package/packages/daemon/src/lib/daemon-embed.ts +782 -0
  65. package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
  66. package/packages/daemon/src/lib/daemon.ts +79 -0
  67. package/packages/daemon/src/lib/dot-path.ts +17 -0
  68. package/packages/daemon/src/lib/errors.ts +67 -0
  69. package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
  70. package/packages/daemon/src/lib/launch-plan.ts +90 -0
  71. package/packages/daemon/src/lib/log.ts +134 -0
  72. package/packages/daemon/src/lib/output.ts +76 -0
  73. package/packages/daemon/src/lib/project-init-runner.ts +150 -0
  74. package/packages/daemon/src/lib/project-inspect.ts +80 -0
  75. package/packages/daemon/src/lib/project-onboard.ts +149 -0
  76. package/packages/daemon/src/lib/project-probe.ts +92 -0
  77. package/packages/daemon/src/lib/project-registry.ts +296 -0
  78. package/packages/daemon/src/lib/session-monitor.ts +122 -0
  79. package/packages/daemon/src/lib/session-options.ts +100 -0
  80. package/packages/daemon/src/lib/shell.ts +8 -0
  81. package/packages/daemon/src/lib/sizes.ts +36 -0
  82. package/packages/daemon/src/lib/skill-sync.ts +155 -0
  83. package/packages/daemon/src/lib/slugify.ts +10 -0
  84. package/packages/daemon/src/lib/terminals-store.ts +125 -0
  85. package/packages/daemon/src/lib/update-check.ts +298 -0
  86. package/packages/daemon/src/lib/update.ts +158 -0
  87. package/packages/daemon/src/lib/workspace-registry.ts +229 -0
  88. package/packages/daemon/src/lib/worktree.ts +289 -0
  89. package/packages/daemon/src/lib/yaml-io.ts +27 -0
  90. package/packages/daemon/src/ls.ts +40 -0
  91. package/packages/daemon/src/restart.ts +24 -0
  92. package/packages/daemon/src/restore.ts +514 -0
  93. package/packages/daemon/src/schemas/domain.ts +2 -0
  94. package/packages/daemon/src/schemas/filesystem.ts +34 -0
  95. package/packages/daemon/src/schemas/ide-config.ts +2 -0
  96. package/packages/daemon/src/schemas/index.ts +59 -0
  97. package/packages/daemon/src/schemas/inspect.ts +67 -0
  98. package/packages/daemon/src/schemas/registry.ts +55 -0
  99. package/packages/daemon/src/schemas/ws-events.ts +135 -0
  100. package/packages/daemon/src/send.ts +171 -0
  101. package/packages/daemon/src/server/README.md +15 -0
  102. package/packages/daemon/src/server/index.ts +74 -0
  103. package/packages/daemon/src/server/pty-bridge.ts +532 -0
  104. package/packages/daemon/src/server/standalone.ts +18 -0
  105. package/packages/daemon/src/server/ws-route.ts +483 -0
  106. package/packages/daemon/src/status.ts +59 -0
  107. package/packages/daemon/src/stop.ts +28 -0
  108. package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
  109. package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
  110. package/packages/daemon/src/terminal/README.md +92 -0
  111. package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
  112. package/packages/daemon/src/tui/chrome/chip.ts +30 -0
  113. package/packages/daemon/src/tui/chrome/events.ts +119 -0
  114. package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
  115. package/packages/daemon/src/tui/chrome/menu.ts +289 -0
  116. package/packages/daemon/src/tui/chrome/notify.ts +195 -0
  117. package/packages/daemon/src/tui/chrome/panels.ts +111 -0
  118. package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
  119. package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
  120. package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
  121. package/packages/daemon/src/tui/chrome/updater.ts +428 -0
  122. package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
  123. package/packages/daemon/src/tui/compiled.ts +113 -0
  124. package/packages/daemon/src/tui/detect/classify.ts +193 -0
  125. package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
  126. package/packages/daemon/src/tui/detect/manifest.ts +184 -0
  127. package/packages/daemon/src/tui/detect/manifests.ts +226 -0
  128. package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
  129. package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
  130. package/packages/daemon/src/tui/integrations/claude.ts +176 -0
  131. package/packages/daemon/src/tui/integrations/offer.ts +145 -0
  132. package/packages/daemon/src/tui/main.ts +70 -0
  133. package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
  134. package/packages/daemon/src/tui/mirror/control.ts +97 -0
  135. package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
  136. package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
  137. package/packages/daemon/src/tui/team/CONTROL.md +50 -0
  138. package/packages/daemon/src/tui/team/entry.ts +11 -0
  139. package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
  140. package/packages/daemon/src/tui/team/home.ts +170 -0
  141. package/packages/daemon/src/tui/team/index.tsx +1521 -0
  142. package/packages/daemon/src/tui/team/input.ts +34 -0
  143. package/packages/daemon/src/tui/team/keymap.ts +127 -0
  144. package/packages/daemon/src/tui/team/mouse.ts +29 -0
  145. package/packages/daemon/src/tui/team/nav.ts +31 -0
  146. package/packages/daemon/src/tui/team/preview.ts +34 -0
  147. package/packages/daemon/src/tui/team/projects.ts +191 -0
  148. package/packages/daemon/src/tui/team/report.ts +73 -0
  149. package/packages/daemon/src/tui/team/sessions.ts +344 -0
  150. package/packages/daemon/src/tui/team/tree.ts +62 -0
  151. package/packages/daemon/src/types.ts +13 -0
  152. package/packages/daemon/src/ui/index.ts +32 -0
  153. package/packages/daemon/src/ui/terminal/index.ts +9 -0
  154. package/packages/daemon/src/ui/types.ts +91 -0
  155. package/packages/daemon/src/ui/web/base.css +80 -0
  156. package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
  157. package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
  158. package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
  159. package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
  160. package/packages/daemon/src/ui/web/hooks.ts +106 -0
  161. package/packages/daemon/src/ui/web/index.ts +27 -0
  162. package/packages/daemon/src/ui/web/render.ts +77 -0
  163. package/packages/daemon/src/ui/web/utils/color.ts +27 -0
  164. package/packages/daemon/src/validate.ts +217 -0
  165. package/packages/daemon/src/widgets/changes/README.md +3 -0
  166. package/packages/daemon/src/widgets/changes/index.tsx +691 -0
  167. package/packages/daemon/src/widgets/config/README.md +3 -0
  168. package/packages/daemon/src/widgets/config/index.tsx +481 -0
  169. package/packages/daemon/src/widgets/explorer/README.md +3 -0
  170. package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
  171. package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
  172. package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
  173. package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
  174. package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
  175. package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
  176. package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
  177. package/packages/daemon/src/widgets/lib/files.ts +88 -0
  178. package/packages/daemon/src/widgets/lib/git.ts +88 -0
  179. package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
  180. package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
  181. package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
  182. package/packages/daemon/src/widgets/lib/theme.ts +194 -0
  183. package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
  184. package/packages/daemon/src/widgets/preview/README.md +3 -0
  185. package/packages/daemon/src/widgets/preview/index.tsx +416 -0
  186. package/packages/daemon/src/widgets/resolve.ts +121 -0
  187. package/packages/daemon/src/widgets/setup/README.md +3 -0
  188. package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
  189. package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
  190. package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
  191. package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
  192. package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
  193. package/packages/daemon/src/widgets/setup/index.tsx +341 -0
  194. package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
  195. package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
  196. package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
  197. package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
  198. package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
  199. package/packages/tmux-bridge/package.json +22 -0
  200. package/packages/tmux-bridge/src/errors.ts +28 -0
  201. package/packages/tmux-bridge/src/index.ts +31 -0
  202. package/packages/tmux-bridge/src/monitor.ts +77 -0
  203. package/packages/tmux-bridge/src/panes.ts +136 -0
  204. package/packages/tmux-bridge/src/runner.test.ts +501 -0
  205. package/packages/tmux-bridge/src/runner.ts +91 -0
  206. package/packages/tmux-bridge/src/sessions.ts +126 -0
  207. package/packages/tmux-bridge/src/targeting.test.ts +107 -0
  208. package/packages/tmux-bridge/src/targeting.ts +90 -0
  209. package/scripts/postinstall.js +26 -2
@@ -0,0 +1,1260 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
4
+ import { join, dirname, basename } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Hono, type MiddlewareHandler } from "hono";
7
+ import { streamSSE } from "hono/streaming";
8
+ import { cors } from "hono/cors";
9
+ import {
10
+ discoverSessions,
11
+ buildOverviews,
12
+ buildProjectDetail,
13
+ type SessionOverview,
14
+ } from "./discovery.ts";
15
+ import {
16
+ listSessionPanes,
17
+ sendCommand,
18
+ sendEnterToPane,
19
+ sendLiteralToPane,
20
+ sendText,
21
+ getPaneBusyStatus,
22
+ } from "../widgets/lib/pane-comms.ts";
23
+ import { resolvePane } from "../send.ts";
24
+ import { getSessionState, killSession, stopSessionMonitor } from "@tmux-ide/tmux-bridge";
25
+ import { readConfig, writeConfig } from "../lib/yaml-io.ts";
26
+ import { IdeConfigSchema } from "../schemas/ide-config.ts";
27
+ import { getLogBuffer, subscribeLogs, type LogEntry } from "../lib/log.ts";
28
+ import { zValidator } from "@hono/zod-validator";
29
+ import {
30
+ getDefaultWorkspaceRegistry,
31
+ WorkspaceAlreadyExistsError,
32
+ WorkspaceNotFoundError,
33
+ } from "../lib/workspace-registry.ts";
34
+ import { AddWorkspaceRequestSchemaZ } from "@tmux-ide/contracts";
35
+ import { sendCommandSchema } from "./schemas.ts";
36
+ import {
37
+ createScriptTerminalId,
38
+ terminalCreateRequestSchema,
39
+ terminalRenameRequestSchema,
40
+ type Terminal,
41
+ type TerminalListResponse,
42
+ type TerminalRuntime,
43
+ } from "@tmux-ide/contracts";
44
+ import {
45
+ deleteTerminal as deleteTerminalRecord,
46
+ loadTerminals,
47
+ renameTerminal as renameTerminalRecord,
48
+ upsertTerminal as upsertTerminalRecord,
49
+ } from "../lib/terminals-store.ts";
50
+ import { defaultPtyBridgeRegistry } from "../server/ws-route.ts";
51
+ import { broadcastTerminalsChanged } from "./ws-events.ts";
52
+ import { AuthService } from "../lib/auth/auth-service.ts";
53
+ import { authMiddleware } from "../lib/auth/middleware.ts";
54
+ import type { AuthConfig } from "../lib/auth/types.ts";
55
+ import { handleWsEventsConnection, broadcastInitOutput, broadcastInitError } from "./ws-events.ts";
56
+ import { createActionDispatcher } from "./actions/dispatcher.ts";
57
+ import {
58
+ listProjects,
59
+ getProject,
60
+ registerProject,
61
+ unregisterProject,
62
+ refreshProject,
63
+ ProjectAlreadyRegisteredError,
64
+ ProjectDirNotFoundError,
65
+ ProjectNotFoundError,
66
+ } from "../lib/project-registry.ts";
67
+ import {
68
+ runInit,
69
+ ProjectInitFailedError,
70
+ ProjectInitTimeoutError,
71
+ } from "../lib/project-init-runner.ts";
72
+ import {
73
+ RegisterProjectRequestSchemaZ,
74
+ InitProjectRequestSchemaZ,
75
+ type ProjectTemplate,
76
+ } from "../schemas/registry.ts";
77
+ import { OnboardProjectRequestSchemaZ } from "../schemas/inspect.ts";
78
+ import { SandboxViolationError, assertInsideSandbox } from "../lib/filesystem-browser.ts";
79
+ import { inspectProject, InspectDirNotFoundError } from "../lib/project-inspect.ts";
80
+ import {
81
+ composeIdeYmlConfig,
82
+ assertNoExistingIdeYml,
83
+ OnboardConflictError,
84
+ OnboardInvalidInputError,
85
+ } from "../lib/project-onboard.ts";
86
+ import { realpathSync } from "node:fs";
87
+ import { homedir } from "node:os";
88
+ import { isAbsolute, resolve as pathResolve } from "node:path";
89
+ import { randomUUID } from "node:crypto";
90
+ import { WebSocketServer } from "ws";
91
+ export interface CreateAppOptions {
92
+ authService?: AuthService;
93
+ authConfig?: AuthConfig;
94
+ remoteAccess?: {
95
+ bindHostname?: string;
96
+ token?: string | null;
97
+ localBypassToken?: string | null;
98
+ };
99
+ }
100
+
101
+ const __dirname = dirname(fileURLToPath(import.meta.url));
102
+ // Resolve our package.json's version. The lookup needs to handle BOTH
103
+ // dev (this file lives at src/command-center/, so the workspace root
104
+ // is two levels up) AND the packaged Electron bundle (this file is
105
+ // flattened into app.asar/dist-electron/, so the bundled package.json
106
+ // is ONE level up; two levels up escapes the asar). Try the candidates
107
+ // in order; fall back to a sentinel if neither is readable.
108
+ function resolvePackageVersion(): string {
109
+ const candidates = [join(__dirname, "../../package.json"), join(__dirname, "../package.json")];
110
+ for (const candidate of candidates) {
111
+ try {
112
+ const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { version?: string };
113
+ if (typeof parsed.version === "string") return parsed.version;
114
+ } catch {
115
+ /* try the next candidate */
116
+ }
117
+ }
118
+ return "0.0.0";
119
+ }
120
+ const pkgVersion: string = resolvePackageVersion();
121
+ let projectStreamConnections = 0;
122
+
123
+ function bearerToken(authHeader: string | undefined): string | null {
124
+ if (!authHeader?.startsWith("Bearer ")) return null;
125
+ return authHeader.slice("Bearer ".length);
126
+ }
127
+
128
+ function requireAuth(token: string | null, localBypassToken: string | null): MiddlewareHandler {
129
+ return async (c, next) => {
130
+ if (!token) return next();
131
+ const url = new URL(c.req.url);
132
+ const suppliedToken =
133
+ bearerToken(c.req.header("Authorization")) ?? url.searchParams.get("token");
134
+ if (suppliedToken === token || (localBypassToken && suppliedToken === localBypassToken)) {
135
+ return next();
136
+ }
137
+ return c.json({ error: "Remote access token required" }, 401);
138
+ };
139
+ }
140
+
141
+ function remoteAccessAuth(options: CreateAppOptions): {
142
+ token: string | null;
143
+ localBypassToken: string | null;
144
+ } {
145
+ const bindHostname = options.remoteAccess?.bindHostname ?? "127.0.0.1";
146
+ return {
147
+ token: bindHostname === "127.0.0.1" ? null : (options.remoteAccess?.token ?? null),
148
+ localBypassToken: options.remoteAccess?.localBypassToken ?? null,
149
+ };
150
+ }
151
+
152
+ const sseMetrics = {
153
+ connections: 0,
154
+ messagesSent: 0,
155
+ };
156
+
157
+ export function getSseMetrics(): { connections: number; messagesSent: number } {
158
+ return { ...sseMetrics };
159
+ }
160
+
161
+ /**
162
+ * Resolve a BottomPanel Output-tab channel id to a `LogEntry` predicate.
163
+ * Channels are simple component-prefix filters over the single in-process
164
+ * structured logger. Returns `null` for unknown channels (handler emits
165
+ * 404). Add new channels here when adding new log components.
166
+ */
167
+ function matchLogChannel(channel: string): ((entry: LogEntry) => boolean) | null {
168
+ switch (channel) {
169
+ case "daemon":
170
+ return () => true;
171
+ case "hq":
172
+ return (entry) => entry.component.startsWith("hq") || entry.component.startsWith("remote");
173
+ case "watchdog":
174
+ return (entry) => entry.component.startsWith("watchdog");
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function freezePayload<T>(payload: T): T {
181
+ if (payload && typeof payload === "object") {
182
+ for (const value of Object.values(payload as Record<string, unknown>)) {
183
+ freezePayload(value);
184
+ }
185
+ Object.freeze(payload);
186
+ }
187
+ return payload;
188
+ }
189
+
190
+ type DiscoveredSession = ReturnType<typeof discoverSessions>[number];
191
+
192
+ function buildProjectStreamSnapshot(session: DiscoveredSession) {
193
+ return {
194
+ project: buildProjectDetail(session),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Resolve a user-supplied directory to a canonical absolute path and verify
200
+ * it lives inside the filesystem-browser sandbox. Used by `/api/filesystem/
201
+ * inspect` and `/api/projects/onboard` so unregistered directories get the
202
+ * same protection as the directory browser. Returns either the canonical
203
+ * path or a structured error suitable for `c.json`.
204
+ *
205
+ * Tilde-expansion mirrors `/api/filesystem/browse`: `~` and `~/sub` map to
206
+ * `homedir()`. The sandbox roots are owned by `assertInsideSandbox`.
207
+ */
208
+ type SandboxResolveOk = { canonical: string };
209
+ type SandboxResolveErr = {
210
+ error: "invalid-path" | "not-found" | "outside-sandbox";
211
+ message: string;
212
+ status: 400 | 403 | 404;
213
+ };
214
+ function sandboxResolveDir(rawDir: string): SandboxResolveOk | SandboxResolveErr {
215
+ const trimmed = rawDir.trim();
216
+ if (!trimmed) return { error: "invalid-path", message: "Path must not be empty", status: 400 };
217
+ if (trimmed.includes("\0")) {
218
+ return { error: "invalid-path", message: "Path contains a null byte", status: 400 };
219
+ }
220
+ const home =
221
+ process.env.TMUX_IDE_HOME_OVERRIDE && process.env.TMUX_IDE_HOME_OVERRIDE.trim().length > 0
222
+ ? process.env.TMUX_IDE_HOME_OVERRIDE
223
+ : homedir();
224
+ let candidate = trimmed;
225
+ if (candidate === "~") {
226
+ candidate = home;
227
+ } else if (candidate.startsWith("~/")) {
228
+ candidate = `${home.replace(/\/+$/, "")}/${candidate.slice(2)}`;
229
+ }
230
+ if (!isAbsolute(candidate)) {
231
+ return { error: "invalid-path", message: "Path must be absolute", status: 400 };
232
+ }
233
+ const resolved = pathResolve(candidate);
234
+ let canonical: string;
235
+ try {
236
+ canonical = realpathSync(resolved);
237
+ } catch (err) {
238
+ const code = (err as NodeJS.ErrnoException).code;
239
+ if (code === "ENOENT" || code === "ENOTDIR") {
240
+ return {
241
+ error: "not-found",
242
+ message: `Path "${resolved}" does not exist`,
243
+ status: 404,
244
+ };
245
+ }
246
+ throw err;
247
+ }
248
+ try {
249
+ assertInsideSandbox(canonical, home);
250
+ } catch (err) {
251
+ if (err instanceof SandboxViolationError) {
252
+ return { error: "outside-sandbox", message: err.message, status: 403 };
253
+ }
254
+ throw err;
255
+ }
256
+ return { canonical };
257
+ }
258
+
259
+ export function createApp(options: CreateAppOptions = {}): Hono {
260
+ const authConfig: AuthConfig = options.authConfig ?? { method: "none", token_expiry: 86400 };
261
+ const authService = options.authService ?? new AuthService();
262
+
263
+ const app = new Hono();
264
+
265
+ // Allow cross-origin (Next.js dashboard, Tailscale, etc.)
266
+ app.use("/*", cors());
267
+
268
+ // Remote access bearer gate. Local Electron access uses a per-daemon
269
+ // bypass token from preload; loopback IPs are not implicitly trusted.
270
+ const remoteAuth = remoteAccessAuth(options);
271
+ app.use("/api/*", requireAuth(remoteAuth.token, remoteAuth.localBypassToken));
272
+
273
+ // Auth middleware — passes through when method is "none"
274
+ app.use("/*", authMiddleware(authService, authConfig));
275
+
276
+ // Global error handler
277
+ app.onError((err, c) => {
278
+ console.error("[command-center]", err.message);
279
+ return c.json({ error: err.message }, 500);
280
+ });
281
+
282
+ // --- Auth routes (always available, bypassed by middleware) ---
283
+
284
+ app.post("/api/auth/challenge", async (c) => {
285
+ const body = await c.req.json();
286
+ const userId = body.userId ?? authService.getCurrentUser();
287
+ const challenge = authService.createChallenge(userId);
288
+ return c.json(challenge);
289
+ });
290
+
291
+ app.post("/api/auth/verify", async (c) => {
292
+ const body = await c.req.json();
293
+ const result = await authService.authenticateWithSSHKey({
294
+ publicKey: body.publicKey,
295
+ signature: body.signature,
296
+ challengeId: body.challengeId,
297
+ });
298
+ if (!result.success) {
299
+ return c.json({ error: result.error }, 401);
300
+ }
301
+ return c.json({ token: result.token, userId: result.userId });
302
+ });
303
+
304
+ app.post("/api/auth/token", async (c) => {
305
+ if (authConfig.method !== "none") {
306
+ return c.json({ error: "Direct token generation requires auth method 'none'" }, 403);
307
+ }
308
+ const body = await c.req.json();
309
+ const userId = body.userId ?? authService.getCurrentUser();
310
+ const token = authService.generateToken(userId);
311
+ return c.json({ token, userId });
312
+ });
313
+
314
+ // --- v2 action dispatcher (single typed entry-point for state-changing
315
+ // project / terminal operations — see src/command-center/actions/) ---
316
+
317
+ app.post("/api/v2/action/:name", createActionDispatcher());
318
+
319
+ app.get("/api/widget/:name/spawn", async (c) => {
320
+ const { resolveWidgetSpawn, WIDGET_TYPES } = await import("../widgets/resolve.ts");
321
+ const name = c.req.param("name");
322
+ if (!WIDGET_TYPES.includes(name)) {
323
+ return c.json({ error: `unknown widget: ${name}`, available: WIDGET_TYPES }, 404);
324
+ }
325
+ const session = c.req.query("session");
326
+ const dir = c.req.query("dir");
327
+ if (!session || !dir) {
328
+ return c.json({ error: "session and dir query params are required" }, 400);
329
+ }
330
+ const target = c.req.query("target") ?? null;
331
+ const themeRaw = c.req.query("theme");
332
+ let theme: unknown = null;
333
+ if (themeRaw) {
334
+ try {
335
+ theme = JSON.parse(themeRaw);
336
+ } catch {
337
+ return c.json({ error: "theme must be valid JSON" }, 400);
338
+ }
339
+ }
340
+ try {
341
+ const spec = resolveWidgetSpawn(name, {
342
+ session,
343
+ dir,
344
+ target,
345
+ theme: theme as never,
346
+ });
347
+ return c.json(spec);
348
+ } catch (err) {
349
+ return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
350
+ }
351
+ });
352
+
353
+ // T067: /healthz — minimal liveness probe used by daemon-client.
354
+ // Intentionally NOT under /api so it bypasses the auth middleware on
355
+ // every consumer. Returns ok + version + uptime (ms since boot).
356
+ const HEALTHZ_BOOTED_AT = Date.now();
357
+ app.get("/healthz", (c) => {
358
+ return c.json({
359
+ ok: true,
360
+ version: process.env.npm_package_version ?? "dev",
361
+ uptimeMs: Date.now() - HEALTHZ_BOOTED_AT,
362
+ });
363
+ });
364
+
365
+ app.get("/api/sessions", (c) => {
366
+ const sessions = discoverSessions();
367
+ const overviews = buildOverviews(sessions);
368
+ return c.json({ sessions: overviews });
369
+ });
370
+
371
+ // ---------------------------------------------------------------------
372
+ // /api/workspaces — registry-backed CRUD. The registry is the source of
373
+ // truth for which projects the daemon serves. WS frames `workspace.added`
374
+ // and `workspace.removed` are emitted by the registry's emitter, picked
375
+ // up by ws-events' ensureWorkspaceRegistryListener and fanned to clients.
376
+ // ---------------------------------------------------------------------
377
+
378
+ app.get("/api/workspaces", (c) => {
379
+ const registry = getDefaultWorkspaceRegistry();
380
+ return c.json({ workspaces: registry.list() });
381
+ });
382
+
383
+ app.get("/api/workspaces/:name", (c) => {
384
+ const name = c.req.param("name");
385
+ const registry = getDefaultWorkspaceRegistry();
386
+ const workspace = registry.get(name);
387
+ if (!workspace) return c.json({ error: "Workspace not found" }, 404);
388
+ return c.json({ workspace });
389
+ });
390
+
391
+ app.post("/api/workspaces", zValidator("json", AddWorkspaceRequestSchemaZ), (c) => {
392
+ const body = c.req.valid("json");
393
+ const registry = getDefaultWorkspaceRegistry();
394
+ const name = body.name ?? basename(body.projectDir);
395
+ if (!name || name.length === 0) {
396
+ return c.json({ error: "Cannot derive workspace name from projectDir" }, 400);
397
+ }
398
+ try {
399
+ const workspace = registry.add({
400
+ name,
401
+ sessionName: body.sessionName,
402
+ projectDir: body.projectDir,
403
+ ideConfigPath: body.ideConfigPath ?? null,
404
+ });
405
+ return c.json({ workspace }, 201);
406
+ } catch (err) {
407
+ if (err instanceof WorkspaceAlreadyExistsError) {
408
+ return c.json({ error: err.message, code: err.code }, 409);
409
+ }
410
+ throw err;
411
+ }
412
+ });
413
+
414
+ // GET /api/chat/providers — discovery (claude-code / codex binaries on
415
+ // PATH). Distinct from `/api/providers`, which serves the redacted
416
+ // user-configured `ProviderInstanceSummary` set (T079).
417
+ app.delete("/api/workspaces/:name", (c) => {
418
+ const name = c.req.param("name");
419
+ const registry = getDefaultWorkspaceRegistry();
420
+ try {
421
+ registry.remove(name);
422
+ return c.body(null, 204);
423
+ } catch (err) {
424
+ if (err instanceof WorkspaceNotFoundError) {
425
+ return c.json({ error: err.message, code: err.code }, 404);
426
+ }
427
+ throw err;
428
+ }
429
+ });
430
+
431
+ app.get("/api/project/:name", (c) => {
432
+ const name = c.req.param("name");
433
+ const sessions = discoverSessions();
434
+ const session = sessions.find((s) => s.name === name);
435
+ if (!session) {
436
+ return c.json({ error: "Session not found" }, 404);
437
+ }
438
+ const detail = buildProjectDetail(session);
439
+ return c.json({ ...detail });
440
+ });
441
+
442
+ app.get("/api/project/:name/panes", (c) => {
443
+ const name = c.req.param("name");
444
+ let panes: ReturnType<typeof listSessionPanes>;
445
+ try {
446
+ panes = listSessionPanes(name);
447
+ } catch {
448
+ return c.json({ error: "Session not found" }, 404);
449
+ }
450
+ if (panes.length === 0) {
451
+ // Verify the session actually exists before returning empty
452
+ const sessions = discoverSessions();
453
+ if (!sessions.find((s) => s.name === name)) {
454
+ return c.json({ error: "Session not found" }, 404);
455
+ }
456
+ }
457
+ return c.json({
458
+ panes: panes.map((p) => ({
459
+ id: p.id,
460
+ index: p.index,
461
+ title: p.title,
462
+ currentCommand: p.currentCommand,
463
+ width: p.width,
464
+ height: p.height,
465
+ active: p.active,
466
+ role: p.role,
467
+ name: p.name,
468
+ type: p.type,
469
+ })),
470
+ });
471
+ });
472
+
473
+ app.get("/api/project/:name/terminals", async (c) => {
474
+ const name = c.req.param("name");
475
+ const sessions = discoverSessions();
476
+ const session = sessions.find((s) => s.name === name);
477
+ if (!session) return c.json({ error: "Session not found" }, 404);
478
+ const records = loadTerminals(session.dir);
479
+ const terminals = records.map((t) => {
480
+ const bridge = defaultPtyBridgeRegistry.peek(t.id) as
481
+ | (Parameters<typeof defaultPtyBridgeRegistry.peek>[0] extends never ? never : null)
482
+ | (Record<string, unknown> & {
483
+ running?: boolean;
484
+ cols?: number | null;
485
+ rows?: number | null;
486
+ getReplayBuffer?: () => Buffer;
487
+ })
488
+ | null;
489
+ let runtime: TerminalRuntime = { running: false };
490
+ if (bridge) {
491
+ const cols = typeof bridge.cols === "number" ? bridge.cols : undefined;
492
+ const rows = typeof bridge.rows === "number" ? bridge.rows : undefined;
493
+ const replay =
494
+ typeof bridge.getReplayBuffer === "function"
495
+ ? bridge.getReplayBuffer().byteLength
496
+ : undefined;
497
+ runtime = {
498
+ running: bridge.running !== false,
499
+ ...(cols !== undefined ? { cols } : {}),
500
+ ...(rows !== undefined ? { rows } : {}),
501
+ ...(replay !== undefined ? { replayBytes: replay } : {}),
502
+ };
503
+ }
504
+ return { ...t, runtime };
505
+ });
506
+ return c.json({ terminals } satisfies TerminalListResponse);
507
+ });
508
+
509
+ app.post(
510
+ "/api/project/:name/terminals",
511
+ zValidator("json", terminalCreateRequestSchema),
512
+ async (c) => {
513
+ const name = c.req.param("name");
514
+ const sessions = discoverSessions();
515
+ const session = sessions.find((s) => s.name === name);
516
+ if (!session) return c.json({ error: "Session not found" }, 404);
517
+ const body = c.req.valid("json");
518
+ // Pick the id: explicit > deterministic (when script provided) >
519
+ // UUID. The script-derived id collapse is the load-bearing bit
520
+ // for "two callers asking for the same run-script tab share a
521
+ // bridge + scrollback".
522
+ let id = body.id;
523
+ let scripted = false;
524
+ const kind = body.kind ?? "shell";
525
+ if (!id && body.script) {
526
+ id = await createScriptTerminalId({
527
+ projectId: name,
528
+ scopeId: body.scopeId,
529
+ kind,
530
+ script: body.script,
531
+ });
532
+ scripted = true;
533
+ }
534
+ if (!id) id = randomUUID();
535
+ try {
536
+ const upsertInput: Parameters<typeof upsertTerminalRecord>[1] = {
537
+ id,
538
+ projectId: name,
539
+ scopeId: body.scopeId,
540
+ name: body.name,
541
+ kind,
542
+ };
543
+ if (scripted) upsertInput.scripted = true;
544
+ const record = upsertTerminalRecord(session.dir, upsertInput);
545
+ broadcastTerminalsChanged(name);
546
+ return c.json({ ok: true, terminal: record satisfies Terminal });
547
+ } catch (err) {
548
+ return c.json({ error: (err as Error).message }, 400);
549
+ }
550
+ },
551
+ );
552
+
553
+ app.post(
554
+ "/api/project/:name/terminals/:id/rename",
555
+ zValidator("json", terminalRenameRequestSchema),
556
+ async (c) => {
557
+ const name = c.req.param("name");
558
+ const id = c.req.param("id");
559
+ const sessions = discoverSessions();
560
+ const session = sessions.find((s) => s.name === name);
561
+ if (!session) return c.json({ error: "Session not found" }, 404);
562
+ try {
563
+ const record = renameTerminalRecord(session.dir, id, c.req.valid("json").name);
564
+ if (!record) return c.json({ error: "Terminal not found" }, 404);
565
+ broadcastTerminalsChanged(name);
566
+ return c.json({ ok: true, terminal: record satisfies Terminal });
567
+ } catch (err) {
568
+ return c.json({ error: (err as Error).message }, 400);
569
+ }
570
+ },
571
+ );
572
+
573
+ app.delete("/api/project/:name/terminals/:id", async (c) => {
574
+ const name = c.req.param("name");
575
+ const id = c.req.param("id");
576
+ const sessions = discoverSessions();
577
+ const session = sessions.find((s) => s.name === name);
578
+ if (!session) return c.json({ error: "Session not found" }, 404);
579
+ const removedRecord = deleteTerminalRecord(session.dir, id);
580
+ // Kill the live bridge too — keep store + registry consistent.
581
+ const killed = defaultPtyBridgeRegistry.delete(id);
582
+ if (!removedRecord && !killed) {
583
+ return c.json({ error: "Terminal not found" }, 404);
584
+ }
585
+ broadcastTerminalsChanged(name);
586
+ return c.json({ ok: true });
587
+ });
588
+
589
+ // --- Project event/stream endpoints ---
590
+ // The orchestrator/task event feed moved out of tmux-ide (now in sfora.ai),
591
+ // so these surfaces only carry the live pane/project snapshot.
592
+
593
+ app.get("/api/project/:name/events", (c) => {
594
+ const name = c.req.param("name");
595
+ const session = discoverSessions().find((s) => s.name === name);
596
+ if (!session) {
597
+ return c.json({ error: "Session not found" }, 404);
598
+ }
599
+ return c.json({ events: [] });
600
+ });
601
+
602
+ app.get("/api/project/:name/stream", (c) => {
603
+ const name = c.req.param("name");
604
+ const session = discoverSessions().find((s) => s.name === name);
605
+ if (!session) {
606
+ return c.json({ error: "Session not found" }, 404);
607
+ }
608
+
609
+ return streamSSE(c, async (stream) => {
610
+ projectStreamConnections += 1;
611
+ sseMetrics.connections = projectStreamConnections;
612
+ let closed = false;
613
+ let previousSnapshotHash = "";
614
+ let lastPing = Date.now();
615
+
616
+ function writeSse(event: string, payload: unknown): void {
617
+ sseMetrics.messagesSent += 1;
618
+ void stream.writeSSE({ event, data: JSON.stringify(freezePayload(payload)) });
619
+ }
620
+
621
+ function writeChanges(currentSession: DiscoveredSession): void {
622
+ const snapshot = buildProjectStreamSnapshot(currentSession);
623
+ const snapshotHash = JSON.stringify(snapshot);
624
+ if (snapshotHash !== previousSnapshotHash) {
625
+ writeSse("snapshot", snapshot);
626
+ previousSnapshotHash = snapshotHash;
627
+ }
628
+ }
629
+
630
+ try {
631
+ stream.onAbort(() => {
632
+ closed = true;
633
+ });
634
+ writeChanges(session);
635
+ while (!closed) {
636
+ await stream.sleep(250);
637
+ const current = discoverSessions().find((candidate) => candidate.name === name);
638
+ if (!current) break;
639
+ writeChanges(current);
640
+ const now = Date.now();
641
+ if (now - lastPing >= 25_000) {
642
+ writeSse("ping", { at: new Date().toISOString() });
643
+ lastPing = now;
644
+ }
645
+ }
646
+ } finally {
647
+ projectStreamConnections = Math.max(0, projectStreamConnections - 1);
648
+ sseMetrics.connections = projectStreamConnections;
649
+ }
650
+ });
651
+ });
652
+
653
+ app.post("/api/project/:name/inject", async (c) => {
654
+ const name = c.req.param("name");
655
+ const sessions = discoverSessions();
656
+ const session = sessions.find((s) => s.name === name);
657
+ if (!session) {
658
+ return c.json({ error: "Session not found" }, 404);
659
+ }
660
+
661
+ let body: unknown;
662
+ try {
663
+ body = await c.req.json();
664
+ } catch {
665
+ return c.json({ error: "Invalid JSON body" }, 400);
666
+ }
667
+
668
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
669
+ return c.json({ error: "Invalid request body" }, 400);
670
+ }
671
+
672
+ const text = (body as { text?: unknown }).text;
673
+ const paneId = (body as { paneId?: unknown }).paneId;
674
+ const sendEnter = (body as { sendEnter?: unknown }).sendEnter;
675
+
676
+ if (typeof text !== "string" || text.trim().length === 0) {
677
+ return c.json({ error: "text must be a non-empty string" }, 400);
678
+ }
679
+ if (paneId !== undefined && (typeof paneId !== "string" || !/^%\d+$/.test(paneId))) {
680
+ return c.json({ error: "paneId must match /^%\\d+$/" }, 400);
681
+ }
682
+ if (sendEnter !== undefined && typeof sendEnter !== "boolean") {
683
+ return c.json({ error: "sendEnter must be a boolean" }, 400);
684
+ }
685
+
686
+ const panes = listSessionPanes(name);
687
+ const pane = paneId
688
+ ? panes.find((candidate) => candidate.id === paneId)
689
+ : panes.find((p) => p.active);
690
+ if (!pane) {
691
+ return c.json({ error: "Pane not found" }, 404);
692
+ }
693
+
694
+ sendLiteralToPane(name, pane.id, text);
695
+ if (sendEnter) sendEnterToPane(name, pane.id);
696
+
697
+ return c.json({ ok: true });
698
+ });
699
+
700
+ // Send message to a pane by name/title/role/ID
701
+ app.post("/api/project/:name/send", zValidator("json", sendCommandSchema), async (c) => {
702
+ const name = c.req.param("name");
703
+ const sessions = discoverSessions();
704
+ const session = sessions.find((s) => s.name === name);
705
+ if (!session) {
706
+ return c.json({ error: "Session not found" }, 404);
707
+ }
708
+
709
+ const { target, message, noEnter } = c.req.valid("json");
710
+
711
+ const panes = listSessionPanes(name);
712
+ const pane = resolvePane(panes, target);
713
+ if (!pane) {
714
+ const available = panes.map((p) => ({
715
+ id: p.id,
716
+ title: p.title,
717
+ name: p.name,
718
+ role: p.role,
719
+ }));
720
+ return c.json({ error: "Pane not found", target, available }, 404);
721
+ }
722
+
723
+ const busyStatus = getPaneBusyStatus(name, pane.id);
724
+
725
+ // Collapse multiline for agent panes
726
+ const prepared = busyStatus === "agent" ? message.replace(/\n+/g, " ").trim() : message;
727
+
728
+ if (noEnter) {
729
+ sendText(name, pane.id, prepared);
730
+ } else {
731
+ sendCommand(name, pane.id, prepared);
732
+ }
733
+
734
+ return c.json({
735
+ ok: true,
736
+ session: name,
737
+ target: {
738
+ paneId: pane.id,
739
+ name: pane.name,
740
+ title: pane.title,
741
+ role: pane.role,
742
+ },
743
+ busyStatus,
744
+ });
745
+ });
746
+
747
+ // GET /api/project/:name/config — read parsed ide.yml + raw text. Used by
748
+ // the v2 Config editor to hydrate the form.
749
+ app.get("/api/project/:name/config", (c) => {
750
+ const name = c.req.param("name");
751
+ const sessions = discoverSessions();
752
+ const session = sessions.find((s) => s.name === name);
753
+ if (!session) return c.json({ error: "Session not found" }, 404);
754
+ try {
755
+ const { config, configPath } = readConfig(session.dir);
756
+ return c.json({ ok: true, config, configPath });
757
+ } catch (err) {
758
+ const message = err instanceof Error ? err.message : String(err);
759
+ return c.json({ error: "Failed to read ide.yml", detail: message }, 500);
760
+ }
761
+ });
762
+
763
+ // POST /api/project/:name/config — accept a full IdeConfig payload, validate
764
+ // against IdeConfigSchema, and write to ide.yml. Returns the persisted
765
+ // config so the client can re-hydrate without a follow-up GET.
766
+ app.post("/api/project/:name/config", async (c) => {
767
+ const name = c.req.param("name");
768
+ const sessions = discoverSessions();
769
+ const session = sessions.find((s) => s.name === name);
770
+ if (!session) return c.json({ error: "Session not found" }, 404);
771
+ let body: unknown;
772
+ try {
773
+ body = await c.req.json();
774
+ } catch {
775
+ return c.json({ error: "Invalid JSON body" }, 400);
776
+ }
777
+ const parsed = IdeConfigSchema.safeParse(body);
778
+ if (!parsed.success) {
779
+ return c.json({ error: "Invalid config", details: parsed.error.issues }, 400);
780
+ }
781
+ try {
782
+ const configPath = writeConfig(session.dir, parsed.data);
783
+ return c.json({ ok: true, config: parsed.data, configPath });
784
+ } catch (err) {
785
+ const message = err instanceof Error ? err.message : String(err);
786
+ return c.json({ error: "Failed to write ide.yml", detail: message }, 500);
787
+ }
788
+ });
789
+
790
+ // Launch a tmux-ide session (shells out to CLI since launch has complex side effects)
791
+ const execFileAsync = promisify(execFile);
792
+
793
+ // POST /api/project/:name/restart — fire `tmux-ide restart` async. The CLI
794
+ // handles stop+launch; this endpoint is fire-and-forget aside from a short
795
+ // 30s timeout so the client can retry on its own.
796
+ app.post("/api/project/:name/restart", async (c) => {
797
+ const name = c.req.param("name");
798
+ const sessions = discoverSessions();
799
+ const session = sessions.find((s) => s.name === name);
800
+ if (!session) return c.json({ error: "Session not found" }, 404);
801
+ try {
802
+ await execFileAsync("tmux-ide", ["restart", "--json"], {
803
+ cwd: session.dir,
804
+ timeout: 30000,
805
+ env: { ...process.env, TMUX: "" },
806
+ });
807
+ return c.json({ ok: true, session: name, status: "restarted" });
808
+ } catch (err) {
809
+ const message = err instanceof Error ? err.message : String(err);
810
+ return c.json({ error: "Restart failed", detail: message }, 500);
811
+ }
812
+ });
813
+
814
+ app.post("/api/project/:name/launch", async (c) => {
815
+ const name = c.req.param("name");
816
+ const sessions = discoverSessions();
817
+ const session = sessions.find((s) => s.name === name);
818
+ if (!session) {
819
+ return c.json({ error: "Session not found" }, 404);
820
+ }
821
+
822
+ // Check if already running
823
+ const state = getSessionState(name);
824
+ if (state.running) {
825
+ return c.json({ ok: true, session: name, status: "already_running" });
826
+ }
827
+
828
+ try {
829
+ await execFileAsync("tmux-ide", ["--json"], {
830
+ cwd: session.dir,
831
+ timeout: 30000,
832
+ env: { ...process.env, TMUX: "" }, // Clear TMUX to avoid nesting
833
+ });
834
+ return c.json({ ok: true, session: name, status: "launched" });
835
+ } catch (err: unknown) {
836
+ const message = err instanceof Error ? err.message : String(err);
837
+ return c.json({ error: "Launch failed", detail: message }, 500);
838
+ }
839
+ });
840
+
841
+ // Stop a tmux-ide session
842
+ app.post("/api/project/:name/stop", async (c) => {
843
+ const name = c.req.param("name");
844
+ const sessions = discoverSessions();
845
+ const session = sessions.find((s) => s.name === name);
846
+ if (!session) {
847
+ return c.json({ error: "Session not found" }, 404);
848
+ }
849
+
850
+ const state = getSessionState(name);
851
+ if (!state.running) {
852
+ return c.json({ ok: true, session: name, status: "not_running" });
853
+ }
854
+
855
+ stopSessionMonitor(name);
856
+ const result = killSession(name);
857
+ if (result.stopped) {
858
+ return c.json({ ok: true, session: name, status: "stopped" });
859
+ }
860
+ return c.json({ error: "Stop failed", reason: result.reason }, 500);
861
+ });
862
+
863
+ // SSE endpoint — cursor-based event streaming with orchestrator state
864
+ app.get("/api/events", (c) => {
865
+ return streamSSE(c, async (stream) => {
866
+ let prevOverviews: SessionOverview[] = [];
867
+
868
+ const poll = () => {
869
+ const sessions = discoverSessions();
870
+ const overviews = buildOverviews(sessions);
871
+
872
+ const prevNames = new Set(prevOverviews.map((s) => s.name));
873
+ const currNames = new Set(overviews.map((s) => s.name));
874
+
875
+ for (const overview of overviews) {
876
+ if (!prevNames.has(overview.name)) {
877
+ stream.writeSSE({ event: "session_added", data: JSON.stringify(overview) });
878
+ }
879
+ }
880
+
881
+ for (const prev of prevOverviews) {
882
+ if (!currNames.has(prev.name)) {
883
+ stream.writeSSE({
884
+ event: "session_removed",
885
+ data: JSON.stringify({ name: prev.name }),
886
+ });
887
+ }
888
+ }
889
+
890
+ prevOverviews = overviews;
891
+ };
892
+
893
+ poll();
894
+ while (true) {
895
+ await stream.sleep(2000);
896
+ poll();
897
+ }
898
+ });
899
+ });
900
+
901
+ // ---------------------------------------------------------------------
902
+ // GET /api/logs/:channel — SSE stream of structured logger entries.
903
+ //
904
+ // Channels are filtered views over the single in-process logger:
905
+ // - "daemon" → no component filter (all entries)
906
+ // - "hq" → component starts with "hq" or "remote"
907
+ // - "watchdog" → component starts with "watchdog"
908
+ // The dashboard BottomPanel Output tab opens one EventSource per
909
+ // selected channel. Backfill from the in-memory ring buffer is sent
910
+ // first as `event: "backfill"` followed by a `bookmark` event so the
911
+ // client can render history immediately; new entries arrive as
912
+ // `event: "entry"` data.
913
+ // ---------------------------------------------------------------------
914
+ app.get("/api/logs/:channel", (c) => {
915
+ const channel = c.req.param("channel");
916
+ const match = matchLogChannel(channel);
917
+ if (!match) {
918
+ return c.json({ error: `Unknown log channel: ${channel}` }, 404);
919
+ }
920
+ return streamSSE(c, async (stream) => {
921
+ // 1. Backfill from the ring buffer.
922
+ const backfill = getLogBuffer().filter(match);
923
+ for (const entry of backfill) {
924
+ await stream.writeSSE({ event: "entry", data: JSON.stringify(entry) });
925
+ }
926
+ await stream.writeSSE({ event: "bookmark", data: String(backfill.length) });
927
+ // 2. Subscribe to live entries.
928
+ const queue: LogEntry[] = [];
929
+ let cancelled = false;
930
+ const unsub = subscribeLogs((entry) => {
931
+ if (cancelled) return;
932
+ if (match(entry)) queue.push(entry);
933
+ });
934
+ try {
935
+ while (!cancelled) {
936
+ if (queue.length === 0) {
937
+ await stream.sleep(500);
938
+ continue;
939
+ }
940
+ const drained = queue.splice(0, queue.length);
941
+ for (const entry of drained) {
942
+ await stream.writeSSE({ event: "entry", data: JSON.stringify(entry) });
943
+ }
944
+ }
945
+ } finally {
946
+ cancelled = true;
947
+ unsub();
948
+ }
949
+ });
950
+ });
951
+
952
+ app.get("/health", (c) => {
953
+ return c.json({
954
+ ok: true,
955
+ uptime: Math.round(process.uptime()),
956
+ version: pkgVersion,
957
+ });
958
+ });
959
+
960
+ // --- Project registry ---
961
+
962
+ app.get("/api/projects", (c) => {
963
+ return c.json({ projects: listProjects() });
964
+ });
965
+
966
+ app.get("/api/projects/templates", (c) => {
967
+ return c.json({ templates: listAvailableTemplates() });
968
+ });
969
+
970
+ app.post("/api/projects", async (c) => {
971
+ let body: unknown;
972
+ try {
973
+ body = await c.req.json();
974
+ } catch {
975
+ return c.json({ error: "Invalid JSON body" }, 400);
976
+ }
977
+ const parsed = RegisterProjectRequestSchemaZ.safeParse(body);
978
+ if (!parsed.success) {
979
+ return c.json({ error: "Invalid request", details: parsed.error.issues }, 400);
980
+ }
981
+ try {
982
+ const project = await registerProject({
983
+ dir: parsed.data.dir,
984
+ name: parsed.data.name,
985
+ });
986
+ return c.json({ project }, 201);
987
+ } catch (err) {
988
+ if (err instanceof ProjectDirNotFoundError) {
989
+ return c.json({ error: err.message, code: err.code }, 400);
990
+ }
991
+ if (err instanceof ProjectAlreadyRegisteredError) {
992
+ return c.json({ error: err.message, code: err.code, suggestion: err.suggestion }, 409);
993
+ }
994
+ throw err;
995
+ }
996
+ });
997
+
998
+ app.delete("/api/projects/:name", (c) => {
999
+ const name = c.req.param("name");
1000
+ try {
1001
+ unregisterProject(name);
1002
+ return c.json({ ok: true });
1003
+ } catch (err) {
1004
+ if (err instanceof ProjectNotFoundError) {
1005
+ return c.json({ error: err.message, code: err.code }, 404);
1006
+ }
1007
+ throw err;
1008
+ }
1009
+ });
1010
+
1011
+ app.post("/api/projects/:name/probe", async (c) => {
1012
+ const name = c.req.param("name");
1013
+ if (!getProject(name)) {
1014
+ return c.json({ error: `Project "${name}" not found in registry`, code: "NOT_FOUND" }, 404);
1015
+ }
1016
+ try {
1017
+ const project = await refreshProject(name);
1018
+ return c.json({ project });
1019
+ } catch (err) {
1020
+ if (err instanceof ProjectNotFoundError) {
1021
+ return c.json({ error: err.message, code: err.code }, 404);
1022
+ }
1023
+ throw err;
1024
+ }
1025
+ });
1026
+
1027
+ app.post("/api/projects/init", async (c) => {
1028
+ let body: unknown;
1029
+ try {
1030
+ body = await c.req.json();
1031
+ } catch {
1032
+ return c.json({ error: "Invalid JSON body" }, 400);
1033
+ }
1034
+ const parsed = InitProjectRequestSchemaZ.safeParse(body);
1035
+ if (!parsed.success) {
1036
+ return c.json({ error: "Invalid request", details: parsed.error.issues }, 400);
1037
+ }
1038
+ if (!existsSync(parsed.data.dir)) {
1039
+ return c.json({ error: `Directory "${parsed.data.dir}" does not exist` }, 400);
1040
+ }
1041
+
1042
+ const jobId = randomUUID();
1043
+ const command = process.env.TMUX_IDE_INIT_COMMAND ?? "tmux-ide";
1044
+
1045
+ // Fire-and-forget — runs in the background and streams chunks via WS.
1046
+ void (async () => {
1047
+ try {
1048
+ await runInit({
1049
+ cwd: parsed.data.dir,
1050
+ template: parsed.data.template,
1051
+ command,
1052
+ onChunk: (chunk) => {
1053
+ broadcastInitOutput(jobId, chunk);
1054
+ },
1055
+ });
1056
+ // Mark stream complete
1057
+ broadcastInitOutput(jobId, "", true);
1058
+
1059
+ // Probe + register the freshly-init'd project so it shows up in
1060
+ // the registry — also broadcasts `projects.changed`.
1061
+ try {
1062
+ await registerProject({ dir: parsed.data.dir });
1063
+ } catch (err) {
1064
+ // Already-registered is benign here; report anything else.
1065
+ if (
1066
+ !(err instanceof ProjectAlreadyRegisteredError) &&
1067
+ !(err instanceof ProjectDirNotFoundError)
1068
+ ) {
1069
+ broadcastInitError(jobId, (err as Error).message);
1070
+ }
1071
+ }
1072
+ } catch (err) {
1073
+ if (err instanceof ProjectInitTimeoutError) {
1074
+ broadcastInitError(jobId, err.message);
1075
+ } else if (err instanceof ProjectInitFailedError) {
1076
+ broadcastInitError(jobId, err.message);
1077
+ } else {
1078
+ broadcastInitError(jobId, (err as Error).message);
1079
+ }
1080
+ }
1081
+ })();
1082
+
1083
+ return c.json({ jobId }, 202);
1084
+ });
1085
+
1086
+ // -------------------------------------------------------------------------
1087
+ // POST /api/filesystem/inspect — registry-agnostic directory inspection.
1088
+ // POST /api/projects/onboard — generate ide.yml + register the project.
1089
+ // -------------------------------------------------------------------------
1090
+
1091
+ app.post("/api/projects/onboard", async (c) => {
1092
+ let body: unknown;
1093
+ try {
1094
+ body = await c.req.json();
1095
+ } catch {
1096
+ return c.json({ error: "Invalid JSON body" }, 400);
1097
+ }
1098
+ const parsed = OnboardProjectRequestSchemaZ.safeParse(body);
1099
+ if (!parsed.success) {
1100
+ return c.json({ error: "Invalid request", details: parsed.error.issues }, 400);
1101
+ }
1102
+ const sandboxResult = sandboxResolveDir(parsed.data.dir);
1103
+ if ("error" in sandboxResult) {
1104
+ return c.json(
1105
+ { error: sandboxResult.error, message: sandboxResult.message },
1106
+ sandboxResult.status,
1107
+ );
1108
+ }
1109
+
1110
+ const dir = sandboxResult.canonical;
1111
+ let inspect;
1112
+ try {
1113
+ inspect = await inspectProject(dir);
1114
+ } catch (err) {
1115
+ if (err instanceof InspectDirNotFoundError) {
1116
+ return c.json({ error: "not-found", message: err.message }, 404);
1117
+ }
1118
+ throw err;
1119
+ }
1120
+
1121
+ // Never overwrite an existing ide.yml.
1122
+ try {
1123
+ assertNoExistingIdeYml(dir);
1124
+ } catch (err) {
1125
+ if (err instanceof OnboardConflictError) {
1126
+ return c.json({ error: err.message, code: err.code }, 409);
1127
+ }
1128
+ throw err;
1129
+ }
1130
+
1131
+ // Compose the config from inputs (defaults to inspect.name).
1132
+ const finalName = parsed.data.name?.trim() || inspect.name;
1133
+ let config;
1134
+ try {
1135
+ config = composeIdeYmlConfig({
1136
+ name: finalName,
1137
+ agents: parsed.data.agents,
1138
+ agentNames: parsed.data.agentNames,
1139
+ devCommand: parsed.data.devCommand ?? null,
1140
+ testCommand: parsed.data.testCommand ?? null,
1141
+ lintCommand: parsed.data.lintCommand ?? null,
1142
+ });
1143
+ } catch (err) {
1144
+ if (err instanceof OnboardInvalidInputError) {
1145
+ return c.json({ error: err.message, code: err.code }, 400);
1146
+ }
1147
+ throw err;
1148
+ }
1149
+
1150
+ writeConfig(dir, config);
1151
+
1152
+ // Now register — this also broadcasts `projects.changed`.
1153
+ try {
1154
+ const project = await registerProject({ dir, name: finalName });
1155
+ return c.json({ project }, 201);
1156
+ } catch (err) {
1157
+ if (err instanceof ProjectAlreadyRegisteredError) {
1158
+ return c.json({ error: err.message, code: err.code, suggestion: err.suggestion }, 409);
1159
+ }
1160
+ if (err instanceof ProjectDirNotFoundError) {
1161
+ return c.json({ error: err.message, code: err.code }, 400);
1162
+ }
1163
+ throw err;
1164
+ }
1165
+ });
1166
+
1167
+ return app;
1168
+ }
1169
+
1170
+ /**
1171
+ * Discover bundled templates by reading `templates/*.yml`. Hardcoded
1172
+ * descriptions for the canonical set; unknown templates surface with a
1173
+ * generic label so we don't break if templates are added without updating
1174
+ * this map.
1175
+ */
1176
+ function listAvailableTemplates(): ProjectTemplate[] {
1177
+ const __filename = fileURLToPath(import.meta.url);
1178
+ const __dir = dirname(__filename);
1179
+ // Repo-root templates/ — 4 levels up from packages/daemon/src/command-center/.
1180
+ // Pre-fold this lived at <pkg>/templates (..,.. worked); post-fold the
1181
+ // canonical templates dir is at the repo root.
1182
+ const templatesDir = join(__dir, "..", "..", "..", "..", "templates");
1183
+ if (!existsSync(templatesDir)) return [];
1184
+ const labels: Record<string, { label: string; description: string }> = {
1185
+ default: { label: "Default", description: "Single Claude pane + dev/shell row" },
1186
+ nextjs: {
1187
+ label: "Next.js",
1188
+ description: "Two Claude panes + Next.js dev server + shell",
1189
+ },
1190
+ vite: { label: "Vite", description: "Vite dev server + Claude + shell" },
1191
+ convex: {
1192
+ label: "Convex",
1193
+ description: "Convex dev + Next.js + Claude pane",
1194
+ },
1195
+ python: { label: "Python", description: "Python project with Claude + tests" },
1196
+ go: { label: "Go", description: "Go project with Claude + tests + shell" },
1197
+ "agent-team": {
1198
+ label: "Agent Team",
1199
+ description: "Lead + teammate Claude panes for coordinated multi-agent work",
1200
+ },
1201
+ "agent-team-nextjs": {
1202
+ label: "Agent Team — Next.js",
1203
+ description: "Agent team layout tuned for a Next.js app",
1204
+ },
1205
+ "agent-team-monorepo": {
1206
+ label: "Agent Team — Monorepo",
1207
+ description: "Agent team layout for monorepos with multiple apps",
1208
+ },
1209
+ missions: {
1210
+ label: "Missions",
1211
+ description: "Mission-driven layout with planner, validator, and researcher",
1212
+ },
1213
+ };
1214
+ const entries = readdirSync(templatesDir).filter((f) => f.endsWith(".yml"));
1215
+ return entries
1216
+ .map((file) => {
1217
+ const id = file.replace(/\.yml$/, "");
1218
+ const meta = labels[id];
1219
+ return {
1220
+ id,
1221
+ label: meta?.label ?? id,
1222
+ description: meta?.description ?? `Template: ${id}`,
1223
+ };
1224
+ })
1225
+ .sort((a, b) => a.id.localeCompare(b.id));
1226
+ }
1227
+
1228
+ /**
1229
+ * Attach the unified `/ws/events` push channel to a Node HTTP server. The
1230
+ * daemon calls this once after binding the command-center port. Existing
1231
+ * SSE endpoints (`/api/events`, `/api/project/<name>/stream`) continue to
1232
+ * work alongside this — they will be retired in a follow-up slice.
1233
+ */
1234
+ export function attachWsEvents(server: import("node:http").Server): {
1235
+ close: () => void;
1236
+ } {
1237
+ const wss = new WebSocketServer({ noServer: true });
1238
+
1239
+ const upgradeListener = (
1240
+ req: import("node:http").IncomingMessage,
1241
+ socket: import("node:net").Socket,
1242
+ head: Buffer,
1243
+ ): void => {
1244
+ const url = req.url ?? "/";
1245
+ const pathname = url.split("?")[0];
1246
+ if (pathname !== "/ws/events") return;
1247
+ wss.handleUpgrade(req, socket, head, (ws) => {
1248
+ handleWsEventsConnection(ws);
1249
+ });
1250
+ };
1251
+
1252
+ server.on("upgrade", upgradeListener);
1253
+
1254
+ return {
1255
+ close: () => {
1256
+ server.off("upgrade", upgradeListener);
1257
+ wss.close();
1258
+ },
1259
+ };
1260
+ }