tmux-ide 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -9
- package/bin/cli.js +1024 -519
- package/bin/cli.ts +63 -6
- package/bunfig.toml +4 -0
- package/package.json +18 -7
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/dist/agent-explain.d.ts +8 -1
- package/packages/daemon/dist/agent-explain.js +19 -3
- package/packages/daemon/dist/lib/tui-binary.d.ts +57 -0
- package/packages/daemon/dist/lib/tui-binary.js +130 -0
- package/packages/daemon/dist/widgets/explorer/breadcrumbs.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/explorer/tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/lib/help-overlay.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/agent-naming.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/config-tree.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/detect-panel.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/field-editor.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/footer.d.ts +1 -1
- package/packages/daemon/dist/widgets/setup/layout-picker.d.ts +1 -1
- package/packages/daemon/src/agent-explain.ts +298 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +427 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/tui-binary.ts +165 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +382 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +510 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +121 -0
- package/packages/daemon/src/tui/detect/classify.ts +208 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest.ts +199 -0
- package/packages/daemon/src/tui/detect/manifests.ts +354 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +217 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +82 -0
- package/packages/daemon/src/tui/mirror/ack-writer.ts +77 -0
- package/packages/daemon/src/tui/mirror/agent-chip.ts +97 -0
- package/packages/daemon/src/tui/mirror/agent-rows.ts +133 -0
- package/packages/daemon/src/tui/mirror/app-state.ts +179 -0
- package/packages/daemon/src/tui/mirror/app.tsx +5265 -0
- package/packages/daemon/src/tui/mirror/blit.ts +186 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +214 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/dialog-model.ts +298 -0
- package/packages/daemon/src/tui/mirror/dialog-stack.ts +354 -0
- package/packages/daemon/src/tui/mirror/diff-model.ts +112 -0
- package/packages/daemon/src/tui/mirror/editor-buffer.ts +117 -0
- package/packages/daemon/src/tui/mirror/file-tree.ts +97 -0
- package/packages/daemon/src/tui/mirror/focus-border.ts +57 -0
- package/packages/daemon/src/tui/mirror/folder-picker.ts +124 -0
- package/packages/daemon/src/tui/mirror/home-model.ts +174 -0
- package/packages/daemon/src/tui/mirror/input-coalescer.ts +105 -0
- package/packages/daemon/src/tui/mirror/menu-model.ts +187 -0
- package/packages/daemon/src/tui/mirror/palette.ts +274 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +561 -0
- package/packages/daemon/src/tui/mirror/pane-surface.tsx +415 -0
- package/packages/daemon/src/tui/mirror/perf-tap.ts +160 -0
- package/packages/daemon/src/tui/mirror/resize-model.ts +85 -0
- package/packages/daemon/src/tui/mirror/scrollbar-model.ts +88 -0
- package/packages/daemon/src/tui/mirror/search-model.ts +70 -0
- package/packages/daemon/src/tui/mirror/selection.ts +262 -0
- package/packages/daemon/src/tui/mirror/session-mirror.ts +443 -0
- package/packages/daemon/src/tui/mirror/settings-model.ts +345 -0
- package/packages/daemon/src/tui/mirror/size-truth.ts +77 -0
- package/packages/daemon/src/tui/mirror/spans.ts +46 -0
- package/packages/daemon/src/tui/mirror/status-grammar.ts +32 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +38 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +83 -0
- package/packages/daemon/src/tui/team/sessions.ts +433 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/build-tui.mjs +11 -4
- package/scripts/perf-mirror.mjs +313 -0
- package/scripts/postinstall.js +26 -2
- package/skill/SKILL.md +22 -0
- package/templates/AGENTS.md +14 -7
- package/templates/agent-team-monorepo.yml +8 -0
- package/templates/agent-team-nextjs.yml +8 -0
- package/templates/agent-team.yml +10 -0
- package/templates/convex.yml +2 -0
- package/templates/default.yml +11 -5
- package/templates/go.yml +4 -0
- package/templates/missions.yml +6 -0
- package/templates/nextjs.yml +4 -0
- package/templates/python.yml +4 -0
- package/templates/skills/backend.md +5 -12
- package/templates/skills/frontend.md +5 -12
- package/templates/skills/general-worker.md +5 -12
- package/templates/skills/researcher.md +7 -12
- package/templates/skills/reviewer.md +7 -16
- package/templates/vite.yml +4 -0
|
@@ -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
|
+
}
|