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,1426 @@
|
|
|
1
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { streamSSE } from "hono/streaming";
|
|
7
|
+
import { cors } from "hono/cors";
|
|
8
|
+
import {
|
|
9
|
+
discoverSessions,
|
|
10
|
+
buildOverviews,
|
|
11
|
+
buildProjectDetail,
|
|
12
|
+
buildOrchestratorSnapshot,
|
|
13
|
+
updateTask,
|
|
14
|
+
type SessionOverview,
|
|
15
|
+
type ProjectDetail,
|
|
16
|
+
} from "./discovery.ts";
|
|
17
|
+
import {
|
|
18
|
+
listSessionPanes,
|
|
19
|
+
sendCommand,
|
|
20
|
+
sendText,
|
|
21
|
+
getPaneBusyStatus,
|
|
22
|
+
} from "../widgets/lib/pane-comms.ts";
|
|
23
|
+
import { resolvePane } from "../send.ts";
|
|
24
|
+
import { getSessionState, killSession, stopSessionMonitor } from "../lib/tmux.ts";
|
|
25
|
+
import { readConfig } from "../lib/yaml-io.ts";
|
|
26
|
+
import {
|
|
27
|
+
ensureTasksDir,
|
|
28
|
+
nextTaskId,
|
|
29
|
+
saveTask,
|
|
30
|
+
deleteTask,
|
|
31
|
+
loadMission,
|
|
32
|
+
saveMission,
|
|
33
|
+
loadTasks,
|
|
34
|
+
type Task,
|
|
35
|
+
} from "../lib/task-store.ts";
|
|
36
|
+
import { readEvents, appendEvent } from "../lib/event-log.ts";
|
|
37
|
+
import { extractMarks, calculateStats, tagContent } from "../lib/authorship.ts";
|
|
38
|
+
import {
|
|
39
|
+
loadValidationState,
|
|
40
|
+
loadValidationContract,
|
|
41
|
+
saveValidationState,
|
|
42
|
+
checkCoverage,
|
|
43
|
+
} from "../lib/validation.ts";
|
|
44
|
+
import { loadSkills, loadSkill } from "../lib/skill-registry.ts";
|
|
45
|
+
import { computeMetrics, loadMissionHistory } from "../lib/metrics.ts";
|
|
46
|
+
import { loadPlans, markPlanDone } from "../lib/plan-store.ts";
|
|
47
|
+
import {
|
|
48
|
+
loadCheckpoints,
|
|
49
|
+
loadCheckpoint,
|
|
50
|
+
loadCheckpointsForTask,
|
|
51
|
+
saveCheckpoint,
|
|
52
|
+
deleteCheckpoint,
|
|
53
|
+
nextCheckpointId,
|
|
54
|
+
loadReviews,
|
|
55
|
+
loadReview,
|
|
56
|
+
loadReviewsForTask,
|
|
57
|
+
saveReview,
|
|
58
|
+
deleteReview,
|
|
59
|
+
nextReviewId,
|
|
60
|
+
type Checkpoint,
|
|
61
|
+
type ReviewRequest,
|
|
62
|
+
type ReviewComment,
|
|
63
|
+
} from "../lib/workflow-store.ts";
|
|
64
|
+
import { zValidator } from "@hono/zod-validator";
|
|
65
|
+
import {
|
|
66
|
+
updateTaskSchema,
|
|
67
|
+
createTaskSchema,
|
|
68
|
+
savePlanSchema,
|
|
69
|
+
sendCommandSchema,
|
|
70
|
+
createMilestoneSchema,
|
|
71
|
+
updateMilestoneSchema,
|
|
72
|
+
updateAssertionSchema,
|
|
73
|
+
triggerResearchSchema,
|
|
74
|
+
} from "./schemas.ts";
|
|
75
|
+
import { AuthService } from "../lib/auth/auth-service.ts";
|
|
76
|
+
import { authMiddleware } from "../lib/auth/middleware.ts";
|
|
77
|
+
import type { AuthConfig } from "../lib/auth/types.ts";
|
|
78
|
+
import { TunnelManager } from "../lib/tunnels/manager.ts";
|
|
79
|
+
import { RemoteRegistry } from "../lib/hq/registry.ts";
|
|
80
|
+
import { RegistrationPayloadSchema } from "../lib/hq/types.ts";
|
|
81
|
+
import { dispatchResearch, loadResearchState } from "../lib/research.ts";
|
|
82
|
+
|
|
83
|
+
export interface CreateAppOptions {
|
|
84
|
+
authService?: AuthService;
|
|
85
|
+
authConfig?: AuthConfig;
|
|
86
|
+
tunnelManager?: TunnelManager;
|
|
87
|
+
remoteRegistry?: RemoteRegistry;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ALLOWED_MILESTONE_TRANSITIONS = new Map([
|
|
91
|
+
["locked", new Set(["active"])],
|
|
92
|
+
["active", new Set(["validating"])],
|
|
93
|
+
["validating", new Set(["done", "active"])],
|
|
94
|
+
["done", new Set<string>()],
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function isValidMilestoneTransition(
|
|
98
|
+
from: "locked" | "active" | "done" | "validating",
|
|
99
|
+
to: "locked" | "active" | "done" | "validating",
|
|
100
|
+
): boolean {
|
|
101
|
+
if (from === to) return true;
|
|
102
|
+
return ALLOWED_MILESTONE_TRANSITIONS.get(from)?.has(to) ?? false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createApp(options: CreateAppOptions = {}): Hono {
|
|
106
|
+
const authConfig: AuthConfig = options.authConfig ?? { method: "none", token_expiry: 86400 };
|
|
107
|
+
const authService = options.authService ?? new AuthService();
|
|
108
|
+
|
|
109
|
+
const app = new Hono();
|
|
110
|
+
|
|
111
|
+
// Allow cross-origin (Next.js dashboard, Tailscale, etc.)
|
|
112
|
+
app.use("/*", cors());
|
|
113
|
+
|
|
114
|
+
// Auth middleware — passes through when method is "none"
|
|
115
|
+
app.use("/*", authMiddleware(authService, authConfig));
|
|
116
|
+
|
|
117
|
+
// Global error handler
|
|
118
|
+
app.onError((err, c) => {
|
|
119
|
+
console.error("[command-center]", err.message);
|
|
120
|
+
return c.json({ error: err.message }, 500);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Auth routes (always available, bypassed by middleware) ---
|
|
124
|
+
|
|
125
|
+
app.post("/api/auth/challenge", async (c) => {
|
|
126
|
+
const body = await c.req.json();
|
|
127
|
+
const userId = body.userId ?? authService.getCurrentUser();
|
|
128
|
+
const challenge = authService.createChallenge(userId);
|
|
129
|
+
return c.json(challenge);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.post("/api/auth/verify", async (c) => {
|
|
133
|
+
const body = await c.req.json();
|
|
134
|
+
const result = await authService.authenticateWithSSHKey({
|
|
135
|
+
publicKey: body.publicKey,
|
|
136
|
+
signature: body.signature,
|
|
137
|
+
challengeId: body.challengeId,
|
|
138
|
+
});
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
return c.json({ error: result.error }, 401);
|
|
141
|
+
}
|
|
142
|
+
return c.json({ token: result.token, userId: result.userId });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
app.post("/api/auth/token", async (c) => {
|
|
146
|
+
if (authConfig.method !== "none") {
|
|
147
|
+
return c.json({ error: "Direct token generation requires auth method 'none'" }, 403);
|
|
148
|
+
}
|
|
149
|
+
const body = await c.req.json();
|
|
150
|
+
const userId = body.userId ?? authService.getCurrentUser();
|
|
151
|
+
const token = authService.generateToken(userId);
|
|
152
|
+
return c.json({ token, userId });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// --- API routes ---
|
|
156
|
+
|
|
157
|
+
app.get("/api/sessions", (c) => {
|
|
158
|
+
const sessions = discoverSessions();
|
|
159
|
+
const overviews = buildOverviews(sessions);
|
|
160
|
+
return c.json({ sessions: overviews });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
app.get("/api/project/:name", (c) => {
|
|
164
|
+
const name = c.req.param("name");
|
|
165
|
+
const sessions = discoverSessions();
|
|
166
|
+
const session = sessions.find((s) => s.name === name);
|
|
167
|
+
if (!session) {
|
|
168
|
+
return c.json({ error: "Session not found" }, 404);
|
|
169
|
+
}
|
|
170
|
+
const detail = buildProjectDetail(session);
|
|
171
|
+
const orchestratorSnapshot = buildOrchestratorSnapshot(session);
|
|
172
|
+
let orchestratorConfig: { enabled: boolean; dispatchMode: string } | null = null;
|
|
173
|
+
try {
|
|
174
|
+
const { config } = readConfig(session.dir);
|
|
175
|
+
const orch = config.orchestrator;
|
|
176
|
+
if (orch) {
|
|
177
|
+
orchestratorConfig = {
|
|
178
|
+
enabled: orch.enabled ?? false,
|
|
179
|
+
dispatchMode: orch.dispatch_mode ?? "tasks",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// unreadable ide.yml
|
|
184
|
+
}
|
|
185
|
+
return c.json({ ...detail, orchestratorSnapshot, orchestratorConfig });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
app.get("/api/project/:name/panes", (c) => {
|
|
189
|
+
const name = c.req.param("name");
|
|
190
|
+
let panes: ReturnType<typeof listSessionPanes>;
|
|
191
|
+
try {
|
|
192
|
+
panes = listSessionPanes(name);
|
|
193
|
+
} catch {
|
|
194
|
+
return c.json({ error: "Session not found" }, 404);
|
|
195
|
+
}
|
|
196
|
+
if (panes.length === 0) {
|
|
197
|
+
// Verify the session actually exists before returning empty
|
|
198
|
+
const sessions = discoverSessions();
|
|
199
|
+
if (!sessions.find((s) => s.name === name)) {
|
|
200
|
+
return c.json({ error: "Session not found" }, 404);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return c.json({
|
|
204
|
+
panes: panes.map((p) => ({
|
|
205
|
+
id: p.id,
|
|
206
|
+
index: p.index,
|
|
207
|
+
title: p.title,
|
|
208
|
+
currentCommand: p.currentCommand,
|
|
209
|
+
width: p.width,
|
|
210
|
+
height: p.height,
|
|
211
|
+
active: p.active,
|
|
212
|
+
role: p.role,
|
|
213
|
+
name: p.name,
|
|
214
|
+
type: p.type,
|
|
215
|
+
})),
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
app.post("/api/project/:name/task/:id", zValidator("json", updateTaskSchema), async (c) => {
|
|
220
|
+
const name = c.req.param("name");
|
|
221
|
+
const taskId = c.req.param("id");
|
|
222
|
+
|
|
223
|
+
const sessions = discoverSessions();
|
|
224
|
+
const session = sessions.find((s) => s.name === name);
|
|
225
|
+
if (!session) {
|
|
226
|
+
return c.json({ error: "Session not found" }, 404);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const body = c.req.valid("json");
|
|
230
|
+
const updated = updateTask(session.dir, taskId, body);
|
|
231
|
+
if (!updated) {
|
|
232
|
+
return c.json({ error: "Task not found" }, 404);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return c.json({ ok: true, task: updated });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Create task
|
|
239
|
+
app.post("/api/project/:name/task", zValidator("json", createTaskSchema), async (c) => {
|
|
240
|
+
const name = c.req.param("name");
|
|
241
|
+
const sessions = discoverSessions();
|
|
242
|
+
const session = sessions.find((s) => s.name === name);
|
|
243
|
+
if (!session) {
|
|
244
|
+
return c.json({ error: "Session not found" }, 404);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const body = c.req.valid("json");
|
|
248
|
+
|
|
249
|
+
ensureTasksDir(session.dir);
|
|
250
|
+
const id = nextTaskId(session.dir);
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
const task: Task = {
|
|
253
|
+
id,
|
|
254
|
+
title: body.title.trim(),
|
|
255
|
+
description: body.description ?? "",
|
|
256
|
+
goal: body.goal ?? null,
|
|
257
|
+
status: "todo",
|
|
258
|
+
assignee: null,
|
|
259
|
+
priority: body.priority ?? 2,
|
|
260
|
+
created: now,
|
|
261
|
+
updated: now,
|
|
262
|
+
tags: body.tags ?? [],
|
|
263
|
+
proof: null,
|
|
264
|
+
depends_on: [],
|
|
265
|
+
retryCount: 0,
|
|
266
|
+
maxRetries: 5,
|
|
267
|
+
lastError: null,
|
|
268
|
+
nextRetryAt: null,
|
|
269
|
+
milestone: null,
|
|
270
|
+
specialty: null,
|
|
271
|
+
fulfills: [],
|
|
272
|
+
discoveredIssues: [],
|
|
273
|
+
salientSummary: null,
|
|
274
|
+
};
|
|
275
|
+
saveTask(session.dir, task);
|
|
276
|
+
return c.json({ ok: true, task }, 201);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Delete task
|
|
280
|
+
app.delete("/api/project/:name/task/:id", (c) => {
|
|
281
|
+
const name = c.req.param("name");
|
|
282
|
+
const taskId = c.req.param("id");
|
|
283
|
+
const sessions = discoverSessions();
|
|
284
|
+
const session = sessions.find((s) => s.name === name);
|
|
285
|
+
if (!session) {
|
|
286
|
+
return c.json({ error: "Session not found" }, 404);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!deleteTask(session.dir, taskId)) {
|
|
290
|
+
return c.json({ error: "Task not found" }, 404);
|
|
291
|
+
}
|
|
292
|
+
return c.json({ ok: true, deleted: taskId });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// List plan files with status metadata
|
|
296
|
+
app.get("/api/project/:name/plans", (c) => {
|
|
297
|
+
const name = c.req.param("name");
|
|
298
|
+
const sessions = discoverSessions();
|
|
299
|
+
const session = sessions.find((s) => s.name === name);
|
|
300
|
+
if (!session) {
|
|
301
|
+
return c.json({ error: "Session not found" }, 404);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const plans = loadPlans(session.dir).map((p) => ({
|
|
305
|
+
name: p.name,
|
|
306
|
+
path: `${p.name}.md`,
|
|
307
|
+
title: p.title,
|
|
308
|
+
status: p.status,
|
|
309
|
+
effort: p.effort ?? null,
|
|
310
|
+
completed: p.completed ?? null,
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
return c.json({ plans });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Read a single plan file
|
|
317
|
+
app.get("/api/project/:name/plans/:filename", (c) => {
|
|
318
|
+
const name = c.req.param("name");
|
|
319
|
+
const filename = c.req.param("filename");
|
|
320
|
+
const sessions = discoverSessions();
|
|
321
|
+
const session = sessions.find((s) => s.name === name);
|
|
322
|
+
if (!session) {
|
|
323
|
+
return c.json({ error: "Session not found" }, 404);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Sanitize filename — no path traversal
|
|
327
|
+
const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
|
|
328
|
+
const filePath = join(
|
|
329
|
+
session.dir,
|
|
330
|
+
"plans",
|
|
331
|
+
safeName.endsWith(".md") ? safeName : `${safeName}.md`,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
if (!existsSync(filePath)) {
|
|
335
|
+
return c.json({ error: "Plan not found" }, 404);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
339
|
+
const { content, marks } = extractMarks(raw);
|
|
340
|
+
const stats = marks ? calculateStats(marks.marks) : null;
|
|
341
|
+
return c.json({
|
|
342
|
+
name: safeName.replace(/\.md$/, ""),
|
|
343
|
+
content,
|
|
344
|
+
marks: marks?.marks ?? null,
|
|
345
|
+
stats,
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Save a plan file (with authorship tagging)
|
|
350
|
+
app.post("/api/project/:name/plans/:filename", zValidator("json", savePlanSchema), async (c) => {
|
|
351
|
+
const name = c.req.param("name");
|
|
352
|
+
const filename = c.req.param("filename");
|
|
353
|
+
const sessions = discoverSessions();
|
|
354
|
+
const session = sessions.find((s) => s.name === name);
|
|
355
|
+
if (!session) {
|
|
356
|
+
return c.json({ error: "Session not found" }, 404);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const body = c.req.valid("json");
|
|
360
|
+
|
|
361
|
+
const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
|
|
362
|
+
const filePath = join(
|
|
363
|
+
session.dir,
|
|
364
|
+
"plans",
|
|
365
|
+
safeName.endsWith(".md") ? safeName : `${safeName}.md`,
|
|
366
|
+
);
|
|
367
|
+
const plansDir = join(session.dir, "plans");
|
|
368
|
+
if (!existsSync(plansDir)) mkdirSync(plansDir, { recursive: true });
|
|
369
|
+
|
|
370
|
+
// Auto-tag uncovered character ranges as human-authored
|
|
371
|
+
const tagged = tagContent(body.content, "human");
|
|
372
|
+
writeFileSync(filePath, tagged);
|
|
373
|
+
|
|
374
|
+
return c.json({ ok: true });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Delete a plan file
|
|
378
|
+
app.delete("/api/project/:name/plans/:filename", (c) => {
|
|
379
|
+
const name = c.req.param("name");
|
|
380
|
+
const filename = c.req.param("filename");
|
|
381
|
+
const sessions = discoverSessions();
|
|
382
|
+
const session = sessions.find((s) => s.name === name);
|
|
383
|
+
if (!session) {
|
|
384
|
+
return c.json({ error: "Session not found" }, 404);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "");
|
|
388
|
+
const filePath = join(
|
|
389
|
+
session.dir,
|
|
390
|
+
"plans",
|
|
391
|
+
safeName.endsWith(".md") ? safeName : `${safeName}.md`,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
if (!existsSync(filePath)) {
|
|
395
|
+
return c.json({ error: "Plan not found" }, 404);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
unlinkSync(filePath);
|
|
399
|
+
return c.json({ ok: true, deleted: safeName });
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// Mark a plan as done
|
|
403
|
+
app.post("/api/project/:name/plans/:filename/done", (c) => {
|
|
404
|
+
const name = c.req.param("name");
|
|
405
|
+
const filename = c.req.param("filename");
|
|
406
|
+
const sessions = discoverSessions();
|
|
407
|
+
const session = sessions.find((s) => s.name === name);
|
|
408
|
+
if (!session) {
|
|
409
|
+
return c.json({ error: "Session not found" }, 404);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const safeName = filename.replace(/[^a-zA-Z0-9_\-. ]/g, "").replace(/\.md$/, "");
|
|
413
|
+
const result = markPlanDone(session.dir, safeName);
|
|
414
|
+
if (!result) {
|
|
415
|
+
return c.json({ error: "Plan not found" }, 404);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return c.json({ ok: true, plan: result });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// --- Checkpoints ---
|
|
422
|
+
|
|
423
|
+
app.get("/api/project/:name/checkpoints", (c) => {
|
|
424
|
+
const name = c.req.param("name");
|
|
425
|
+
const sessions = discoverSessions();
|
|
426
|
+
const session = sessions.find((s) => s.name === name);
|
|
427
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
428
|
+
const taskId = c.req.query("task");
|
|
429
|
+
const list = taskId
|
|
430
|
+
? loadCheckpointsForTask(session.dir, taskId)
|
|
431
|
+
: loadCheckpoints(session.dir);
|
|
432
|
+
return c.json({ checkpoints: list });
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
app.get("/api/project/:name/checkpoints/:id", (c) => {
|
|
436
|
+
const name = c.req.param("name");
|
|
437
|
+
const id = c.req.param("id");
|
|
438
|
+
const sessions = discoverSessions();
|
|
439
|
+
const session = sessions.find((s) => s.name === name);
|
|
440
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
441
|
+
const cp = loadCheckpoint(session.dir, id);
|
|
442
|
+
if (!cp) return c.json({ error: "Checkpoint not found" }, 404);
|
|
443
|
+
return c.json({ checkpoint: cp });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
app.post("/api/project/:name/checkpoints", async (c) => {
|
|
447
|
+
const name = c.req.param("name");
|
|
448
|
+
const sessions = discoverSessions();
|
|
449
|
+
const session = sessions.find((s) => s.name === name);
|
|
450
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
451
|
+
const body = await c.req.json();
|
|
452
|
+
const now = new Date().toISOString();
|
|
453
|
+
const id = nextCheckpointId(session.dir);
|
|
454
|
+
const checkpoint: Checkpoint = {
|
|
455
|
+
id,
|
|
456
|
+
taskId: body.taskId ?? "",
|
|
457
|
+
title: body.title ?? `Checkpoint ${id}`,
|
|
458
|
+
description: body.description ?? "",
|
|
459
|
+
status: "pending",
|
|
460
|
+
createdBy: body.createdBy ?? "",
|
|
461
|
+
reviewedBy: null,
|
|
462
|
+
created: now,
|
|
463
|
+
updated: now,
|
|
464
|
+
diff: body.diff ?? null,
|
|
465
|
+
files: body.files ?? [],
|
|
466
|
+
comments: [],
|
|
467
|
+
};
|
|
468
|
+
saveCheckpoint(session.dir, checkpoint);
|
|
469
|
+
return c.json({ ok: true, checkpoint }, 201);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
app.post("/api/project/:name/checkpoints/:id", async (c) => {
|
|
473
|
+
const name = c.req.param("name");
|
|
474
|
+
const id = c.req.param("id");
|
|
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 existing = loadCheckpoint(session.dir, id);
|
|
479
|
+
if (!existing) return c.json({ error: "Checkpoint not found" }, 404);
|
|
480
|
+
const body = await c.req.json();
|
|
481
|
+
const updated: Checkpoint = {
|
|
482
|
+
...existing,
|
|
483
|
+
...body,
|
|
484
|
+
id: existing.id,
|
|
485
|
+
created: existing.created,
|
|
486
|
+
updated: new Date().toISOString(),
|
|
487
|
+
};
|
|
488
|
+
saveCheckpoint(session.dir, updated);
|
|
489
|
+
return c.json({ ok: true, checkpoint: updated });
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
app.delete("/api/project/:name/checkpoints/:id", (c) => {
|
|
493
|
+
const name = c.req.param("name");
|
|
494
|
+
const id = c.req.param("id");
|
|
495
|
+
const sessions = discoverSessions();
|
|
496
|
+
const session = sessions.find((s) => s.name === name);
|
|
497
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
498
|
+
if (!deleteCheckpoint(session.dir, id)) return c.json({ error: "Checkpoint not found" }, 404);
|
|
499
|
+
return c.json({ ok: true, deleted: id });
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// --- Reviews ---
|
|
503
|
+
|
|
504
|
+
app.get("/api/project/:name/reviews", (c) => {
|
|
505
|
+
const name = c.req.param("name");
|
|
506
|
+
const sessions = discoverSessions();
|
|
507
|
+
const session = sessions.find((s) => s.name === name);
|
|
508
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
509
|
+
const taskId = c.req.query("task");
|
|
510
|
+
const list = taskId ? loadReviewsForTask(session.dir, taskId) : loadReviews(session.dir);
|
|
511
|
+
return c.json({ reviews: list });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
app.get("/api/project/:name/reviews/:id", (c) => {
|
|
515
|
+
const name = c.req.param("name");
|
|
516
|
+
const id = c.req.param("id");
|
|
517
|
+
const sessions = discoverSessions();
|
|
518
|
+
const session = sessions.find((s) => s.name === name);
|
|
519
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
520
|
+
const review = loadReview(session.dir, id);
|
|
521
|
+
if (!review) return c.json({ error: "Review not found" }, 404);
|
|
522
|
+
return c.json({ review });
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
app.post("/api/project/:name/reviews", async (c) => {
|
|
526
|
+
const name = c.req.param("name");
|
|
527
|
+
const sessions = discoverSessions();
|
|
528
|
+
const session = sessions.find((s) => s.name === name);
|
|
529
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
530
|
+
const body = await c.req.json();
|
|
531
|
+
const now = new Date().toISOString();
|
|
532
|
+
const id = nextReviewId(session.dir);
|
|
533
|
+
const review: ReviewRequest = {
|
|
534
|
+
id,
|
|
535
|
+
taskId: body.taskId ?? "",
|
|
536
|
+
checkpointId: body.checkpointId ?? null,
|
|
537
|
+
title: body.title ?? `Review ${id}`,
|
|
538
|
+
description: body.description ?? "",
|
|
539
|
+
status: "open",
|
|
540
|
+
requestedBy: body.requestedBy ?? "",
|
|
541
|
+
reviewer: body.reviewer ?? null,
|
|
542
|
+
created: now,
|
|
543
|
+
updated: now,
|
|
544
|
+
comments: [],
|
|
545
|
+
};
|
|
546
|
+
saveReview(session.dir, review);
|
|
547
|
+
return c.json({ ok: true, review }, 201);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
app.post("/api/project/:name/reviews/:id", async (c) => {
|
|
551
|
+
const name = c.req.param("name");
|
|
552
|
+
const id = c.req.param("id");
|
|
553
|
+
const sessions = discoverSessions();
|
|
554
|
+
const session = sessions.find((s) => s.name === name);
|
|
555
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
556
|
+
const existing = loadReview(session.dir, id);
|
|
557
|
+
if (!existing) return c.json({ error: "Review not found" }, 404);
|
|
558
|
+
const body = await c.req.json();
|
|
559
|
+
const updated: ReviewRequest = {
|
|
560
|
+
...existing,
|
|
561
|
+
...body,
|
|
562
|
+
id: existing.id,
|
|
563
|
+
created: existing.created,
|
|
564
|
+
updated: new Date().toISOString(),
|
|
565
|
+
};
|
|
566
|
+
saveReview(session.dir, updated);
|
|
567
|
+
return c.json({ ok: true, review: updated });
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
app.post("/api/project/:name/reviews/:id/comment", async (c) => {
|
|
571
|
+
const name = c.req.param("name");
|
|
572
|
+
const id = c.req.param("id");
|
|
573
|
+
const sessions = discoverSessions();
|
|
574
|
+
const session = sessions.find((s) => s.name === name);
|
|
575
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
576
|
+
const existing = loadReview(session.dir, id);
|
|
577
|
+
if (!existing) return c.json({ error: "Review not found" }, 404);
|
|
578
|
+
const body = await c.req.json();
|
|
579
|
+
const comment: ReviewComment = {
|
|
580
|
+
author: body.author ?? "",
|
|
581
|
+
body: body.body ?? "",
|
|
582
|
+
created: new Date().toISOString(),
|
|
583
|
+
};
|
|
584
|
+
existing.comments.push(comment);
|
|
585
|
+
existing.updated = comment.created;
|
|
586
|
+
saveReview(session.dir, existing);
|
|
587
|
+
return c.json({ ok: true, review: existing });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
app.delete("/api/project/:name/reviews/:id", (c) => {
|
|
591
|
+
const name = c.req.param("name");
|
|
592
|
+
const id = c.req.param("id");
|
|
593
|
+
const sessions = discoverSessions();
|
|
594
|
+
const session = sessions.find((s) => s.name === name);
|
|
595
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
596
|
+
if (!deleteReview(session.dir, id)) return c.json({ error: "Review not found" }, 404);
|
|
597
|
+
return c.json({ ok: true, deleted: id });
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
app.get("/api/project/:name/diff", (c) => {
|
|
601
|
+
const name = c.req.param("name");
|
|
602
|
+
const sessions = discoverSessions();
|
|
603
|
+
const session = sessions.find((s) => s.name === name);
|
|
604
|
+
if (!session) {
|
|
605
|
+
return c.json({ error: "Session not found" }, 404);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Get git diff (staged + unstaged vs HEAD)
|
|
609
|
+
let diff = "";
|
|
610
|
+
try {
|
|
611
|
+
diff = execFileSync("git", ["diff", "HEAD"], {
|
|
612
|
+
cwd: session.dir,
|
|
613
|
+
encoding: "utf-8",
|
|
614
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
615
|
+
});
|
|
616
|
+
} catch {
|
|
617
|
+
// No HEAD yet or not a git repo — try unstaged only
|
|
618
|
+
}
|
|
619
|
+
if (!diff) {
|
|
620
|
+
try {
|
|
621
|
+
diff = execFileSync("git", ["diff"], {
|
|
622
|
+
cwd: session.dir,
|
|
623
|
+
encoding: "utf-8",
|
|
624
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
625
|
+
});
|
|
626
|
+
} catch {
|
|
627
|
+
// Not a git repo
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Get list of changed files with stats
|
|
632
|
+
interface DiffFile {
|
|
633
|
+
file: string;
|
|
634
|
+
additions: number;
|
|
635
|
+
deletions: number;
|
|
636
|
+
}
|
|
637
|
+
let files: DiffFile[] = [];
|
|
638
|
+
try {
|
|
639
|
+
const numstat = execFileSync("git", ["diff", "--numstat", "HEAD"], {
|
|
640
|
+
cwd: session.dir,
|
|
641
|
+
encoding: "utf-8",
|
|
642
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
643
|
+
});
|
|
644
|
+
files = numstat
|
|
645
|
+
.split("\n")
|
|
646
|
+
.filter(Boolean)
|
|
647
|
+
.map((line) => {
|
|
648
|
+
const [added, removed, file] = line.split("\t");
|
|
649
|
+
return {
|
|
650
|
+
file: file!,
|
|
651
|
+
additions: parseInt(added!, 10) || 0,
|
|
652
|
+
deletions: parseInt(removed!, 10) || 0,
|
|
653
|
+
};
|
|
654
|
+
});
|
|
655
|
+
} catch {
|
|
656
|
+
// Fall back to unstaged
|
|
657
|
+
try {
|
|
658
|
+
const numstat = execFileSync("git", ["diff", "--numstat"], {
|
|
659
|
+
cwd: session.dir,
|
|
660
|
+
encoding: "utf-8",
|
|
661
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
662
|
+
});
|
|
663
|
+
files = numstat
|
|
664
|
+
.split("\n")
|
|
665
|
+
.filter(Boolean)
|
|
666
|
+
.map((line) => {
|
|
667
|
+
const [added, removed, file] = line.split("\t");
|
|
668
|
+
return {
|
|
669
|
+
file: file!,
|
|
670
|
+
additions: parseInt(added!, 10) || 0,
|
|
671
|
+
deletions: parseInt(removed!, 10) || 0,
|
|
672
|
+
};
|
|
673
|
+
});
|
|
674
|
+
} catch {
|
|
675
|
+
// Not a git repo
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return c.json({ diff, files });
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Per-file diff endpoint
|
|
683
|
+
app.get("/api/project/:name/diff/:file{.+}", (c) => {
|
|
684
|
+
const name = c.req.param("name");
|
|
685
|
+
const file = c.req.param("file");
|
|
686
|
+
const sessions = discoverSessions();
|
|
687
|
+
const session = sessions.find((s) => s.name === name);
|
|
688
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
689
|
+
|
|
690
|
+
let diff = "";
|
|
691
|
+
try {
|
|
692
|
+
diff = execFileSync("git", ["diff", "HEAD", "--", file], {
|
|
693
|
+
cwd: session.dir,
|
|
694
|
+
encoding: "utf-8",
|
|
695
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
696
|
+
});
|
|
697
|
+
} catch {
|
|
698
|
+
// no committed diff
|
|
699
|
+
}
|
|
700
|
+
if (!diff) {
|
|
701
|
+
try {
|
|
702
|
+
diff = execFileSync("git", ["diff", "--", file], {
|
|
703
|
+
cwd: session.dir,
|
|
704
|
+
encoding: "utf-8",
|
|
705
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
706
|
+
});
|
|
707
|
+
} catch {
|
|
708
|
+
// no working-tree diff
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return c.json({ file, diff });
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// --- Milestone endpoints ---
|
|
716
|
+
|
|
717
|
+
app.get("/api/project/:name/milestones", (c) => {
|
|
718
|
+
const name = c.req.param("name");
|
|
719
|
+
const sessions = discoverSessions();
|
|
720
|
+
const session = sessions.find((s) => s.name === name);
|
|
721
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
722
|
+
const mission = loadMission(session.dir);
|
|
723
|
+
if (!mission) return c.json({ milestones: [] });
|
|
724
|
+
const tasks = loadTasks(session.dir);
|
|
725
|
+
const milestones = [...mission.milestones]
|
|
726
|
+
.sort((a, b) => a.order - b.order)
|
|
727
|
+
.map((m) => {
|
|
728
|
+
const mTasks = tasks.filter((t) => t.milestone === m.id);
|
|
729
|
+
return {
|
|
730
|
+
...m,
|
|
731
|
+
taskCount: mTasks.length,
|
|
732
|
+
tasksDone: mTasks.filter((t) => t.status === "done").length,
|
|
733
|
+
};
|
|
734
|
+
});
|
|
735
|
+
return c.json({ milestones });
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
app.get("/api/project/:name/milestones/:id", (c) => {
|
|
739
|
+
const name = c.req.param("name");
|
|
740
|
+
const milestoneId = c.req.param("id");
|
|
741
|
+
const sessions = discoverSessions();
|
|
742
|
+
const session = sessions.find((s) => s.name === name);
|
|
743
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
744
|
+
const mission = loadMission(session.dir);
|
|
745
|
+
if (!mission) return c.json({ error: "No mission" }, 404);
|
|
746
|
+
const milestone = mission.milestones.find((m) => m.id === milestoneId);
|
|
747
|
+
if (!milestone) return c.json({ error: "Milestone not found" }, 404);
|
|
748
|
+
const tasks = loadTasks(session.dir).filter((t) => t.milestone === milestoneId);
|
|
749
|
+
return c.json({ milestone, tasks });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
app.post(
|
|
753
|
+
"/api/project/:name/milestones",
|
|
754
|
+
zValidator("json", createMilestoneSchema),
|
|
755
|
+
async (c) => {
|
|
756
|
+
const name = c.req.param("name");
|
|
757
|
+
const sessions = discoverSessions();
|
|
758
|
+
const session = sessions.find((s) => s.name === name);
|
|
759
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
760
|
+
const mission = loadMission(session.dir);
|
|
761
|
+
if (!mission) return c.json({ error: "No mission" }, 404);
|
|
762
|
+
const body = c.req.valid("json");
|
|
763
|
+
const id = `M${body.sequence}`;
|
|
764
|
+
if (mission.milestones.find((m) => m.id === id)) {
|
|
765
|
+
return c.json({ error: `Milestone ${id} already exists` }, 409);
|
|
766
|
+
}
|
|
767
|
+
const now = new Date().toISOString();
|
|
768
|
+
const hasActive = mission.milestones.some(
|
|
769
|
+
(m) => m.status === "active" || m.status === "done",
|
|
770
|
+
);
|
|
771
|
+
const milestone = {
|
|
772
|
+
id,
|
|
773
|
+
title: body.title,
|
|
774
|
+
description: body.description ?? "",
|
|
775
|
+
status: (hasActive ? "locked" : "active") as "locked" | "active",
|
|
776
|
+
order: body.sequence,
|
|
777
|
+
created: now,
|
|
778
|
+
updated: now,
|
|
779
|
+
};
|
|
780
|
+
mission.milestones.push(milestone);
|
|
781
|
+
mission.milestones.sort((a, b) => a.order - b.order);
|
|
782
|
+
mission.updated = now;
|
|
783
|
+
saveMission(session.dir, mission);
|
|
784
|
+
return c.json({ ok: true, milestone }, 201);
|
|
785
|
+
},
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
app.post(
|
|
789
|
+
"/api/project/:name/milestones/:id",
|
|
790
|
+
zValidator("json", updateMilestoneSchema),
|
|
791
|
+
async (c) => {
|
|
792
|
+
const name = c.req.param("name");
|
|
793
|
+
const milestoneId = c.req.param("id");
|
|
794
|
+
const sessions = discoverSessions();
|
|
795
|
+
const session = sessions.find((s) => s.name === name);
|
|
796
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
797
|
+
const mission = loadMission(session.dir);
|
|
798
|
+
if (!mission) return c.json({ error: "No mission" }, 404);
|
|
799
|
+
const milestone = mission.milestones.find((m) => m.id === milestoneId);
|
|
800
|
+
if (!milestone) return c.json({ error: "Milestone not found" }, 404);
|
|
801
|
+
const body = c.req.valid("json");
|
|
802
|
+
if (body.status && !isValidMilestoneTransition(milestone.status, body.status)) {
|
|
803
|
+
return c.json(
|
|
804
|
+
{
|
|
805
|
+
error: `Invalid milestone status transition: ${milestone.status} -> ${body.status}`,
|
|
806
|
+
},
|
|
807
|
+
409,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
if (body.status) milestone.status = body.status;
|
|
811
|
+
if (body.title) milestone.title = body.title;
|
|
812
|
+
if (body.description !== undefined) milestone.description = body.description;
|
|
813
|
+
milestone.updated = new Date().toISOString();
|
|
814
|
+
mission.updated = milestone.updated;
|
|
815
|
+
saveMission(session.dir, mission);
|
|
816
|
+
return c.json({ ok: true, milestone });
|
|
817
|
+
},
|
|
818
|
+
);
|
|
819
|
+
|
|
820
|
+
// --- Validation endpoints ---
|
|
821
|
+
|
|
822
|
+
app.get("/api/project/:name/validation", (c) => {
|
|
823
|
+
const name = c.req.param("name");
|
|
824
|
+
const sessions = discoverSessions();
|
|
825
|
+
const session = sessions.find((s) => s.name === name);
|
|
826
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
827
|
+
const state = loadValidationState(session.dir);
|
|
828
|
+
const contract = loadValidationContract(session.dir);
|
|
829
|
+
return c.json({ contract: contract ?? null, state: state ?? null });
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
app.get("/api/project/:name/validation/coverage", (c) => {
|
|
833
|
+
const name = c.req.param("name");
|
|
834
|
+
const sessions = discoverSessions();
|
|
835
|
+
const session = sessions.find((s) => s.name === name);
|
|
836
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
837
|
+
return c.json(checkCoverage(session.dir));
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
app.post(
|
|
841
|
+
"/api/project/:name/validation/assert/:assertId",
|
|
842
|
+
zValidator("json", updateAssertionSchema),
|
|
843
|
+
async (c) => {
|
|
844
|
+
const name = c.req.param("name");
|
|
845
|
+
const assertId = c.req.param("assertId");
|
|
846
|
+
const sessions = discoverSessions();
|
|
847
|
+
const session = sessions.find((s) => s.name === name);
|
|
848
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
849
|
+
const body = c.req.valid("json");
|
|
850
|
+
ensureTasksDir(session.dir);
|
|
851
|
+
const state = loadValidationState(session.dir) ?? { assertions: {}, lastVerified: null };
|
|
852
|
+
state.assertions[assertId] = {
|
|
853
|
+
status: body.status,
|
|
854
|
+
verifiedBy: body.verifiedBy ?? null,
|
|
855
|
+
verifiedAt: new Date().toISOString(),
|
|
856
|
+
evidence: body.evidence ?? null,
|
|
857
|
+
blockedBy: body.status === "blocked" ? (body.evidence ?? null) : null,
|
|
858
|
+
};
|
|
859
|
+
state.lastVerified = new Date().toISOString();
|
|
860
|
+
saveValidationState(session.dir, state);
|
|
861
|
+
return c.json({ ok: true, assertionId: assertId, ...state.assertions[assertId] });
|
|
862
|
+
},
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
app.get("/api/project/:name/research", (c) => {
|
|
866
|
+
const name = c.req.param("name");
|
|
867
|
+
const sessions = discoverSessions();
|
|
868
|
+
const session = sessions.find((s) => s.name === name);
|
|
869
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
870
|
+
|
|
871
|
+
const state = loadResearchState(session.dir);
|
|
872
|
+
const tasks = loadTasks(session.dir);
|
|
873
|
+
const activeTask =
|
|
874
|
+
state.activeResearchTaskId != null
|
|
875
|
+
? (tasks.find((task) => task.id === state.activeResearchTaskId) ?? null)
|
|
876
|
+
: null;
|
|
877
|
+
const findings = tasks
|
|
878
|
+
.filter((task) => task.tags.includes("research") && task.status === "done")
|
|
879
|
+
.sort((a, b) => Date.parse(b.updated) - Date.parse(a.updated))
|
|
880
|
+
.slice(0, 10);
|
|
881
|
+
|
|
882
|
+
return c.json({ state, activeTask, findings });
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
app.post(
|
|
886
|
+
"/api/project/:name/research/trigger",
|
|
887
|
+
zValidator("json", triggerResearchSchema),
|
|
888
|
+
async (c) => {
|
|
889
|
+
const name = c.req.param("name");
|
|
890
|
+
const sessions = discoverSessions();
|
|
891
|
+
const session = sessions.find((s) => s.name === name);
|
|
892
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
893
|
+
|
|
894
|
+
const body = c.req.valid("json");
|
|
895
|
+
const tasks = loadTasks(session.dir);
|
|
896
|
+
const researchState = loadResearchState(session.dir);
|
|
897
|
+
|
|
898
|
+
let maxConcurrentAgents = 10;
|
|
899
|
+
let masterPane: string | null = null;
|
|
900
|
+
let researchEnabled = true;
|
|
901
|
+
try {
|
|
902
|
+
const { config } = readConfig(session.dir);
|
|
903
|
+
maxConcurrentAgents = config.orchestrator?.max_concurrent_agents ?? 10;
|
|
904
|
+
masterPane = config.orchestrator?.master_pane ?? null;
|
|
905
|
+
researchEnabled = config.orchestrator?.research?.enabled ?? true;
|
|
906
|
+
} catch {
|
|
907
|
+
// ide.yml unreadable — use defaults
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const task = dispatchResearch(
|
|
911
|
+
{
|
|
912
|
+
session: name,
|
|
913
|
+
dir: session.dir,
|
|
914
|
+
masterPane,
|
|
915
|
+
maxConcurrentAgents,
|
|
916
|
+
research: { enabled: researchEnabled },
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
lastActivity: new Map(),
|
|
920
|
+
previousTasks: new Map(),
|
|
921
|
+
claimedTasks: new Set(),
|
|
922
|
+
taskClaimTimes: new Map(),
|
|
923
|
+
},
|
|
924
|
+
researchState,
|
|
925
|
+
tasks,
|
|
926
|
+
listSessionPanes(name),
|
|
927
|
+
{
|
|
928
|
+
type: body.type,
|
|
929
|
+
reason: `Manual research trigger: ${body.type}`,
|
|
930
|
+
},
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
if (!task) {
|
|
934
|
+
return c.json({ error: `Unable to dispatch research trigger "${body.type}"` }, 409);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return c.json({ ok: true, task }, 201);
|
|
938
|
+
},
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
// --- Skill endpoints ---
|
|
942
|
+
|
|
943
|
+
app.get("/api/project/:name/skills", (c) => {
|
|
944
|
+
const name = c.req.param("name");
|
|
945
|
+
const sessions = discoverSessions();
|
|
946
|
+
const session = sessions.find((s) => s.name === name);
|
|
947
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
948
|
+
const skills = loadSkills(session.dir);
|
|
949
|
+
return c.json({ skills });
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
app.get("/api/project/:name/skills/:skillName", (c) => {
|
|
953
|
+
const name = c.req.param("name");
|
|
954
|
+
const skillName = c.req.param("skillName");
|
|
955
|
+
const sessions = discoverSessions();
|
|
956
|
+
const session = sessions.find((s) => s.name === name);
|
|
957
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
958
|
+
const skill = loadSkill(session.dir, skillName);
|
|
959
|
+
if (!skill) return c.json({ error: "Skill not found" }, 404);
|
|
960
|
+
return c.json({ skill });
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
// --- Mission endpoints ---
|
|
964
|
+
|
|
965
|
+
app.get("/api/project/:name/mission", (c) => {
|
|
966
|
+
const name = c.req.param("name");
|
|
967
|
+
const sessions = discoverSessions();
|
|
968
|
+
const session = sessions.find((s) => s.name === name);
|
|
969
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
970
|
+
const mission = loadMission(session.dir);
|
|
971
|
+
if (!mission) return c.json({ error: "No mission" }, 404);
|
|
972
|
+
const valState = loadValidationState(session.dir);
|
|
973
|
+
const assertions = valState ? Object.values(valState.assertions) : [];
|
|
974
|
+
const validationSummary = {
|
|
975
|
+
total: assertions.length,
|
|
976
|
+
passing: assertions.filter((a) => a.status === "passing").length,
|
|
977
|
+
failing: assertions.filter((a) => a.status === "failing").length,
|
|
978
|
+
pending: assertions.filter((a) => a.status === "pending").length,
|
|
979
|
+
blocked: assertions.filter((a) => a.status === "blocked").length,
|
|
980
|
+
};
|
|
981
|
+
return c.json({ mission, validationSummary });
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
app.post("/api/project/:name/mission/plan-complete", async (c) => {
|
|
985
|
+
const name = c.req.param("name");
|
|
986
|
+
const sessions = discoverSessions();
|
|
987
|
+
const session = sessions.find((s) => s.name === name);
|
|
988
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
989
|
+
const mission = loadMission(session.dir);
|
|
990
|
+
if (!mission) return c.json({ error: "No mission" }, 404);
|
|
991
|
+
if (mission.status !== "planning") {
|
|
992
|
+
return c.json({ error: `Mission is "${mission.status}", expected "planning"` }, 409);
|
|
993
|
+
}
|
|
994
|
+
mission.status = "active";
|
|
995
|
+
const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
|
|
996
|
+
const first = sorted.find((m) => m.status === "locked");
|
|
997
|
+
if (first) {
|
|
998
|
+
first.status = "active";
|
|
999
|
+
first.updated = new Date().toISOString();
|
|
1000
|
+
}
|
|
1001
|
+
mission.updated = new Date().toISOString();
|
|
1002
|
+
saveMission(session.dir, mission);
|
|
1003
|
+
return c.json({ ok: true, mission });
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
// --- Metrics endpoints ---
|
|
1007
|
+
|
|
1008
|
+
app.get("/api/project/:name/metrics", (c) => {
|
|
1009
|
+
const name = c.req.param("name");
|
|
1010
|
+
const sessions = discoverSessions();
|
|
1011
|
+
const session = sessions.find((s) => s.name === name);
|
|
1012
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1013
|
+
return c.json(computeMetrics(session.dir));
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
app.get("/api/project/:name/metrics/agents", (c) => {
|
|
1017
|
+
const name = c.req.param("name");
|
|
1018
|
+
const sessions = discoverSessions();
|
|
1019
|
+
const session = sessions.find((s) => s.name === name);
|
|
1020
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1021
|
+
return c.json({ agents: computeMetrics(session.dir).agents });
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
app.get("/api/project/:name/metrics/timeline", (c) => {
|
|
1025
|
+
const name = c.req.param("name");
|
|
1026
|
+
const sessions = discoverSessions();
|
|
1027
|
+
const session = sessions.find((s) => s.name === name);
|
|
1028
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1029
|
+
return c.json({ timeline: computeMetrics(session.dir).timeline });
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
app.get("/api/project/:name/metrics/history", (c) => {
|
|
1033
|
+
const name = c.req.param("name");
|
|
1034
|
+
const sessions = discoverSessions();
|
|
1035
|
+
const session = sessions.find((s) => s.name === name);
|
|
1036
|
+
if (!session) return c.json({ error: "Session not found" }, 404);
|
|
1037
|
+
return c.json({ history: loadMissionHistory(session.dir) });
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Events endpoint — returns recent orchestrator events
|
|
1041
|
+
app.get("/api/project/:name/events", (c) => {
|
|
1042
|
+
const name = c.req.param("name");
|
|
1043
|
+
const sessions = discoverSessions();
|
|
1044
|
+
const session = sessions.find((s) => s.name === name);
|
|
1045
|
+
if (!session) {
|
|
1046
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
const allEvents = readEvents(session.dir);
|
|
1050
|
+
// Return last 50 events, newest first
|
|
1051
|
+
const recent = allEvents.slice(-50).reverse();
|
|
1052
|
+
|
|
1053
|
+
// Add relative timestamps
|
|
1054
|
+
const now = Date.now();
|
|
1055
|
+
const withRelative = recent.map((e) => {
|
|
1056
|
+
const ms = now - new Date(e.timestamp).getTime();
|
|
1057
|
+
let relative: string;
|
|
1058
|
+
if (ms < 60000) relative = `${Math.floor(ms / 1000)}s ago`;
|
|
1059
|
+
else if (ms < 3600000) relative = `${Math.floor(ms / 60000)}m ago`;
|
|
1060
|
+
else if (ms < 86400000) relative = `${Math.floor(ms / 3600000)}h ago`;
|
|
1061
|
+
else relative = `${Math.floor(ms / 86400000)}d ago`;
|
|
1062
|
+
return { ...e, relative };
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
return c.json({ events: withRelative });
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// --- Remote command execution endpoints ---
|
|
1069
|
+
|
|
1070
|
+
// Send message to a pane by name/title/role/ID
|
|
1071
|
+
app.post("/api/project/:name/send", zValidator("json", sendCommandSchema), async (c) => {
|
|
1072
|
+
const name = c.req.param("name");
|
|
1073
|
+
const sessions = discoverSessions();
|
|
1074
|
+
const session = sessions.find((s) => s.name === name);
|
|
1075
|
+
if (!session) {
|
|
1076
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const { target, message, noEnter } = c.req.valid("json");
|
|
1080
|
+
|
|
1081
|
+
const panes = listSessionPanes(name);
|
|
1082
|
+
const pane = resolvePane(panes, target);
|
|
1083
|
+
if (!pane) {
|
|
1084
|
+
const available = panes.map((p) => ({
|
|
1085
|
+
id: p.id,
|
|
1086
|
+
title: p.title,
|
|
1087
|
+
name: p.name,
|
|
1088
|
+
role: p.role,
|
|
1089
|
+
}));
|
|
1090
|
+
return c.json({ error: "Pane not found", target, available }, 404);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const busyStatus = getPaneBusyStatus(name, pane.id);
|
|
1094
|
+
|
|
1095
|
+
// Collapse multiline for agent panes
|
|
1096
|
+
const prepared = busyStatus === "agent" ? message.replace(/\n+/g, " ").trim() : message;
|
|
1097
|
+
|
|
1098
|
+
if (noEnter) {
|
|
1099
|
+
sendText(name, pane.id, prepared);
|
|
1100
|
+
} else {
|
|
1101
|
+
sendCommand(name, pane.id, prepared);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
appendEvent(session.dir, {
|
|
1105
|
+
timestamp: new Date().toISOString(),
|
|
1106
|
+
type: "send",
|
|
1107
|
+
target: pane.name ?? pane.title,
|
|
1108
|
+
paneId: pane.id,
|
|
1109
|
+
message: prepared.length > 100 ? prepared.slice(0, 100) + "..." : prepared,
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
return c.json({
|
|
1113
|
+
ok: true,
|
|
1114
|
+
session: name,
|
|
1115
|
+
target: {
|
|
1116
|
+
paneId: pane.id,
|
|
1117
|
+
name: pane.name,
|
|
1118
|
+
title: pane.title,
|
|
1119
|
+
role: pane.role,
|
|
1120
|
+
},
|
|
1121
|
+
busyStatus,
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// Launch a tmux-ide session (shells out to CLI since launch has complex side effects)
|
|
1126
|
+
const execFileAsync = promisify(execFile);
|
|
1127
|
+
|
|
1128
|
+
app.post("/api/project/:name/launch", async (c) => {
|
|
1129
|
+
const name = c.req.param("name");
|
|
1130
|
+
const sessions = discoverSessions();
|
|
1131
|
+
const session = sessions.find((s) => s.name === name);
|
|
1132
|
+
if (!session) {
|
|
1133
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Check if already running
|
|
1137
|
+
const state = getSessionState(name);
|
|
1138
|
+
if (state.running) {
|
|
1139
|
+
return c.json({ ok: true, session: name, status: "already_running" });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
try {
|
|
1143
|
+
await execFileAsync("tmux-ide", ["--json"], {
|
|
1144
|
+
cwd: session.dir,
|
|
1145
|
+
timeout: 30000,
|
|
1146
|
+
env: { ...process.env, TMUX: "" }, // Clear TMUX to avoid nesting
|
|
1147
|
+
});
|
|
1148
|
+
return c.json({ ok: true, session: name, status: "launched" });
|
|
1149
|
+
} catch (err: unknown) {
|
|
1150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1151
|
+
return c.json({ error: "Launch failed", detail: message }, 500);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
// Stop a tmux-ide session
|
|
1156
|
+
app.post("/api/project/:name/stop", async (c) => {
|
|
1157
|
+
const name = c.req.param("name");
|
|
1158
|
+
const sessions = discoverSessions();
|
|
1159
|
+
const session = sessions.find((s) => s.name === name);
|
|
1160
|
+
if (!session) {
|
|
1161
|
+
return c.json({ error: "Session not found" }, 404);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const state = getSessionState(name);
|
|
1165
|
+
if (!state.running) {
|
|
1166
|
+
return c.json({ ok: true, session: name, status: "not_running" });
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
stopSessionMonitor(name);
|
|
1170
|
+
const result = killSession(name);
|
|
1171
|
+
if (result.stopped) {
|
|
1172
|
+
return c.json({ ok: true, session: name, status: "stopped" });
|
|
1173
|
+
}
|
|
1174
|
+
return c.json({ error: "Stop failed", reason: result.reason }, 500);
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// SSE endpoint — cursor-based event streaming with orchestrator state
|
|
1178
|
+
app.get("/api/events", (c) => {
|
|
1179
|
+
return streamSSE(c, async (stream) => {
|
|
1180
|
+
let prevOverviews: SessionOverview[] = [];
|
|
1181
|
+
let prevDetails = new Map<string, ProjectDetail>();
|
|
1182
|
+
const eventCursors = new Map<string, number>(); // session → last-seen event count
|
|
1183
|
+
let prevOrchHashes = new Map<string, string>(); // session → orchestrator snapshot hash
|
|
1184
|
+
|
|
1185
|
+
const poll = () => {
|
|
1186
|
+
const sessions = discoverSessions();
|
|
1187
|
+
const overviews = buildOverviews(sessions);
|
|
1188
|
+
|
|
1189
|
+
// Detect session-level changes
|
|
1190
|
+
const prevNames = new Set(prevOverviews.map((s) => s.name));
|
|
1191
|
+
const currNames = new Set(overviews.map((s) => s.name));
|
|
1192
|
+
|
|
1193
|
+
for (const overview of overviews) {
|
|
1194
|
+
if (!prevNames.has(overview.name)) {
|
|
1195
|
+
stream.writeSSE({
|
|
1196
|
+
event: "session_added",
|
|
1197
|
+
data: JSON.stringify(overview),
|
|
1198
|
+
});
|
|
1199
|
+
continue;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const prev = prevOverviews.find((s) => s.name === overview.name);
|
|
1203
|
+
if (
|
|
1204
|
+
prev &&
|
|
1205
|
+
(prev.stats.doneTasks !== overview.stats.doneTasks ||
|
|
1206
|
+
prev.stats.totalTasks !== overview.stats.totalTasks ||
|
|
1207
|
+
prev.stats.activeAgents !== overview.stats.activeAgents)
|
|
1208
|
+
) {
|
|
1209
|
+
stream.writeSSE({
|
|
1210
|
+
event: "session_update",
|
|
1211
|
+
data: JSON.stringify(overview),
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
for (const prev of prevOverviews) {
|
|
1217
|
+
if (!currNames.has(prev.name)) {
|
|
1218
|
+
stream.writeSSE({
|
|
1219
|
+
event: "session_removed",
|
|
1220
|
+
data: JSON.stringify({ name: prev.name }),
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Per-session: event log cursor + orchestrator state + task/agent diffs
|
|
1226
|
+
for (const session of sessions) {
|
|
1227
|
+
// Cursor-based event streaming from event log
|
|
1228
|
+
const events = readEvents(session.dir);
|
|
1229
|
+
const cursor = eventCursors.get(session.name) ?? 0;
|
|
1230
|
+
|
|
1231
|
+
// Handle log rotation: if events.length < cursor, log was rotated
|
|
1232
|
+
const effectiveCursor = events.length < cursor ? 0 : cursor;
|
|
1233
|
+
const newEvents = events.slice(effectiveCursor);
|
|
1234
|
+
|
|
1235
|
+
for (const evt of newEvents) {
|
|
1236
|
+
const eventId = `${evt.timestamp}:${evt.type}:${evt.taskId ?? ""}`;
|
|
1237
|
+
stream.writeSSE({
|
|
1238
|
+
id: eventId,
|
|
1239
|
+
event: "orchestrator_event",
|
|
1240
|
+
data: JSON.stringify({ session: session.name, ...evt }),
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
eventCursors.set(session.name, events.length);
|
|
1244
|
+
|
|
1245
|
+
// Orchestrator state snapshot (emit only on change)
|
|
1246
|
+
const orchSnapshot = buildOrchestratorSnapshot(session);
|
|
1247
|
+
const orchHash = JSON.stringify(orchSnapshot);
|
|
1248
|
+
const prevHash = prevOrchHashes.get(session.name);
|
|
1249
|
+
if (orchHash !== prevHash) {
|
|
1250
|
+
stream.writeSSE({
|
|
1251
|
+
event: "orchestrator_state",
|
|
1252
|
+
data: JSON.stringify({ session: session.name, ...orchSnapshot }),
|
|
1253
|
+
});
|
|
1254
|
+
prevOrchHashes.set(session.name, orchHash);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Task-level and agent-level diffs (existing logic)
|
|
1258
|
+
const detail = buildProjectDetail(session);
|
|
1259
|
+
const prevDetail = prevDetails.get(session.name);
|
|
1260
|
+
|
|
1261
|
+
if (prevDetail) {
|
|
1262
|
+
const prevTaskMap = new Map(prevDetail.tasks.map((t) => [t.id, t]));
|
|
1263
|
+
for (const task of detail.tasks) {
|
|
1264
|
+
const prevTask = prevTaskMap.get(task.id);
|
|
1265
|
+
if (!prevTask) {
|
|
1266
|
+
stream.writeSSE({
|
|
1267
|
+
event: "task_update",
|
|
1268
|
+
data: JSON.stringify({
|
|
1269
|
+
session: session.name,
|
|
1270
|
+
taskId: task.id,
|
|
1271
|
+
status: task.status,
|
|
1272
|
+
title: task.title,
|
|
1273
|
+
}),
|
|
1274
|
+
});
|
|
1275
|
+
} else if (prevTask.status !== task.status || prevTask.assignee !== task.assignee) {
|
|
1276
|
+
stream.writeSSE({
|
|
1277
|
+
event: "task_update",
|
|
1278
|
+
data: JSON.stringify({
|
|
1279
|
+
session: session.name,
|
|
1280
|
+
taskId: task.id,
|
|
1281
|
+
status: task.status,
|
|
1282
|
+
assignee: task.assignee,
|
|
1283
|
+
}),
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Detect agent status changes
|
|
1289
|
+
const prevAgentMap = new Map(prevDetail.agents.map((a) => [a.paneTitle, a]));
|
|
1290
|
+
for (const agent of detail.agents) {
|
|
1291
|
+
const prevAgent = prevAgentMap.get(agent.paneTitle);
|
|
1292
|
+
if (!prevAgent || prevAgent.isBusy !== agent.isBusy) {
|
|
1293
|
+
stream.writeSSE({
|
|
1294
|
+
event: "agent_status",
|
|
1295
|
+
data: JSON.stringify({
|
|
1296
|
+
session: session.name,
|
|
1297
|
+
agent: agent.paneTitle,
|
|
1298
|
+
busy: agent.isBusy,
|
|
1299
|
+
taskId: agent.taskId,
|
|
1300
|
+
}),
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
prevDetails.set(session.name, detail);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
prevOverviews = overviews;
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
// Initial snapshot
|
|
1313
|
+
poll();
|
|
1314
|
+
|
|
1315
|
+
// Poll every 2 seconds
|
|
1316
|
+
while (true) {
|
|
1317
|
+
await stream.sleep(2000);
|
|
1318
|
+
poll();
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// --- HQ endpoints ---
|
|
1324
|
+
|
|
1325
|
+
const remoteRegistry = options.remoteRegistry ?? null;
|
|
1326
|
+
|
|
1327
|
+
app.post("/api/hq/register", async (c) => {
|
|
1328
|
+
if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
|
|
1329
|
+
const body = await c.req.json();
|
|
1330
|
+
const parsed = RegistrationPayloadSchema.safeParse(body);
|
|
1331
|
+
if (!parsed.success)
|
|
1332
|
+
return c.json({ error: "Invalid payload", details: parsed.error.issues }, 400);
|
|
1333
|
+
try {
|
|
1334
|
+
const machine = remoteRegistry.register(parsed.data);
|
|
1335
|
+
return c.json({
|
|
1336
|
+
ok: true,
|
|
1337
|
+
id: machine.id,
|
|
1338
|
+
name: machine.name,
|
|
1339
|
+
registeredAt: machine.registeredAt.toISOString(),
|
|
1340
|
+
});
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
app.get("/api/hq/machines", (c) => {
|
|
1347
|
+
if (!remoteRegistry) return c.json({ machines: [] });
|
|
1348
|
+
const machines = remoteRegistry.getMachines().map((m) => ({
|
|
1349
|
+
id: m.id,
|
|
1350
|
+
name: m.name,
|
|
1351
|
+
url: m.url,
|
|
1352
|
+
registeredAt: m.registeredAt.toISOString(),
|
|
1353
|
+
lastHeartbeat: m.lastHeartbeat.toISOString(),
|
|
1354
|
+
sessions: Array.from(m.sessionIds),
|
|
1355
|
+
}));
|
|
1356
|
+
return c.json({ machines });
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
app.get("/api/hq/machines/:id", (c) => {
|
|
1360
|
+
if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
|
|
1361
|
+
const machine = remoteRegistry.getMachine(c.req.param("id"));
|
|
1362
|
+
if (!machine) return c.json({ error: "Machine not found" }, 404);
|
|
1363
|
+
return c.json({
|
|
1364
|
+
id: machine.id,
|
|
1365
|
+
name: machine.name,
|
|
1366
|
+
url: machine.url,
|
|
1367
|
+
registeredAt: machine.registeredAt.toISOString(),
|
|
1368
|
+
lastHeartbeat: machine.lastHeartbeat.toISOString(),
|
|
1369
|
+
sessions: Array.from(machine.sessionIds),
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
app.delete("/api/hq/machines/:id", (c) => {
|
|
1374
|
+
if (!remoteRegistry) return c.json({ error: "HQ registry not enabled" }, 501);
|
|
1375
|
+
const removed = remoteRegistry.unregister(c.req.param("id"));
|
|
1376
|
+
if (!removed) return c.json({ error: "Machine not found" }, 404);
|
|
1377
|
+
return c.json({ ok: true });
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
// --- Tunnel endpoints ---
|
|
1381
|
+
|
|
1382
|
+
const tunnelManager = options.tunnelManager ?? null;
|
|
1383
|
+
|
|
1384
|
+
app.get("/api/tunnel", async (c) => {
|
|
1385
|
+
if (!tunnelManager) return c.json({ running: false, provider: null });
|
|
1386
|
+
const status = await tunnelManager.status();
|
|
1387
|
+
return c.json(status);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
app.post("/api/tunnel/start", async (c) => {
|
|
1391
|
+
if (!tunnelManager) return c.json({ error: "Tunnel manager not configured" }, 501);
|
|
1392
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1393
|
+
const { tunnelConfigSchema } = await import("../lib/tunnels/types.ts");
|
|
1394
|
+
const parsed = tunnelConfigSchema.safeParse(body);
|
|
1395
|
+
if (!parsed.success)
|
|
1396
|
+
return c.json({ error: "Invalid tunnel config", details: parsed.error.issues }, 400);
|
|
1397
|
+
const status = await tunnelManager.start(parsed.data);
|
|
1398
|
+
return c.json(status);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
app.post("/api/tunnel/stop", async (c) => {
|
|
1402
|
+
if (!tunnelManager) return c.json({ error: "Tunnel manager not configured" }, 501);
|
|
1403
|
+
await tunnelManager.stop();
|
|
1404
|
+
return c.json({ ok: true });
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
// Health check for daemon liveness probes
|
|
1408
|
+
app.get("/health", (c) => {
|
|
1409
|
+
return c.json({
|
|
1410
|
+
ok: true,
|
|
1411
|
+
uptime: Math.round(process.uptime()),
|
|
1412
|
+
version: "2.0.0",
|
|
1413
|
+
});
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
// Root: API info
|
|
1417
|
+
app.get("/", (c) => {
|
|
1418
|
+
return c.json({
|
|
1419
|
+
status: "ok",
|
|
1420
|
+
message: "tmux-ide command center API",
|
|
1421
|
+
docs: "/api/sessions",
|
|
1422
|
+
});
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
return app;
|
|
1426
|
+
}
|