tmux-ide 1.2.1 → 2.0.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/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified tmux-ide daemon — background process that combines:
|
|
3
|
+
* 1. Pane monitor loop (port detection, agent state, title drift)
|
|
4
|
+
* 2. Orchestrator startup (task dispatch, stall detection)
|
|
5
|
+
* 3. Command Center HTTP/WebSocket server
|
|
6
|
+
*
|
|
7
|
+
* Entry: bun src/lib/daemon.ts <session> [port]
|
|
8
|
+
* Spawned as a detached child by launch.ts (via daemon-watchdog.ts).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createServer, type Server } from "node:http";
|
|
15
|
+
import { computePortPanes, computeAgentStates } from "./session-monitor.ts";
|
|
16
|
+
|
|
17
|
+
/** Remove dispatch files older than 24 hours */
|
|
18
|
+
function cleanupDispatchFiles(dir: string): void {
|
|
19
|
+
const dispatchDir = join(dir, ".tasks", "dispatch");
|
|
20
|
+
if (!existsSync(dispatchDir)) return;
|
|
21
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
22
|
+
try {
|
|
23
|
+
for (const file of readdirSync(dispatchDir)) {
|
|
24
|
+
const filePath = join(dispatchDir, file);
|
|
25
|
+
try {
|
|
26
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
27
|
+
if (mtime < cutoff) unlinkSync(filePath);
|
|
28
|
+
} catch {
|
|
29
|
+
// skip files we can't stat/delete
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// dispatch dir unreadable — skip
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const INTERVAL = 1000;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Args
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
const sessionArg = process.argv[2];
|
|
44
|
+
if (!sessionArg) {
|
|
45
|
+
console.error("Usage: daemon.ts <session> [port]");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
const session: string = sessionArg;
|
|
49
|
+
const requestedPort = parseInt(process.argv[3] ?? "6060", 10);
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// tmux helpers (same as session-monitor.ts)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
interface MonitorPane {
|
|
56
|
+
id: string;
|
|
57
|
+
pid: string;
|
|
58
|
+
cmd?: string;
|
|
59
|
+
title?: string;
|
|
60
|
+
role?: string;
|
|
61
|
+
type?: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function tmux(...args: string[]): string {
|
|
66
|
+
return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tmuxSilent(...args: string[]): string {
|
|
70
|
+
try {
|
|
71
|
+
return tmux(...args);
|
|
72
|
+
} catch {
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionExists(): boolean {
|
|
78
|
+
try {
|
|
79
|
+
tmux("has-session", "-t", session);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasClients(): boolean {
|
|
87
|
+
return tmuxSilent("list-clients").length > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function listPanes(): MonitorPane[] {
|
|
91
|
+
const raw = tmuxSilent(
|
|
92
|
+
"list-panes",
|
|
93
|
+
"-t",
|
|
94
|
+
session,
|
|
95
|
+
"-F",
|
|
96
|
+
"#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}\t#{@ide_role}\t#{@ide_type}\t#{@ide_name}",
|
|
97
|
+
);
|
|
98
|
+
if (!raw) return [];
|
|
99
|
+
return raw.split("\n").map((line) => {
|
|
100
|
+
const [id, pid, cmd, title, role, type, name] = line.split("\t");
|
|
101
|
+
return {
|
|
102
|
+
id: id!,
|
|
103
|
+
pid: pid!,
|
|
104
|
+
cmd,
|
|
105
|
+
title,
|
|
106
|
+
role: role || undefined,
|
|
107
|
+
type: type || undefined,
|
|
108
|
+
name: name || undefined,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Monitor loop
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
let lastState = "";
|
|
118
|
+
let prevAgentStates = new Map<string, "busy" | "idle" | null>();
|
|
119
|
+
let monitorInitialized = false;
|
|
120
|
+
|
|
121
|
+
function tick(): void {
|
|
122
|
+
if (!sessionExists()) {
|
|
123
|
+
shutdown();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (!hasClients()) return; // skip when nobody is watching
|
|
127
|
+
|
|
128
|
+
const panes = listPanes();
|
|
129
|
+
if (panes.length === 0) return;
|
|
130
|
+
|
|
131
|
+
const portPanes = computePortPanes(panes);
|
|
132
|
+
const agentStates = computeAgentStates(panes);
|
|
133
|
+
|
|
134
|
+
// Build state fingerprint for change detection (includes title drift)
|
|
135
|
+
const stateKey = panes
|
|
136
|
+
.map((p) => {
|
|
137
|
+
const port = portPanes.has(p.id) ? "1" : "0";
|
|
138
|
+
const agent = agentStates.get(p.id) ?? "-";
|
|
139
|
+
const titleDrift = p.name && p.title !== p.name ? "d" : "ok";
|
|
140
|
+
return `${p.id}:${port}:${agent}:${titleDrift}`;
|
|
141
|
+
})
|
|
142
|
+
.join("|");
|
|
143
|
+
|
|
144
|
+
if (stateKey === lastState) return;
|
|
145
|
+
|
|
146
|
+
// Apply changes
|
|
147
|
+
for (const pane of panes) {
|
|
148
|
+
const hasPort = portPanes.has(pane.id) ? "1" : "0";
|
|
149
|
+
const agent = agentStates.get(pane.id);
|
|
150
|
+
|
|
151
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
|
|
152
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
|
|
153
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
|
|
154
|
+
|
|
155
|
+
// Restore configured pane title if Claude Code changed it
|
|
156
|
+
if (pane.name && pane.title !== pane.name) {
|
|
157
|
+
tmuxSilent("select-pane", "-t", pane.id, "-T", pane.name);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Notify master pane when an agent transitions from busy → idle
|
|
162
|
+
if (monitorInitialized) {
|
|
163
|
+
const masterPane = panes.find((p) => p.role === "lead");
|
|
164
|
+
if (masterPane) {
|
|
165
|
+
for (const pane of panes) {
|
|
166
|
+
const prev = prevAgentStates.get(pane.id);
|
|
167
|
+
const curr = agentStates.get(pane.id);
|
|
168
|
+
if (prev === "busy" && curr === "idle" && pane.id !== masterPane.id) {
|
|
169
|
+
const label = pane.name ?? pane.title ?? pane.id;
|
|
170
|
+
tmuxSilent("send-keys", "-t", masterPane.id, `Agent "${label}" is now idle`, "Enter");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
monitorInitialized = true;
|
|
176
|
+
prevAgentStates = agentStates;
|
|
177
|
+
|
|
178
|
+
tmuxSilent("refresh-client", "-S");
|
|
179
|
+
lastState = stateKey;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const monitorInterval = setInterval(tick, INTERVAL);
|
|
183
|
+
tick(); // run immediately
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Orchestrator (if enabled in ide.yml)
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
let stopOrchestrator: (() => void) | null = null;
|
|
190
|
+
|
|
191
|
+
async function startOrchestrator(): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const { readConfig } = await import("./yaml-io.ts");
|
|
194
|
+
const { config } = readConfig(process.cwd());
|
|
195
|
+
|
|
196
|
+
if (!config.orchestrator?.enabled) return;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const { createOrchestrator } = await import("./orchestrator.ts");
|
|
200
|
+
|
|
201
|
+
// Configure webhooks for event delivery
|
|
202
|
+
if (config.orchestrator.webhooks?.length) {
|
|
203
|
+
const { setWebhookConfig } = await import("./event-log.ts");
|
|
204
|
+
setWebhookConfig(config.orchestrator.webhooks);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const orch = config.orchestrator;
|
|
208
|
+
|
|
209
|
+
// Build pane specialty map from ide.yml config
|
|
210
|
+
const paneSpecialties = new Map<string, string[]>();
|
|
211
|
+
for (const row of config.rows) {
|
|
212
|
+
for (const pane of row.panes) {
|
|
213
|
+
if (pane.specialty && pane.title) {
|
|
214
|
+
paneSpecialties.set(
|
|
215
|
+
pane.title,
|
|
216
|
+
pane.specialty.split(",").map((s: string) => s.trim().toLowerCase()),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Clean up stale dispatch files on startup
|
|
223
|
+
cleanupDispatchFiles(process.cwd());
|
|
224
|
+
|
|
225
|
+
stopOrchestrator = createOrchestrator({
|
|
226
|
+
session,
|
|
227
|
+
dir: process.cwd(),
|
|
228
|
+
autoDispatch: orch.auto_dispatch ?? true,
|
|
229
|
+
stallTimeout: orch.stall_timeout ?? 300000,
|
|
230
|
+
pollInterval: orch.poll_interval ?? 5000,
|
|
231
|
+
masterPane: orch.master_pane ?? null,
|
|
232
|
+
beforeRun: orch.before_run ?? null,
|
|
233
|
+
afterRun: orch.after_run ?? null,
|
|
234
|
+
maxConcurrentAgents: orch.max_concurrent_agents ?? 10,
|
|
235
|
+
dispatchMode: orch.dispatch_mode ?? "tasks",
|
|
236
|
+
paneSpecialties,
|
|
237
|
+
services:
|
|
238
|
+
(orch.services as Record<
|
|
239
|
+
string,
|
|
240
|
+
{ command: string; port?: number; healthcheck?: string }
|
|
241
|
+
>) ?? {},
|
|
242
|
+
research: orch.research,
|
|
243
|
+
});
|
|
244
|
+
} catch {
|
|
245
|
+
// Orchestrator module not available yet
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
// Config not readable — skip orchestrator
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
void startOrchestrator();
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Command Center HTTP + WebSocket server
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
let httpServer: Server | null = null;
|
|
259
|
+
|
|
260
|
+
async function startCommandCenter(): Promise<void> {
|
|
261
|
+
try {
|
|
262
|
+
const { createApp } = await import("../command-center/server.ts");
|
|
263
|
+
const { getRequestListener } = await import("@hono/node-server");
|
|
264
|
+
const { AuthService } = await import("./auth/auth-service.ts");
|
|
265
|
+
const { AuthConfigSchema } = await import("./auth/types.ts");
|
|
266
|
+
const { TunnelManager } = await import("./tunnels/manager.ts");
|
|
267
|
+
const { RemoteRegistry } = await import("./hq/registry.ts");
|
|
268
|
+
const { HQConfigSchema } = await import("./hq/types.ts");
|
|
269
|
+
|
|
270
|
+
// Load auth + tunnel + hq config from ide.yml
|
|
271
|
+
let authConfig = AuthConfigSchema.parse({});
|
|
272
|
+
let authService: InstanceType<typeof AuthService> | undefined;
|
|
273
|
+
let tunnelConfig: Record<string, unknown> | undefined;
|
|
274
|
+
let hqConfig: ReturnType<typeof HQConfigSchema.parse> | undefined;
|
|
275
|
+
try {
|
|
276
|
+
const { readConfig } = await import("./yaml-io.ts");
|
|
277
|
+
const { config } = readConfig(process.cwd());
|
|
278
|
+
if (config.auth) {
|
|
279
|
+
authConfig = AuthConfigSchema.parse(config.auth);
|
|
280
|
+
}
|
|
281
|
+
if (config.tunnel) {
|
|
282
|
+
tunnelConfig = config.tunnel as Record<string, unknown>;
|
|
283
|
+
}
|
|
284
|
+
if (config.hq) {
|
|
285
|
+
hqConfig = HQConfigSchema.parse(config.hq);
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// Config not readable — use defaults
|
|
289
|
+
}
|
|
290
|
+
authService = new AuthService(authConfig.secret);
|
|
291
|
+
const tunnelManager = new TunnelManager({ session, dir: process.cwd() });
|
|
292
|
+
|
|
293
|
+
// Start HQ registry if role is "hq"
|
|
294
|
+
let remoteRegistry: InstanceType<typeof RemoteRegistry> | undefined;
|
|
295
|
+
if (hqConfig?.enabled && hqConfig.role === "hq") {
|
|
296
|
+
let _isShuttingDown = false;
|
|
297
|
+
remoteRegistry = new RemoteRegistry({
|
|
298
|
+
healthInterval: hqConfig.heartbeat_interval,
|
|
299
|
+
isShuttingDown: () => _isShuttingDown,
|
|
300
|
+
});
|
|
301
|
+
// Wire shutdown flag
|
|
302
|
+
const origShutdown = shutdown;
|
|
303
|
+
const shutdownWithRegistry = () => {
|
|
304
|
+
_isShuttingDown = true;
|
|
305
|
+
remoteRegistry?.destroy();
|
|
306
|
+
origShutdown();
|
|
307
|
+
};
|
|
308
|
+
process.removeListener("SIGTERM", shutdown);
|
|
309
|
+
process.removeListener("SIGINT", shutdown);
|
|
310
|
+
process.on("SIGTERM", shutdownWithRegistry);
|
|
311
|
+
process.on("SIGINT", shutdownWithRegistry);
|
|
312
|
+
console.log("[daemon] HQ registry started");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const app = createApp({ authService, authConfig, tunnelManager, remoteRegistry });
|
|
316
|
+
|
|
317
|
+
// Health endpoint for watchdog and external probes
|
|
318
|
+
app.get("/health", (c: { json: (body: unknown, status?: number) => Response }) => {
|
|
319
|
+
return c.json({ ok: true, session, uptime: process.uptime() });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const listener = getRequestListener(app.fetch);
|
|
323
|
+
const server = createServer(listener);
|
|
324
|
+
|
|
325
|
+
const bindRequestedPort = (port: number): Promise<boolean> =>
|
|
326
|
+
new Promise<boolean>((res, rej) => {
|
|
327
|
+
server.once("error", (err: NodeJS.ErrnoException) => {
|
|
328
|
+
if (err.code === "EADDRINUSE") {
|
|
329
|
+
console.error(`[daemon] Command Center port ${port} is in use; skipping HTTP server`);
|
|
330
|
+
server.removeAllListeners("error");
|
|
331
|
+
res(false);
|
|
332
|
+
} else {
|
|
333
|
+
rej(err);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
server.listen(port, "0.0.0.0", () => {
|
|
337
|
+
tmuxSilent("set-option", "-t", session, "@command_center_port", String(port));
|
|
338
|
+
console.log(`[daemon] Command Center on http://0.0.0.0:${port} (session: ${session})`);
|
|
339
|
+
res(true);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const commandCenterStarted = await bindRequestedPort(requestedPort);
|
|
344
|
+
if (commandCenterStarted) {
|
|
345
|
+
httpServer = server;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Auto-start tunnel if configured
|
|
349
|
+
if (commandCenterStarted && tunnelConfig && tunnelConfig.auto_start) {
|
|
350
|
+
try {
|
|
351
|
+
const { tunnelConfigSchema } = await import("./tunnels/types.ts");
|
|
352
|
+
const parsed = tunnelConfigSchema.parse({
|
|
353
|
+
...tunnelConfig,
|
|
354
|
+
port: tunnelConfig.port ?? requestedPort,
|
|
355
|
+
});
|
|
356
|
+
await tunnelManager.start(parsed);
|
|
357
|
+
console.log("[daemon] Tunnel auto-started");
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error("[daemon] Tunnel auto-start failed:", err);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Auto-start HQ client if role is "remote"
|
|
364
|
+
if (
|
|
365
|
+
commandCenterStarted &&
|
|
366
|
+
hqConfig?.enabled &&
|
|
367
|
+
hqConfig.role === "remote" &&
|
|
368
|
+
hqConfig.hq_url
|
|
369
|
+
) {
|
|
370
|
+
try {
|
|
371
|
+
const { HQClient } = await import("./hq/client.ts");
|
|
372
|
+
const os = await import("node:os");
|
|
373
|
+
const hqClient = new HQClient({
|
|
374
|
+
hqUrl: hqConfig.hq_url,
|
|
375
|
+
secret: hqConfig.secret ?? "",
|
|
376
|
+
machineName: hqConfig.machine_name ?? os.hostname(),
|
|
377
|
+
remoteUrl: `http://localhost:${requestedPort}`,
|
|
378
|
+
heartbeatInterval: hqConfig.heartbeat_interval,
|
|
379
|
+
});
|
|
380
|
+
await hqClient.register();
|
|
381
|
+
console.log(`[daemon] Registered with HQ as ${hqClient.getName()}`);
|
|
382
|
+
|
|
383
|
+
// Deregister on shutdown
|
|
384
|
+
const origShutdownFn = shutdown;
|
|
385
|
+
process.removeListener("SIGTERM", shutdown);
|
|
386
|
+
process.removeListener("SIGINT", shutdown);
|
|
387
|
+
const shutdownWithHQ = async () => {
|
|
388
|
+
await hqClient.destroy();
|
|
389
|
+
origShutdownFn();
|
|
390
|
+
};
|
|
391
|
+
process.on("SIGTERM", () => void shutdownWithHQ());
|
|
392
|
+
process.on("SIGINT", () => void shutdownWithHQ());
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error("[daemon] HQ client registration failed:", err);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch (err) {
|
|
398
|
+
// Command center failed but daemon continues (monitor + orchestrator still work)
|
|
399
|
+
console.error("[daemon] Command Center failed to start:", err);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
void startCommandCenter();
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Unified shutdown
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
function shutdown(): void {
|
|
410
|
+
clearInterval(monitorInterval);
|
|
411
|
+
if (stopOrchestrator) stopOrchestrator();
|
|
412
|
+
if (httpServer) httpServer.close();
|
|
413
|
+
process.exit(0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
process.on("SIGTERM", shutdown);
|
|
417
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { getByPath, setByPath } from "./dot-path.ts";
|
|
3
|
+
|
|
4
|
+
describe("getByPath", () => {
|
|
5
|
+
it("gets a top-level key", () => {
|
|
6
|
+
expect(getByPath({ name: "test" }, "name")).toBe("test");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("gets a nested value", () => {
|
|
10
|
+
const obj = { rows: [{ panes: [{ title: "Shell" }] }] };
|
|
11
|
+
expect(getByPath(obj, "rows.0.panes.0.title")).toBe("Shell");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns undefined for non-existent path", () => {
|
|
15
|
+
expect(getByPath({}, "a.b.c")).toBe(undefined);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns undefined for partially valid path", () => {
|
|
19
|
+
expect(getByPath({ a: { b: 1 } }, "a.b.c")).toBe(undefined);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("setByPath", () => {
|
|
24
|
+
it("sets a top-level key", () => {
|
|
25
|
+
const obj = {};
|
|
26
|
+
setByPath(obj, "name", "test");
|
|
27
|
+
expect(obj).toEqual({ name: "test" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("sets a nested value", () => {
|
|
31
|
+
const obj = { rows: [{ panes: [{ title: "old" }] }] };
|
|
32
|
+
setByPath(obj, "rows.0.panes.0.title", "new");
|
|
33
|
+
expect(obj.rows[0].panes[0].title).toBe("new");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("creates arrays for numeric keys", () => {
|
|
37
|
+
const obj = {};
|
|
38
|
+
setByPath(obj, "rows.0.title", "test");
|
|
39
|
+
expect(Array.isArray(obj.rows)).toBeTruthy();
|
|
40
|
+
expect(obj.rows[0].title).toBe("test");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("creates objects for string keys", () => {
|
|
44
|
+
const obj = {};
|
|
45
|
+
setByPath(obj, "a.b.c", "val");
|
|
46
|
+
expect(obj).toEqual({ a: { b: { c: "val" } } });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("handles duplicate segment values (regression)", () => {
|
|
50
|
+
// Bug: rows.0.panes.0 — both "0" segments caused indexOf to return
|
|
51
|
+
// the same index, breaking the lookahead for array vs object creation
|
|
52
|
+
const obj = {};
|
|
53
|
+
setByPath(obj, "rows.0.panes.0", "value");
|
|
54
|
+
expect(Array.isArray(obj.rows)).toBeTruthy();
|
|
55
|
+
expect(Array.isArray(obj.rows[0].panes)).toBeTruthy();
|
|
56
|
+
expect(obj.rows[0].panes[0]).toBe("value");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles deeper duplicate segments", () => {
|
|
60
|
+
const obj = {};
|
|
61
|
+
setByPath(obj, "a.0.b.0.c", "deep");
|
|
62
|
+
expect(Array.isArray(obj.a)).toBeTruthy();
|
|
63
|
+
expect(Array.isArray(obj.a[0].b)).toBeTruthy();
|
|
64
|
+
expect(obj.a[0].b[0].c).toBe("deep");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
export function getByPath(obj: Record<string, any>, path: string): unknown {
|
|
3
|
+
return path.split(".").reduce((o: any, k) => o?.[k], obj);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function setByPath(obj: Record<string, any>, path: string, value: unknown): void {
|
|
7
|
+
const keys = path.split(".");
|
|
8
|
+
const last = keys.pop()!;
|
|
9
|
+
let i = 0;
|
|
10
|
+
const target = keys.reduce((o: any, k) => {
|
|
11
|
+
const nextKey = keys[i + 1] ?? last;
|
|
12
|
+
if (o[k] === undefined) o[k] = /^\d+$/.test(nextKey) ? [] : {};
|
|
13
|
+
i++;
|
|
14
|
+
return o[k];
|
|
15
|
+
}, obj);
|
|
16
|
+
target[last] = value;
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export class IdeError extends Error {
|
|
2
|
+
code: string | undefined;
|
|
3
|
+
exitCode: number;
|
|
4
|
+
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
{ code, exitCode = 1, cause }: { code?: string; exitCode?: number; cause?: Error } = {},
|
|
8
|
+
) {
|
|
9
|
+
super(message, { cause });
|
|
10
|
+
this.name = "IdeError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.exitCode = exitCode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
toJSON(): { error: string; code: string | undefined; cause?: string } {
|
|
16
|
+
const obj: { error: string; code: string | undefined; cause?: string } = {
|
|
17
|
+
error: this.message,
|
|
18
|
+
code: this.code,
|
|
19
|
+
};
|
|
20
|
+
if (this.cause) obj.cause = (this.cause as Error).message;
|
|
21
|
+
return obj;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ConfigError extends IdeError {
|
|
26
|
+
constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
|
|
27
|
+
super(message, { code, exitCode: 1, cause });
|
|
28
|
+
this.name = "ConfigError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class TmuxError extends IdeError {
|
|
33
|
+
constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
|
|
34
|
+
super(message, { code, exitCode: 1, cause });
|
|
35
|
+
this.name = "TmuxError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SessionError extends IdeError {
|
|
40
|
+
constructor(message: string, code: string, { cause }: { cause?: Error } = {}) {
|
|
41
|
+
super(message, { code, exitCode: 1, cause });
|
|
42
|
+
this.name = "SessionError";
|
|
43
|
+
}
|
|
44
|
+
}
|