crewdesk 0.1.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/LICENSE +9 -0
- package/README.md +54 -0
- package/apps/server/dist/cli.js +2050 -0
- package/apps/web/dist/assets/index-hoPwNvfD.js +18 -0
- package/apps/web/dist/assets/index-lYVPS1ZE.css +1 -0
- package/apps/web/dist/favicon.svg +1 -0
- package/apps/web/dist/index.html +14 -0
- package/bin/crewdesk-hook.mjs +8 -0
- package/bin/crewdesk.mjs +23 -0
- package/docs/API.md +36 -0
- package/docs/CONFIGURATION.md +40 -0
- package/docs/DEVELOPMENT.md +25 -0
- package/docs/HOOKS.md +28 -0
- package/docs/INSTALLATION.md +37 -0
- package/docs/SECURITY.md +10 -0
- package/docs/TROUBLESHOOTING.md +11 -0
- package/package.json +75 -0
- package/packages/hook/dist/hook.js +457 -0
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
import { createRequire as __cr } from 'node:module'; const require = __cr(import.meta.url);
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { exec, execSync } from "node:child_process";
|
|
5
|
+
import fs8 from "node:fs";
|
|
6
|
+
import path8 from "node:path";
|
|
7
|
+
import readline from "node:readline/promises";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { z as z2 } from "zod";
|
|
15
|
+
|
|
16
|
+
// ../../packages/shared/src/types.ts
|
|
17
|
+
var AGENT_STATUSES = [
|
|
18
|
+
"STARTING",
|
|
19
|
+
"WORKING",
|
|
20
|
+
"WAITING",
|
|
21
|
+
"QUESTION",
|
|
22
|
+
"PERMISSION",
|
|
23
|
+
"ERROR",
|
|
24
|
+
"COMPLETED",
|
|
25
|
+
"OFFLINE"
|
|
26
|
+
];
|
|
27
|
+
var EVENT_TYPES = [
|
|
28
|
+
"session.started",
|
|
29
|
+
"session.updated",
|
|
30
|
+
"session.stopped",
|
|
31
|
+
"question.created",
|
|
32
|
+
"question.answered",
|
|
33
|
+
"question.cancelled",
|
|
34
|
+
"permission.created",
|
|
35
|
+
"permission.approved",
|
|
36
|
+
"permission.denied",
|
|
37
|
+
"prompt.created",
|
|
38
|
+
"prompt.sent",
|
|
39
|
+
"prompt.cancelled",
|
|
40
|
+
"notification.created",
|
|
41
|
+
"task.started",
|
|
42
|
+
"task.completed",
|
|
43
|
+
"task.failed"
|
|
44
|
+
];
|
|
45
|
+
var REQUEST_EVENT_TYPES = ["permission.created", "question.created", "prompt.created"];
|
|
46
|
+
var EVENT_STATUSES = ["PENDING", "PROCESSING", "RESOLVED", "CANCELLED", "FAILED"];
|
|
47
|
+
var PRIORITIES = ["LOW", "NORMAL", "HIGH", "CRITICAL"];
|
|
48
|
+
var RESPONSE_ACTIONS = ["allow", "deny", "answer", "prompt", "dismiss"];
|
|
49
|
+
|
|
50
|
+
// ../../packages/shared/src/schemas.ts
|
|
51
|
+
import { z } from "zod";
|
|
52
|
+
var SessionIdSchema = z.string().min(1).max(128).regex(/^[A-Za-z0-9_.:-]+$/, "invalid session id");
|
|
53
|
+
var EventIdSchema = z.string().min(1).max(64).regex(/^evt_[0-9A-Z]{26}$/, "invalid event id");
|
|
54
|
+
var AgentIdSchema = z.string().min(1).max(64).regex(/^agent_[0-9A-Z]{26}$/, "invalid agent id");
|
|
55
|
+
var RegisterAgentSchema = z.object({
|
|
56
|
+
sessionId: SessionIdSchema,
|
|
57
|
+
name: z.string().min(1).max(100).optional(),
|
|
58
|
+
project: z.string().max(200).optional(),
|
|
59
|
+
cwd: z.string().max(1e3).optional(),
|
|
60
|
+
pid: z.number().int().positive().optional(),
|
|
61
|
+
account: z.string().max(200).optional(),
|
|
62
|
+
source: z.string().max(50).optional()
|
|
63
|
+
});
|
|
64
|
+
var UpdateAgentSchema = z.object({
|
|
65
|
+
name: z.string().min(1).max(100).optional(),
|
|
66
|
+
status: z.enum(AGENT_STATUSES).optional(),
|
|
67
|
+
activity: z.string().max(500).nullable().optional()
|
|
68
|
+
}).strict();
|
|
69
|
+
var HeartbeatSchema = z.object({
|
|
70
|
+
sessionId: SessionIdSchema,
|
|
71
|
+
status: z.enum(AGENT_STATUSES).optional(),
|
|
72
|
+
activity: z.string().max(500).nullable().optional()
|
|
73
|
+
});
|
|
74
|
+
var CreateEventSchema = z.object({
|
|
75
|
+
sessionId: SessionIdSchema,
|
|
76
|
+
type: z.enum(EVENT_TYPES),
|
|
77
|
+
message: z.string().max(2e4).optional(),
|
|
78
|
+
priority: z.enum(PRIORITIES).optional(),
|
|
79
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
80
|
+
/** Optional agent metadata so an event can implicitly register its session. */
|
|
81
|
+
agent: RegisterAgentSchema.omit({ sessionId: true }).optional()
|
|
82
|
+
});
|
|
83
|
+
var RespondSchema = z.object({
|
|
84
|
+
action: z.enum(RESPONSE_ACTIONS),
|
|
85
|
+
/** Must match the event's session when supplied (wrong-session protection). */
|
|
86
|
+
sessionId: SessionIdSchema.optional(),
|
|
87
|
+
response: z.string().max(2e4).optional(),
|
|
88
|
+
/** Alias accepted for compatibility with the spec's `answer` field. */
|
|
89
|
+
answer: z.string().max(2e4).optional(),
|
|
90
|
+
answers: z.record(z.string(), z.string().max(2e4)).optional(),
|
|
91
|
+
source: z.enum(["dashboard", "terminal", "policy", "system"]).optional(),
|
|
92
|
+
/** Required for destructive permissions when action=allow. */
|
|
93
|
+
confirm: z.boolean().optional()
|
|
94
|
+
});
|
|
95
|
+
var CancelSchema = z.object({
|
|
96
|
+
sessionId: SessionIdSchema.optional(),
|
|
97
|
+
reason: z.string().max(1e3).optional()
|
|
98
|
+
});
|
|
99
|
+
var ListEventsQuerySchema = z.object({
|
|
100
|
+
status: z.string().transform((s) => s.toUpperCase()).pipe(z.enum(EVENT_STATUSES)).optional(),
|
|
101
|
+
type: z.string().max(50).optional(),
|
|
102
|
+
agent: z.string().max(64).optional(),
|
|
103
|
+
session: SessionIdSchema.optional(),
|
|
104
|
+
project: z.string().max(200).optional(),
|
|
105
|
+
q: z.string().max(200).optional(),
|
|
106
|
+
limit: z.coerce.number().int().min(1).max(1e3).optional(),
|
|
107
|
+
before: z.string().max(64).optional()
|
|
108
|
+
});
|
|
109
|
+
var WaitQuerySchema = z.object({
|
|
110
|
+
sessionId: SessionIdSchema,
|
|
111
|
+
timeout: z.coerce.number().int().min(0).max(6e4).optional()
|
|
112
|
+
});
|
|
113
|
+
var PolicyRuleSchema = z.object({
|
|
114
|
+
/** Tool name glob, e.g. "Bash", "Write", "mcp__*". Defaults to any tool. */
|
|
115
|
+
tool: z.string().min(1).max(200).optional(),
|
|
116
|
+
/** Glob matched against the operation summary (the command for Bash, path for file tools). */
|
|
117
|
+
match: z.string().min(1).max(1e3),
|
|
118
|
+
action: z.enum(["allow", "deny", "ask"]),
|
|
119
|
+
reason: z.string().max(500).optional()
|
|
120
|
+
});
|
|
121
|
+
var PolicyFileSchema = z.object({
|
|
122
|
+
permissions: z.array(PolicyRuleSchema).default([])
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ../../packages/shared/src/operations.ts
|
|
126
|
+
function globToRegExp(glob) {
|
|
127
|
+
let re = "";
|
|
128
|
+
for (const ch of glob) {
|
|
129
|
+
if (ch === "*") re += ".*";
|
|
130
|
+
else if (ch === "?") re += ".";
|
|
131
|
+
else re += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
132
|
+
}
|
|
133
|
+
return new RegExp(`^${re}$`, "s");
|
|
134
|
+
}
|
|
135
|
+
function globMatch(glob, value) {
|
|
136
|
+
return globToRegExp(glob).test(value);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ../../packages/shared/src/index.ts
|
|
140
|
+
var DEFAULT_PORT = 7777;
|
|
141
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
142
|
+
|
|
143
|
+
// src/config.ts
|
|
144
|
+
var LOG_LEVELS = ["error", "warn", "info", "debug"];
|
|
145
|
+
var ConfigFileSchema = z2.object({
|
|
146
|
+
port: z2.number().int().min(1).max(65535),
|
|
147
|
+
host: z2.string().min(1),
|
|
148
|
+
database: z2.string().min(1),
|
|
149
|
+
heartbeatTimeout: z2.number().int().min(1e3),
|
|
150
|
+
notifications: z2.boolean(),
|
|
151
|
+
sound: z2.boolean(),
|
|
152
|
+
logLevel: z2.enum(LOG_LEVELS),
|
|
153
|
+
/** How long (ms) a hook may be disconnected before its pending request is considered handled elsewhere. */
|
|
154
|
+
waiterGraceMs: z2.number().int().min(100),
|
|
155
|
+
/** Whether the Stop hook should wait for a follow-up prompt from the dashboard. */
|
|
156
|
+
idlePrompts: z2.boolean(),
|
|
157
|
+
/** Optional auto-expiry for pending permission requests (ms). 0 disables. */
|
|
158
|
+
permissionExpiryMs: z2.number().int().min(0)
|
|
159
|
+
}).partial();
|
|
160
|
+
function hubHome() {
|
|
161
|
+
return process.env.CREWDESK_HOME ?? path.join(os.homedir(), ".crewdesk");
|
|
162
|
+
}
|
|
163
|
+
function expandHome(p) {
|
|
164
|
+
if (p === "~") return os.homedir();
|
|
165
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return path.join(os.homedir(), p.slice(2));
|
|
166
|
+
return p;
|
|
167
|
+
}
|
|
168
|
+
function configPath(home = hubHome()) {
|
|
169
|
+
return path.join(home, "config.json");
|
|
170
|
+
}
|
|
171
|
+
function readConfigFile(home = hubHome()) {
|
|
172
|
+
const file = configPath(home);
|
|
173
|
+
if (!fs.existsSync(file)) return {};
|
|
174
|
+
try {
|
|
175
|
+
const parsed = ConfigFileSchema.safeParse(JSON.parse(fs.readFileSync(file, "utf8")));
|
|
176
|
+
if (parsed.success) return parsed.data;
|
|
177
|
+
process.stderr.write(`crewdesk: ignoring invalid config ${file}: ${parsed.error.message}
|
|
178
|
+
`);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
process.stderr.write(`crewdesk: cannot read config ${file}: ${err.message}
|
|
181
|
+
`);
|
|
182
|
+
}
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
function envInt(name) {
|
|
186
|
+
const v = process.env[name];
|
|
187
|
+
if (v === void 0 || v === "") return void 0;
|
|
188
|
+
const n = Number(v);
|
|
189
|
+
return Number.isFinite(n) ? n : void 0;
|
|
190
|
+
}
|
|
191
|
+
function envBool(name) {
|
|
192
|
+
const v = process.env[name];
|
|
193
|
+
if (v === void 0 || v === "") return void 0;
|
|
194
|
+
return /^(1|true|yes|on)$/i.test(v);
|
|
195
|
+
}
|
|
196
|
+
function loadConfig(overrides = {}) {
|
|
197
|
+
const home = overrides.home ?? hubHome();
|
|
198
|
+
const file = readConfigFile(home);
|
|
199
|
+
const level = (process.env.LOG_LEVEL ?? process.env.CREWDESK_LOG_LEVEL)?.toLowerCase();
|
|
200
|
+
const cfg = {
|
|
201
|
+
home,
|
|
202
|
+
port: envInt("CREWDESK_PORT") ?? file.port ?? DEFAULT_PORT,
|
|
203
|
+
host: process.env.CREWDESK_HOST ?? file.host ?? DEFAULT_HOST,
|
|
204
|
+
database: expandHome(process.env.CREWDESK_DB ?? file.database ?? path.join(home, "data.db")),
|
|
205
|
+
heartbeatTimeout: envInt("CREWDESK_HEARTBEAT_TIMEOUT") ?? file.heartbeatTimeout ?? 3e4,
|
|
206
|
+
notifications: envBool("CREWDESK_NOTIFICATIONS") ?? file.notifications ?? true,
|
|
207
|
+
sound: envBool("CREWDESK_SOUND") ?? file.sound ?? true,
|
|
208
|
+
logLevel: LOG_LEVELS.includes(level ?? "") ? level : file.logLevel ?? "info",
|
|
209
|
+
waiterGraceMs: envInt("CREWDESK_WAITER_GRACE") ?? file.waiterGraceMs ?? 5e3,
|
|
210
|
+
idlePrompts: envBool("CREWDESK_IDLE_PROMPTS") ?? file.idlePrompts ?? true,
|
|
211
|
+
permissionExpiryMs: envInt("CREWDESK_PERMISSION_EXPIRY") ?? file.permissionExpiryMs ?? 0,
|
|
212
|
+
policyFile: path.join(home, "policies.yaml"),
|
|
213
|
+
webDir: null,
|
|
214
|
+
adminToken: null,
|
|
215
|
+
...overrides
|
|
216
|
+
};
|
|
217
|
+
return cfg;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/cli/daemon.ts
|
|
221
|
+
import { spawn } from "node:child_process";
|
|
222
|
+
import crypto from "node:crypto";
|
|
223
|
+
import fs6 from "node:fs";
|
|
224
|
+
import path6 from "node:path";
|
|
225
|
+
|
|
226
|
+
// src/logger.ts
|
|
227
|
+
import fs2 from "node:fs";
|
|
228
|
+
import path2 from "node:path";
|
|
229
|
+
var ORDER = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
230
|
+
var Logger = class {
|
|
231
|
+
constructor(level, logFile2 = null, toConsole = true) {
|
|
232
|
+
this.level = level;
|
|
233
|
+
this.toConsole = toConsole;
|
|
234
|
+
if (logFile2) {
|
|
235
|
+
fs2.mkdirSync(path2.dirname(logFile2), { recursive: true });
|
|
236
|
+
this.stream = fs2.createWriteStream(logFile2, { flags: "a" });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
level;
|
|
240
|
+
toConsole;
|
|
241
|
+
stream = null;
|
|
242
|
+
error(msg, fields) {
|
|
243
|
+
this.write("error", msg, fields);
|
|
244
|
+
}
|
|
245
|
+
warn(msg, fields) {
|
|
246
|
+
this.write("warn", msg, fields);
|
|
247
|
+
}
|
|
248
|
+
info(msg, fields) {
|
|
249
|
+
this.write("info", msg, fields);
|
|
250
|
+
}
|
|
251
|
+
debug(msg, fields) {
|
|
252
|
+
this.write("debug", msg, fields);
|
|
253
|
+
}
|
|
254
|
+
write(level, msg, fields) {
|
|
255
|
+
if (ORDER[level] > ORDER[this.level]) return;
|
|
256
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
257
|
+
const ids = [fields?.sessionId, fields?.eventId].filter(Boolean).join(" ");
|
|
258
|
+
const extra = fields ? Object.entries(fields).filter(([k, v]) => k !== "sessionId" && k !== "eventId" && v !== void 0).map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`).join(" ") : "";
|
|
259
|
+
const line = `${ts} ${level.toUpperCase().padEnd(5)} ${msg}${ids ? " " + ids : ""}${extra ? " " + extra : ""}`;
|
|
260
|
+
if (this.toConsole) (level === "error" ? process.stderr : process.stdout).write(line + "\n");
|
|
261
|
+
this.stream?.write(line + "\n");
|
|
262
|
+
}
|
|
263
|
+
close() {
|
|
264
|
+
this.stream?.end();
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// src/server.ts
|
|
269
|
+
import fs5 from "node:fs";
|
|
270
|
+
import path5 from "node:path";
|
|
271
|
+
import Fastify from "fastify";
|
|
272
|
+
import fastifyStatic from "@fastify/static";
|
|
273
|
+
import fastifyWebsocket from "@fastify/websocket";
|
|
274
|
+
|
|
275
|
+
// src/api/agents.ts
|
|
276
|
+
import { z as z3 } from "zod";
|
|
277
|
+
|
|
278
|
+
// src/services/errors.ts
|
|
279
|
+
var HubError = class extends Error {
|
|
280
|
+
constructor(statusCode, code, message, details) {
|
|
281
|
+
super(message);
|
|
282
|
+
this.statusCode = statusCode;
|
|
283
|
+
this.code = code;
|
|
284
|
+
this.details = details;
|
|
285
|
+
}
|
|
286
|
+
statusCode;
|
|
287
|
+
code;
|
|
288
|
+
details;
|
|
289
|
+
};
|
|
290
|
+
var notFound = (code, message) => new HubError(404, code, message);
|
|
291
|
+
var conflict = (code, message) => new HubError(409, code, message);
|
|
292
|
+
var forbidden = (code, message) => new HubError(403, code, message);
|
|
293
|
+
var unprocessable = (code, message, details) => new HubError(422, code, message, details);
|
|
294
|
+
|
|
295
|
+
// src/api/validate.ts
|
|
296
|
+
function parse(schema, value) {
|
|
297
|
+
const result = schema.safeParse(value ?? {});
|
|
298
|
+
if (!result.success) {
|
|
299
|
+
throw new HubError(
|
|
300
|
+
400,
|
|
301
|
+
"VALIDATION_ERROR",
|
|
302
|
+
"Request validation failed.",
|
|
303
|
+
result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message }))
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return result.data;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// src/api/agents.ts
|
|
310
|
+
var IdParams = z3.object({ id: AgentIdSchema });
|
|
311
|
+
function registerAgentRoutes(app, hub) {
|
|
312
|
+
app.post("/api/agents/register", async (req, reply) => {
|
|
313
|
+
const input = parse(RegisterAgentSchema, req.body);
|
|
314
|
+
const { agent, created } = hub.agents.register(input);
|
|
315
|
+
if (created) {
|
|
316
|
+
hub.events.create({ sessionId: agent.sessionId, type: "session.started", message: `${agent.name} started` });
|
|
317
|
+
}
|
|
318
|
+
reply.code(created ? 201 : 200);
|
|
319
|
+
return { success: true, agentId: agent.id, agent: hub.agents.get(agent.id) };
|
|
320
|
+
});
|
|
321
|
+
app.post("/api/agents/heartbeat", async (req) => {
|
|
322
|
+
const input = parse(HeartbeatSchema, req.body);
|
|
323
|
+
const agent = hub.agents.getBySession(input.sessionId);
|
|
324
|
+
const status = agent.status === "OFFLINE" ? input.status ?? "WORKING" : input.status;
|
|
325
|
+
return { success: true, agent: hub.agents.touch(input.sessionId, status, input.activity) };
|
|
326
|
+
});
|
|
327
|
+
app.get("/api/agents", async () => ({ agents: hub.agents.list() }));
|
|
328
|
+
app.get("/api/agents/:id", async (req) => {
|
|
329
|
+
const { id } = parse(IdParams, req.params);
|
|
330
|
+
const agent = hub.agents.get(id);
|
|
331
|
+
const events = hub.events.list({ session: agent.sessionId, limit: 100 });
|
|
332
|
+
return { agent, events };
|
|
333
|
+
});
|
|
334
|
+
app.patch("/api/agents/:id", async (req) => {
|
|
335
|
+
const { id } = parse(IdParams, req.params);
|
|
336
|
+
return { agent: hub.agents.update(id, parse(UpdateAgentSchema, req.body)) };
|
|
337
|
+
});
|
|
338
|
+
app.delete("/api/agents/:id", async (req) => {
|
|
339
|
+
const { id } = parse(IdParams, req.params);
|
|
340
|
+
const agent = hub.agents.get(id);
|
|
341
|
+
hub.events.cancelAllForSession(agent.sessionId, "Agent removed from the dashboard.");
|
|
342
|
+
hub.agents.remove(id);
|
|
343
|
+
return { success: true };
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/api/events.ts
|
|
348
|
+
import { z as z4 } from "zod";
|
|
349
|
+
var IdParams2 = z4.object({ id: EventIdSchema });
|
|
350
|
+
function registerEventRoutes(app, hub) {
|
|
351
|
+
app.post("/api/events", async (req, reply) => {
|
|
352
|
+
const input = parse(CreateEventSchema, req.body);
|
|
353
|
+
const ev = hub.events.create(input);
|
|
354
|
+
if (ev.status === "PENDING" && input.payload?.transport === "hook") hub.routing.hook.armIdleTimer(ev.id);
|
|
355
|
+
reply.code(201);
|
|
356
|
+
return { id: ev.id, status: ev.status, event: ev };
|
|
357
|
+
});
|
|
358
|
+
app.get("/api/events", async (req) => {
|
|
359
|
+
const q = parse(ListEventsQuerySchema, req.query);
|
|
360
|
+
return { events: hub.events.list(q) };
|
|
361
|
+
});
|
|
362
|
+
app.get("/api/events/pending", async () => ({ events: hub.events.pending() }));
|
|
363
|
+
app.get("/api/events/:id", async (req) => {
|
|
364
|
+
const { id } = parse(IdParams2, req.params);
|
|
365
|
+
return { event: hub.events.get(id) };
|
|
366
|
+
});
|
|
367
|
+
app.post("/api/events/:id/respond", async (req) => {
|
|
368
|
+
const { id } = parse(IdParams2, req.params);
|
|
369
|
+
const input = parse(RespondSchema, req.body);
|
|
370
|
+
const ev = await hub.events.respond(id, input);
|
|
371
|
+
return { success: true, eventId: ev.id, status: ev.status, event: ev };
|
|
372
|
+
});
|
|
373
|
+
app.post("/api/events/:id/cancel", async (req) => {
|
|
374
|
+
const { id } = parse(IdParams2, req.params);
|
|
375
|
+
const input = parse(CancelSchema, req.body);
|
|
376
|
+
const ev = hub.events.cancel(id, input.reason ?? "Cancelled.", input.sessionId);
|
|
377
|
+
return { success: true, eventId: ev.id, status: ev.status, event: ev };
|
|
378
|
+
});
|
|
379
|
+
app.delete("/api/events/:id", async (req) => {
|
|
380
|
+
const { id } = parse(IdParams2, req.params);
|
|
381
|
+
hub.events.deleteEvent(id);
|
|
382
|
+
return { success: true };
|
|
383
|
+
});
|
|
384
|
+
app.get("/api/events/:id/wait", async (req) => {
|
|
385
|
+
const { id } = parse(IdParams2, req.params);
|
|
386
|
+
const q = parse(WaitQuerySchema, req.query);
|
|
387
|
+
const result = await hub.routing.wait(id, q.sessionId, q.timeout ?? 25e3, (fn) => {
|
|
388
|
+
req.raw.once("close", fn);
|
|
389
|
+
});
|
|
390
|
+
return result;
|
|
391
|
+
});
|
|
392
|
+
app.get("/api/policies", async () => hub.policies.list());
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/version.ts
|
|
396
|
+
var VERSION = "0.1.0";
|
|
397
|
+
|
|
398
|
+
// src/api/health.ts
|
|
399
|
+
function registerHealthRoutes(app, hub) {
|
|
400
|
+
const health2 = async () => {
|
|
401
|
+
let database = "ok";
|
|
402
|
+
try {
|
|
403
|
+
hub.db.prepare("SELECT 1").get();
|
|
404
|
+
} catch {
|
|
405
|
+
database = "error";
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
status: "ok",
|
|
409
|
+
database,
|
|
410
|
+
websocket: "ok",
|
|
411
|
+
uptime: Math.round((Date.now() - hub.startedAt) / 1e3),
|
|
412
|
+
version: VERSION,
|
|
413
|
+
pid: process.pid
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
app.get("/health", health2);
|
|
417
|
+
app.get("/api/health", health2);
|
|
418
|
+
app.get("/api/settings", async () => ({
|
|
419
|
+
notifications: hub.config.notifications,
|
|
420
|
+
sound: hub.config.sound,
|
|
421
|
+
idlePrompts: hub.config.idlePrompts,
|
|
422
|
+
heartbeatTimeout: hub.config.heartbeatTimeout,
|
|
423
|
+
version: VERSION
|
|
424
|
+
}));
|
|
425
|
+
app.post("/api/admin/shutdown", async (req, reply) => {
|
|
426
|
+
const token = req.headers["x-hub-token"];
|
|
427
|
+
if (!hub.config.adminToken || token !== hub.config.adminToken) {
|
|
428
|
+
return reply.code(401).send({ error: { code: "UNAUTHORIZED", message: "Invalid admin token." } });
|
|
429
|
+
}
|
|
430
|
+
setTimeout(() => void app.close().then(() => process.exit(0)), 50);
|
|
431
|
+
return { success: true };
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/db/database.ts
|
|
436
|
+
import fs3 from "node:fs";
|
|
437
|
+
import path3 from "node:path";
|
|
438
|
+
import { DatabaseSync } from "node:sqlite";
|
|
439
|
+
|
|
440
|
+
// src/db/migrations.ts
|
|
441
|
+
var MIGRATIONS = [
|
|
442
|
+
`
|
|
443
|
+
CREATE TABLE agents (
|
|
444
|
+
id TEXT PRIMARY KEY,
|
|
445
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
446
|
+
name TEXT NOT NULL,
|
|
447
|
+
project_name TEXT,
|
|
448
|
+
cwd TEXT,
|
|
449
|
+
pid INTEGER,
|
|
450
|
+
account TEXT,
|
|
451
|
+
status TEXT NOT NULL,
|
|
452
|
+
activity TEXT,
|
|
453
|
+
created_at TEXT NOT NULL,
|
|
454
|
+
last_seen_at TEXT NOT NULL
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
CREATE TABLE events (
|
|
458
|
+
id TEXT PRIMARY KEY,
|
|
459
|
+
session_id TEXT NOT NULL,
|
|
460
|
+
type TEXT NOT NULL,
|
|
461
|
+
status TEXT NOT NULL,
|
|
462
|
+
priority TEXT NOT NULL DEFAULT 'NORMAL',
|
|
463
|
+
message TEXT,
|
|
464
|
+
payload TEXT,
|
|
465
|
+
created_at TEXT NOT NULL,
|
|
466
|
+
resolved_at TEXT,
|
|
467
|
+
FOREIGN KEY(session_id) REFERENCES agents(session_id)
|
|
468
|
+
);
|
|
469
|
+
CREATE INDEX idx_events_session ON events(session_id, created_at);
|
|
470
|
+
CREATE INDEX idx_events_status ON events(status, created_at);
|
|
471
|
+
|
|
472
|
+
CREATE TABLE responses (
|
|
473
|
+
id TEXT PRIMARY KEY,
|
|
474
|
+
event_id TEXT NOT NULL,
|
|
475
|
+
session_id TEXT NOT NULL,
|
|
476
|
+
action TEXT NOT NULL,
|
|
477
|
+
response TEXT,
|
|
478
|
+
answers TEXT,
|
|
479
|
+
source TEXT NOT NULL DEFAULT 'dashboard',
|
|
480
|
+
created_at TEXT NOT NULL,
|
|
481
|
+
FOREIGN KEY(event_id) REFERENCES events(id)
|
|
482
|
+
);
|
|
483
|
+
CREATE UNIQUE INDEX idx_responses_event ON responses(event_id);
|
|
484
|
+
`
|
|
485
|
+
];
|
|
486
|
+
function migrate(db) {
|
|
487
|
+
const row = db.prepare("PRAGMA user_version").get();
|
|
488
|
+
let version = row.user_version;
|
|
489
|
+
while (version < MIGRATIONS.length) {
|
|
490
|
+
db.exec("BEGIN");
|
|
491
|
+
try {
|
|
492
|
+
db.exec(MIGRATIONS[version]);
|
|
493
|
+
version += 1;
|
|
494
|
+
db.exec(`PRAGMA user_version = ${version}`);
|
|
495
|
+
db.exec("COMMIT");
|
|
496
|
+
} catch (err) {
|
|
497
|
+
db.exec("ROLLBACK");
|
|
498
|
+
throw err;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return version;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/db/database.ts
|
|
505
|
+
function openDatabase(file) {
|
|
506
|
+
if (file !== ":memory:") fs3.mkdirSync(path3.dirname(file), { recursive: true });
|
|
507
|
+
const db = new DatabaseSync(file);
|
|
508
|
+
db.exec("PRAGMA journal_mode = WAL;");
|
|
509
|
+
db.exec("PRAGMA foreign_keys = ON;");
|
|
510
|
+
db.exec("PRAGMA busy_timeout = 5000;");
|
|
511
|
+
migrate(db);
|
|
512
|
+
return db;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// src/services/agent.service.ts
|
|
516
|
+
import path4 from "node:path";
|
|
517
|
+
import { ulid } from "ulid";
|
|
518
|
+
var PENDING_SQL = `(SELECT COUNT(*) FROM events e WHERE e.session_id = a.session_id AND e.status IN ('PENDING','PROCESSING'))`;
|
|
519
|
+
function toAgent(r) {
|
|
520
|
+
return {
|
|
521
|
+
id: r.id,
|
|
522
|
+
sessionId: r.session_id,
|
|
523
|
+
name: r.name,
|
|
524
|
+
projectName: r.project_name,
|
|
525
|
+
cwd: r.cwd,
|
|
526
|
+
pid: r.pid,
|
|
527
|
+
account: r.account,
|
|
528
|
+
status: r.status,
|
|
529
|
+
activity: r.activity,
|
|
530
|
+
createdAt: r.created_at,
|
|
531
|
+
lastSeenAt: r.last_seen_at,
|
|
532
|
+
pendingCount: r.pending_count ?? 0
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
536
|
+
var AgentService = class {
|
|
537
|
+
constructor(db, bus, log) {
|
|
538
|
+
this.db = db;
|
|
539
|
+
this.bus = bus;
|
|
540
|
+
this.log = log;
|
|
541
|
+
}
|
|
542
|
+
db;
|
|
543
|
+
bus;
|
|
544
|
+
log;
|
|
545
|
+
list() {
|
|
546
|
+
const rows = this.db.prepare(`SELECT a.*, ${PENDING_SQL} AS pending_count FROM agents a ORDER BY a.created_at`).all();
|
|
547
|
+
return rows.map(toAgent);
|
|
548
|
+
}
|
|
549
|
+
get(id) {
|
|
550
|
+
const row = this.db.prepare(`SELECT a.*, ${PENDING_SQL} AS pending_count FROM agents a WHERE a.id = ?`).get(id);
|
|
551
|
+
if (!row) throw notFound("AGENT_NOT_FOUND", "Agent was not found.");
|
|
552
|
+
return toAgent(row);
|
|
553
|
+
}
|
|
554
|
+
findBySession(sessionId) {
|
|
555
|
+
const row = this.db.prepare(`SELECT a.*, ${PENDING_SQL} AS pending_count FROM agents a WHERE a.session_id = ?`).get(sessionId);
|
|
556
|
+
return row ? toAgent(row) : null;
|
|
557
|
+
}
|
|
558
|
+
getBySession(sessionId) {
|
|
559
|
+
const agent = this.findBySession(sessionId);
|
|
560
|
+
if (!agent) throw notFound("SESSION_NOT_FOUND", "Session is not registered.");
|
|
561
|
+
return agent;
|
|
562
|
+
}
|
|
563
|
+
/** Pick "<project> #N" with the smallest N not used by another online agent of that project. */
|
|
564
|
+
defaultName(project) {
|
|
565
|
+
const rows = this.db.prepare(`SELECT name FROM agents WHERE project_name = ? AND status != 'OFFLINE'`).all(project);
|
|
566
|
+
const taken = new Set(rows.map((r) => r.name));
|
|
567
|
+
for (let n = 1; ; n++) {
|
|
568
|
+
const candidate = `${project} #${n}`;
|
|
569
|
+
if (!taken.has(candidate)) return candidate;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Register (or re-register) a session. Idempotent per session id: a reconnecting session keeps its
|
|
574
|
+
* agent id and name, fills in missing metadata and comes back from OFFLINE.
|
|
575
|
+
*/
|
|
576
|
+
register(input, initialStatus = "STARTING") {
|
|
577
|
+
const existing = this.findBySession(input.sessionId);
|
|
578
|
+
const ts = now();
|
|
579
|
+
if (existing) {
|
|
580
|
+
const status = existing.status === "OFFLINE" || initialStatus !== "STARTING" ? initialStatus : existing.status;
|
|
581
|
+
this.db.prepare(
|
|
582
|
+
`UPDATE agents SET
|
|
583
|
+
name = COALESCE(?, name),
|
|
584
|
+
project_name = COALESCE(?, project_name),
|
|
585
|
+
cwd = COALESCE(?, cwd),
|
|
586
|
+
pid = COALESCE(?, pid),
|
|
587
|
+
account = COALESCE(?, account),
|
|
588
|
+
status = ?,
|
|
589
|
+
last_seen_at = ?
|
|
590
|
+
WHERE session_id = ?`
|
|
591
|
+
).run(
|
|
592
|
+
input.name ?? null,
|
|
593
|
+
input.project ?? null,
|
|
594
|
+
input.cwd ?? null,
|
|
595
|
+
input.pid ?? null,
|
|
596
|
+
input.account ?? null,
|
|
597
|
+
status,
|
|
598
|
+
ts,
|
|
599
|
+
input.sessionId
|
|
600
|
+
);
|
|
601
|
+
const agent2 = this.getBySession(input.sessionId);
|
|
602
|
+
if (existing.status === "OFFLINE") this.log.info("Agent reconnected", { sessionId: input.sessionId });
|
|
603
|
+
this.bus.publish({ type: "agent.updated", agent: agent2 });
|
|
604
|
+
return { agent: agent2, created: false };
|
|
605
|
+
}
|
|
606
|
+
const project = input.project ?? (input.cwd ? path4.basename(input.cwd) : "unknown");
|
|
607
|
+
const agent = {
|
|
608
|
+
id: `agent_${ulid()}`,
|
|
609
|
+
sessionId: input.sessionId,
|
|
610
|
+
name: input.name ?? this.defaultName(project),
|
|
611
|
+
projectName: project,
|
|
612
|
+
cwd: input.cwd ?? null,
|
|
613
|
+
pid: input.pid ?? null,
|
|
614
|
+
account: input.account ?? null,
|
|
615
|
+
status: initialStatus,
|
|
616
|
+
activity: null,
|
|
617
|
+
createdAt: ts,
|
|
618
|
+
lastSeenAt: ts,
|
|
619
|
+
pendingCount: 0
|
|
620
|
+
};
|
|
621
|
+
this.db.prepare(
|
|
622
|
+
`INSERT INTO agents (id, session_id, name, project_name, cwd, pid, account, status, activity, created_at, last_seen_at)
|
|
623
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
624
|
+
).run(
|
|
625
|
+
agent.id,
|
|
626
|
+
agent.sessionId,
|
|
627
|
+
agent.name,
|
|
628
|
+
agent.projectName,
|
|
629
|
+
agent.cwd,
|
|
630
|
+
agent.pid,
|
|
631
|
+
agent.account,
|
|
632
|
+
agent.status,
|
|
633
|
+
null,
|
|
634
|
+
ts,
|
|
635
|
+
ts
|
|
636
|
+
);
|
|
637
|
+
this.log.info("Agent registered", { sessionId: agent.sessionId, name: agent.name });
|
|
638
|
+
this.bus.publish({ type: "agent.created", agent });
|
|
639
|
+
return { agent, created: true };
|
|
640
|
+
}
|
|
641
|
+
update(id, patch) {
|
|
642
|
+
const current = this.get(id);
|
|
643
|
+
this.db.prepare(`UPDATE agents SET name = ?, status = ?, activity = ? WHERE id = ?`).run(
|
|
644
|
+
patch.name ?? current.name,
|
|
645
|
+
patch.status ?? current.status,
|
|
646
|
+
patch.activity === void 0 ? current.activity : patch.activity,
|
|
647
|
+
id
|
|
648
|
+
);
|
|
649
|
+
const agent = this.get(id);
|
|
650
|
+
this.bus.publish({ type: "agent.updated", agent });
|
|
651
|
+
return agent;
|
|
652
|
+
}
|
|
653
|
+
/** Record activity from a session: bumps last_seen_at and optionally changes status/activity. */
|
|
654
|
+
touch(sessionId, status, activity) {
|
|
655
|
+
const current = this.findBySession(sessionId);
|
|
656
|
+
if (!current) return null;
|
|
657
|
+
this.db.prepare(`UPDATE agents SET last_seen_at = ?, status = ?, activity = ? WHERE session_id = ?`).run(
|
|
658
|
+
now(),
|
|
659
|
+
status ?? current.status,
|
|
660
|
+
activity === void 0 ? current.activity : activity,
|
|
661
|
+
sessionId
|
|
662
|
+
);
|
|
663
|
+
const agent = this.getBySession(sessionId);
|
|
664
|
+
this.bus.publish({ type: "agent.updated", agent });
|
|
665
|
+
return agent;
|
|
666
|
+
}
|
|
667
|
+
/** Re-broadcast an agent (e.g. after its pending count changed). */
|
|
668
|
+
refresh(sessionId) {
|
|
669
|
+
const agent = this.findBySession(sessionId);
|
|
670
|
+
if (agent) this.bus.publish({ type: "agent.updated", agent });
|
|
671
|
+
}
|
|
672
|
+
remove(id) {
|
|
673
|
+
const agent = this.get(id);
|
|
674
|
+
this.db.exec("BEGIN");
|
|
675
|
+
try {
|
|
676
|
+
this.db.prepare(`DELETE FROM responses WHERE session_id = ?`).run(agent.sessionId);
|
|
677
|
+
this.db.prepare(`DELETE FROM events WHERE session_id = ?`).run(agent.sessionId);
|
|
678
|
+
this.db.prepare(`DELETE FROM agents WHERE id = ?`).run(id);
|
|
679
|
+
this.db.exec("COMMIT");
|
|
680
|
+
} catch (err) {
|
|
681
|
+
this.db.exec("ROLLBACK");
|
|
682
|
+
throw err;
|
|
683
|
+
}
|
|
684
|
+
this.bus.publish({ type: "agent.removed", agentId: id });
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// src/services/bus.ts
|
|
689
|
+
var EventBus = class {
|
|
690
|
+
listeners = /* @__PURE__ */ new Set();
|
|
691
|
+
subscribe(fn) {
|
|
692
|
+
this.listeners.add(fn);
|
|
693
|
+
return () => this.listeners.delete(fn);
|
|
694
|
+
}
|
|
695
|
+
publish(msg) {
|
|
696
|
+
for (const fn of this.listeners) {
|
|
697
|
+
try {
|
|
698
|
+
fn(msg);
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
get size() {
|
|
704
|
+
return this.listeners.size;
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// src/services/event.service.ts
|
|
709
|
+
import { ulid as ulid2 } from "ulid";
|
|
710
|
+
var SELECT = `
|
|
711
|
+
SELECT e.*, a.id AS agent_id,
|
|
712
|
+
r.id AS r_id, r.action AS r_action, r.response AS r_response, r.answers AS r_answers,
|
|
713
|
+
r.source AS r_source, r.created_at AS r_created_at
|
|
714
|
+
FROM events e
|
|
715
|
+
LEFT JOIN agents a ON a.session_id = e.session_id
|
|
716
|
+
LEFT JOIN responses r ON r.event_id = e.id`;
|
|
717
|
+
function parseJson(s) {
|
|
718
|
+
if (s == null) return null;
|
|
719
|
+
try {
|
|
720
|
+
return JSON.parse(s);
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function toEvent(r) {
|
|
726
|
+
const response = r.r_id ? {
|
|
727
|
+
id: r.r_id,
|
|
728
|
+
eventId: r.id,
|
|
729
|
+
sessionId: r.session_id,
|
|
730
|
+
action: r.r_action,
|
|
731
|
+
response: r.r_response,
|
|
732
|
+
answers: parseJson(r.r_answers),
|
|
733
|
+
source: r.r_source ?? "dashboard",
|
|
734
|
+
createdAt: r.r_created_at
|
|
735
|
+
} : null;
|
|
736
|
+
return {
|
|
737
|
+
id: r.id,
|
|
738
|
+
sessionId: r.session_id,
|
|
739
|
+
agentId: r.agent_id,
|
|
740
|
+
type: r.type,
|
|
741
|
+
status: r.status,
|
|
742
|
+
priority: r.priority,
|
|
743
|
+
message: r.message,
|
|
744
|
+
payload: parseJson(r.payload),
|
|
745
|
+
createdAt: r.created_at,
|
|
746
|
+
resolvedAt: r.resolved_at,
|
|
747
|
+
response
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
var isRequestType = (t) => REQUEST_EVENT_TYPES.includes(t);
|
|
751
|
+
var ATTENTION_INFO_TYPES = /* @__PURE__ */ new Set(["notification.created", "task.failed"]);
|
|
752
|
+
var STATUS_ON_CREATE = {
|
|
753
|
+
"session.started": "WAITING",
|
|
754
|
+
"session.stopped": "OFFLINE",
|
|
755
|
+
"permission.created": "PERMISSION",
|
|
756
|
+
"question.created": "QUESTION",
|
|
757
|
+
"prompt.created": "WAITING",
|
|
758
|
+
"task.started": "WORKING",
|
|
759
|
+
"task.completed": "COMPLETED",
|
|
760
|
+
"task.failed": "ERROR"
|
|
761
|
+
};
|
|
762
|
+
function resolutionType(ev, action) {
|
|
763
|
+
if (ev.type === "permission.created") return action === "allow" ? "permission.approved" : "permission.denied";
|
|
764
|
+
if (ev.type === "question.created") return "question.answered";
|
|
765
|
+
if (ev.type === "prompt.created") return action === "prompt" ? "prompt.sent" : null;
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
function cancelType(ev) {
|
|
769
|
+
if (ev.type === "question.created") return "question.cancelled";
|
|
770
|
+
if (ev.type === "prompt.created") return "prompt.cancelled";
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
var EventService = class {
|
|
774
|
+
constructor(db, bus, agents, policies, log) {
|
|
775
|
+
this.db = db;
|
|
776
|
+
this.bus = bus;
|
|
777
|
+
this.agents = agents;
|
|
778
|
+
this.policies = policies;
|
|
779
|
+
this.log = log;
|
|
780
|
+
}
|
|
781
|
+
db;
|
|
782
|
+
bus;
|
|
783
|
+
agents;
|
|
784
|
+
policies;
|
|
785
|
+
log;
|
|
786
|
+
delivery = null;
|
|
787
|
+
setDelivery(d) {
|
|
788
|
+
this.delivery = d;
|
|
789
|
+
}
|
|
790
|
+
get(id) {
|
|
791
|
+
const row = this.db.prepare(`${SELECT} WHERE e.id = ?`).get(id);
|
|
792
|
+
if (!row) throw notFound("EVENT_NOT_FOUND", "Event was not found.");
|
|
793
|
+
return toEvent(row);
|
|
794
|
+
}
|
|
795
|
+
list(q = {}) {
|
|
796
|
+
const where = [];
|
|
797
|
+
const params = [];
|
|
798
|
+
if (q.status) {
|
|
799
|
+
where.push("e.status = ?");
|
|
800
|
+
params.push(q.status);
|
|
801
|
+
}
|
|
802
|
+
if (q.type) {
|
|
803
|
+
if (q.type.includes(".")) {
|
|
804
|
+
where.push("e.type = ?");
|
|
805
|
+
params.push(q.type);
|
|
806
|
+
} else {
|
|
807
|
+
where.push("e.type LIKE ?");
|
|
808
|
+
params.push(`${q.type}.%`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
if (q.agent) {
|
|
812
|
+
where.push("a.id = ?");
|
|
813
|
+
params.push(q.agent);
|
|
814
|
+
}
|
|
815
|
+
if (q.session) {
|
|
816
|
+
where.push("e.session_id = ?");
|
|
817
|
+
params.push(q.session);
|
|
818
|
+
}
|
|
819
|
+
if (q.project) {
|
|
820
|
+
where.push("a.project_name = ?");
|
|
821
|
+
params.push(q.project);
|
|
822
|
+
}
|
|
823
|
+
if (q.q) {
|
|
824
|
+
const like = `%${q.q.replace(/[%_\\]/g, (c2) => "\\" + c2)}%`;
|
|
825
|
+
where.push(
|
|
826
|
+
`(e.message LIKE ? ESCAPE '\\' OR e.payload LIKE ? ESCAPE '\\' OR e.type LIKE ? ESCAPE '\\' OR a.name LIKE ? ESCAPE '\\' OR a.project_name LIKE ? ESCAPE '\\')`
|
|
827
|
+
);
|
|
828
|
+
params.push(like, like, like, like, like);
|
|
829
|
+
}
|
|
830
|
+
if (q.before) {
|
|
831
|
+
where.push("e.id < ?");
|
|
832
|
+
params.push(q.before);
|
|
833
|
+
}
|
|
834
|
+
const sql = `${SELECT} ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY e.id DESC LIMIT ?`;
|
|
835
|
+
params.push(q.limit ?? 200);
|
|
836
|
+
return this.db.prepare(sql).all(...params).map(toEvent);
|
|
837
|
+
}
|
|
838
|
+
/** Every event still waiting on a human, oldest first. */
|
|
839
|
+
pending() {
|
|
840
|
+
return this.db.prepare(`${SELECT} WHERE e.status IN ('PENDING','PROCESSING') ORDER BY e.id ASC`).all().map(toEvent);
|
|
841
|
+
}
|
|
842
|
+
insert(sessionId, type, status, priority, message, payload) {
|
|
843
|
+
const id = `evt_${ulid2()}`;
|
|
844
|
+
const ts = now();
|
|
845
|
+
const terminal = status !== "PENDING" && status !== "PROCESSING";
|
|
846
|
+
this.db.prepare(
|
|
847
|
+
`INSERT INTO events (id, session_id, type, status, priority, message, payload, created_at, resolved_at)
|
|
848
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
849
|
+
).run(id, sessionId, type, status, priority, message, payload ? JSON.stringify(payload) : null, ts, terminal ? ts : null);
|
|
850
|
+
return this.get(id);
|
|
851
|
+
}
|
|
852
|
+
/** Log a purely informational activity entry. */
|
|
853
|
+
logActivity(sessionId, type, message, payload = null) {
|
|
854
|
+
const ev = this.insert(sessionId, type, "RESOLVED", "LOW", message, payload);
|
|
855
|
+
this.bus.publish({ type: "event.created", event: ev });
|
|
856
|
+
return ev;
|
|
857
|
+
}
|
|
858
|
+
defaultPriority(input) {
|
|
859
|
+
if (input.priority) return input.priority;
|
|
860
|
+
if (input.type === "permission.created") return input.payload?.destructive ? "CRITICAL" : "HIGH";
|
|
861
|
+
if (input.type === "question.created" || input.type === "task.failed") return "HIGH";
|
|
862
|
+
if (input.type === "prompt.created" || input.type === "notification.created") return "NORMAL";
|
|
863
|
+
return "LOW";
|
|
864
|
+
}
|
|
865
|
+
create(input) {
|
|
866
|
+
let agent = this.agents.findBySession(input.sessionId);
|
|
867
|
+
if (!agent || agent.status === "OFFLINE" || input.type === "session.started") {
|
|
868
|
+
agent = this.agents.register({ sessionId: input.sessionId, ...input.agent }).agent;
|
|
869
|
+
}
|
|
870
|
+
const payload = input.payload ?? null;
|
|
871
|
+
let status = isRequestType(input.type) || ATTENTION_INFO_TYPES.has(input.type) ? "PENDING" : "RESOLVED";
|
|
872
|
+
if (input.type === "notification.created" && payload?.silent) status = "RESOLVED";
|
|
873
|
+
const priority = this.defaultPriority(input);
|
|
874
|
+
if (input.type === "task.started") {
|
|
875
|
+
this.cancelSession(input.sessionId, ["prompt.created"], "User continued in the terminal.");
|
|
876
|
+
this.dismissInfo(input.sessionId);
|
|
877
|
+
}
|
|
878
|
+
if (input.type === "session.stopped") {
|
|
879
|
+
this.cancelSession(input.sessionId, null, "Session ended.");
|
|
880
|
+
}
|
|
881
|
+
if (input.type === "prompt.created") {
|
|
882
|
+
this.cancelSession(input.sessionId, ["prompt.created"], "Superseded by a newer idle prompt.");
|
|
883
|
+
}
|
|
884
|
+
const ev = this.insert(input.sessionId, input.type, status, priority, input.message ?? null, payload);
|
|
885
|
+
this.log.info(`Event ${input.type}`, { sessionId: ev.sessionId, eventId: ev.id });
|
|
886
|
+
this.bus.publish({ type: "event.created", event: ev });
|
|
887
|
+
const nextStatus = STATUS_ON_CREATE[input.type];
|
|
888
|
+
const activity = input.type === "task.started" ? (input.message ?? null)?.slice(0, 200) ?? null : input.type === "session.stopped" ? null : void 0;
|
|
889
|
+
if (input.type === "notification.created" && payload?.notificationType === "idle_prompt") {
|
|
890
|
+
this.agents.touch(input.sessionId, "WAITING");
|
|
891
|
+
} else if (nextStatus) {
|
|
892
|
+
this.agents.touch(input.sessionId, nextStatus, activity);
|
|
893
|
+
} else {
|
|
894
|
+
this.agents.touch(input.sessionId);
|
|
895
|
+
}
|
|
896
|
+
if (input.type === "permission.created") return this.applyPolicy(ev);
|
|
897
|
+
return this.get(ev.id);
|
|
898
|
+
}
|
|
899
|
+
applyPolicy(ev) {
|
|
900
|
+
const p = ev.payload ?? {};
|
|
901
|
+
const decision = this.policies.evaluate(
|
|
902
|
+
String(p.toolName ?? ""),
|
|
903
|
+
String(p.summary ?? ""),
|
|
904
|
+
Boolean(p.destructive)
|
|
905
|
+
);
|
|
906
|
+
if (!decision || decision.action === "ask") return ev;
|
|
907
|
+
this.log.info(`Policy ${decision.action}`, { eventId: ev.id, match: decision.rule.match });
|
|
908
|
+
return this.resolve(ev, {
|
|
909
|
+
action: decision.action,
|
|
910
|
+
response: decision.rule.reason ?? `Matched policy "${decision.rule.match}"`,
|
|
911
|
+
source: "policy"
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
/** Validate and record a human decision, then hand it to the transport. */
|
|
915
|
+
async respond(id, input) {
|
|
916
|
+
const ev = this.get(id);
|
|
917
|
+
if (input.sessionId && input.sessionId !== ev.sessionId) {
|
|
918
|
+
throw forbidden("SESSION_MISMATCH", "Event does not belong to the given session.");
|
|
919
|
+
}
|
|
920
|
+
if (ev.status !== "PENDING") {
|
|
921
|
+
throw conflict("EVENT_NOT_PENDING", `Event is already ${ev.status}.`);
|
|
922
|
+
}
|
|
923
|
+
const text = input.response ?? input.answer;
|
|
924
|
+
const allowed = {
|
|
925
|
+
"permission.created": ["allow", "deny"],
|
|
926
|
+
"question.created": ["answer", "dismiss"],
|
|
927
|
+
"prompt.created": ["prompt", "dismiss"],
|
|
928
|
+
"notification.created": ["dismiss"],
|
|
929
|
+
"task.failed": ["dismiss"]
|
|
930
|
+
};
|
|
931
|
+
const actions = allowed[ev.type];
|
|
932
|
+
if (!actions) throw unprocessable("EVENT_NOT_ACTIONABLE", `Events of type ${ev.type} cannot be responded to.`);
|
|
933
|
+
if (!actions.includes(input.action)) {
|
|
934
|
+
throw unprocessable("INVALID_ACTION", `Action "${input.action}" is not valid for ${ev.type}. Use: ${actions.join(", ")}.`);
|
|
935
|
+
}
|
|
936
|
+
if (ev.type === "permission.created" && input.action === "allow" && ev.payload?.destructive && !input.confirm) {
|
|
937
|
+
throw unprocessable("CONFIRMATION_REQUIRED", "This operation may be destructive; resend with confirm=true.");
|
|
938
|
+
}
|
|
939
|
+
if (input.action === "answer" && !text && !(input.answers && Object.keys(input.answers).length)) {
|
|
940
|
+
throw unprocessable("ANSWER_REQUIRED", "Provide an answer.");
|
|
941
|
+
}
|
|
942
|
+
if (input.action === "prompt" && !text?.trim()) {
|
|
943
|
+
throw unprocessable("PROMPT_REQUIRED", "Provide the instruction to send.");
|
|
944
|
+
}
|
|
945
|
+
const resolved = this.resolve(ev, {
|
|
946
|
+
action: input.action,
|
|
947
|
+
response: text ?? null,
|
|
948
|
+
answers: input.answers ?? null,
|
|
949
|
+
source: input.source ?? "dashboard"
|
|
950
|
+
});
|
|
951
|
+
return this.deliver(resolved);
|
|
952
|
+
}
|
|
953
|
+
/** Record the response row and move the event to PROCESSING (awaiting delivery). */
|
|
954
|
+
resolve(ev, r) {
|
|
955
|
+
const responseId = `resp_${ulid2()}`;
|
|
956
|
+
const ts = now();
|
|
957
|
+
const infoOnly = ATTENTION_INFO_TYPES.has(ev.type);
|
|
958
|
+
const status = infoOnly ? "RESOLVED" : "PROCESSING";
|
|
959
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
960
|
+
try {
|
|
961
|
+
const res = this.db.prepare(`UPDATE events SET status = ?, resolved_at = ? WHERE id = ? AND status = 'PENDING'`).run(status, infoOnly ? ts : null, ev.id);
|
|
962
|
+
if (res.changes === 0) throw conflict("EVENT_NOT_PENDING", "Event is no longer pending.");
|
|
963
|
+
this.db.prepare(
|
|
964
|
+
`INSERT INTO responses (id, event_id, session_id, action, response, answers, source, created_at)
|
|
965
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
966
|
+
).run(responseId, ev.id, ev.sessionId, r.action, r.response ?? null, r.answers ? JSON.stringify(r.answers) : null, r.source, ts);
|
|
967
|
+
this.db.exec("COMMIT");
|
|
968
|
+
} catch (err) {
|
|
969
|
+
this.db.exec("ROLLBACK");
|
|
970
|
+
throw err;
|
|
971
|
+
}
|
|
972
|
+
this.log.info("Response received", { sessionId: ev.sessionId, eventId: ev.id, action: r.action, source: r.source });
|
|
973
|
+
const updated = this.get(ev.id);
|
|
974
|
+
this.bus.publish({ type: infoOnly ? "event.resolved" : "event.updated", event: updated });
|
|
975
|
+
const logType = resolutionType(ev, r.action);
|
|
976
|
+
if (logType) {
|
|
977
|
+
const summary = ev.type === "permission.created" ? String(ev.payload?.summary ?? ev.message ?? "") : r.action === "prompt" ? r.response ?? "" : ev.message ?? "";
|
|
978
|
+
this.logActivity(ev.sessionId, logType, summary.slice(0, 500), { requestId: ev.id, source: r.source });
|
|
979
|
+
}
|
|
980
|
+
if (!infoOnly) this.recomputeAgentStatus(ev.sessionId, "WORKING");
|
|
981
|
+
else this.agents.refresh(ev.sessionId);
|
|
982
|
+
return updated;
|
|
983
|
+
}
|
|
984
|
+
/** Try to push a PROCESSING event's response to the live session endpoint. */
|
|
985
|
+
async deliver(ev) {
|
|
986
|
+
if (ev.status !== "PROCESSING" || !ev.response || !this.delivery) return ev;
|
|
987
|
+
const delivered = await this.delivery.deliver(ev, ev.response);
|
|
988
|
+
return delivered ? this.markDelivered(ev.id) : ev;
|
|
989
|
+
}
|
|
990
|
+
/** The session endpoint received the response. */
|
|
991
|
+
markDelivered(id) {
|
|
992
|
+
const res = this.db.prepare(`UPDATE events SET status = 'RESOLVED', resolved_at = ? WHERE id = ? AND status = 'PROCESSING'`).run(now(), id);
|
|
993
|
+
const ev = this.get(id);
|
|
994
|
+
if (res.changes > 0) {
|
|
995
|
+
this.log.info("Event resolved", { sessionId: ev.sessionId, eventId: id });
|
|
996
|
+
this.bus.publish({ type: "event.resolved", event: ev });
|
|
997
|
+
this.agents.refresh(ev.sessionId);
|
|
998
|
+
}
|
|
999
|
+
return ev;
|
|
1000
|
+
}
|
|
1001
|
+
cancel(id, reason = "Cancelled.", sessionId) {
|
|
1002
|
+
const ev = this.get(id);
|
|
1003
|
+
if (sessionId && sessionId !== ev.sessionId) {
|
|
1004
|
+
throw forbidden("SESSION_MISMATCH", "Event does not belong to the given session.");
|
|
1005
|
+
}
|
|
1006
|
+
if (ev.status !== "PENDING" && ev.status !== "PROCESSING") {
|
|
1007
|
+
throw conflict("EVENT_NOT_PENDING", `Event is already ${ev.status}.`);
|
|
1008
|
+
}
|
|
1009
|
+
return this.finish(ev, "CANCELLED", reason);
|
|
1010
|
+
}
|
|
1011
|
+
/** Close an event without a delivered decision (cancelled, expired, delivery failed). */
|
|
1012
|
+
finish(ev, status, reason) {
|
|
1013
|
+
const payload = { ...ev.payload ?? {}, closeReason: reason };
|
|
1014
|
+
const res = this.db.prepare(
|
|
1015
|
+
`UPDATE events SET status = ?, resolved_at = ?, payload = ? WHERE id = ? AND status IN ('PENDING','PROCESSING')`
|
|
1016
|
+
).run(status, now(), JSON.stringify(payload), ev.id);
|
|
1017
|
+
const updated = this.get(ev.id);
|
|
1018
|
+
if (res.changes === 0) return updated;
|
|
1019
|
+
this.delivery?.withdraw(updated, reason);
|
|
1020
|
+
this.log.info(`Event ${status.toLowerCase()}`, { sessionId: ev.sessionId, eventId: ev.id, reason });
|
|
1021
|
+
this.bus.publish({ type: "event.resolved", event: updated });
|
|
1022
|
+
const log = status === "CANCELLED" ? cancelType(ev) : null;
|
|
1023
|
+
if (log) this.logActivity(ev.sessionId, log, reason, { requestId: ev.id });
|
|
1024
|
+
this.recomputeAgentStatus(ev.sessionId);
|
|
1025
|
+
return updated;
|
|
1026
|
+
}
|
|
1027
|
+
/** The hook endpoint for this event vanished: Claude Code resolved it some other way. */
|
|
1028
|
+
abandoned(id) {
|
|
1029
|
+
let ev;
|
|
1030
|
+
try {
|
|
1031
|
+
ev = this.get(id);
|
|
1032
|
+
} catch {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (ev.status === "PENDING") {
|
|
1036
|
+
const reason = ev.type === "prompt.created" ? "Session resumed from the terminal." : "Handled in the terminal (or the hook timed out).";
|
|
1037
|
+
this.finish(ev, "CANCELLED", reason);
|
|
1038
|
+
} else if (ev.status === "PROCESSING") {
|
|
1039
|
+
this.finish(ev, "FAILED", "The session stopped waiting before the decision was delivered.");
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
cancelSession(sessionId, types, reason) {
|
|
1043
|
+
const rows = this.db.prepare(`SELECT id, type FROM events WHERE session_id = ? AND status IN ('PENDING','PROCESSING')`).all(sessionId);
|
|
1044
|
+
for (const r of rows) {
|
|
1045
|
+
if (types && !types.includes(r.type)) continue;
|
|
1046
|
+
if (!types && ATTENTION_INFO_TYPES.has(r.type)) continue;
|
|
1047
|
+
this.finish(this.get(r.id), "CANCELLED", reason);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/** Auto-dismiss stale notifications once a session is busy again. */
|
|
1051
|
+
dismissInfo(sessionId) {
|
|
1052
|
+
const rows = this.db.prepare(`SELECT id FROM events WHERE session_id = ? AND status = 'PENDING' AND type = 'notification.created'`).all(sessionId);
|
|
1053
|
+
for (const r of rows) {
|
|
1054
|
+
const ev = this.get(r.id);
|
|
1055
|
+
this.resolve(ev, { action: "dismiss", response: "Session resumed.", source: "system" });
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
cancelAllForSession(sessionId, reason) {
|
|
1059
|
+
this.cancelSession(sessionId, null, reason);
|
|
1060
|
+
}
|
|
1061
|
+
/** Derive the agent's status from what it is still waiting on. */
|
|
1062
|
+
recomputeAgentStatus(sessionId, fallback) {
|
|
1063
|
+
const agent = this.agents.findBySession(sessionId);
|
|
1064
|
+
if (!agent) return;
|
|
1065
|
+
const rows = this.db.prepare(`SELECT type FROM events WHERE session_id = ? AND status = 'PENDING'`).all(sessionId);
|
|
1066
|
+
const types = new Set(rows.map((r) => r.type));
|
|
1067
|
+
let status;
|
|
1068
|
+
if (types.has("permission.created")) status = "PERMISSION";
|
|
1069
|
+
else if (types.has("question.created")) status = "QUESTION";
|
|
1070
|
+
else if (types.has("prompt.created")) status = "WAITING";
|
|
1071
|
+
else if (agent.status === "PERMISSION" || agent.status === "QUESTION") status = fallback ?? "WORKING";
|
|
1072
|
+
else if (fallback && agent.status !== "OFFLINE") status = fallback;
|
|
1073
|
+
this.agents.touch(sessionId, status);
|
|
1074
|
+
}
|
|
1075
|
+
/** Expire pending permissions older than maxAgeMs (optional feature). */
|
|
1076
|
+
expirePermissions(maxAgeMs) {
|
|
1077
|
+
if (maxAgeMs <= 0) return;
|
|
1078
|
+
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
|
|
1079
|
+
const rows = this.db.prepare(`SELECT id FROM events WHERE type = 'permission.created' AND status = 'PENDING' AND created_at < ?`).all(cutoff);
|
|
1080
|
+
for (const r of rows) this.finish(this.get(r.id), "CANCELLED", "Permission request expired.");
|
|
1081
|
+
}
|
|
1082
|
+
deleteEvent(id) {
|
|
1083
|
+
const ev = this.get(id);
|
|
1084
|
+
if (ev.status === "PENDING" || ev.status === "PROCESSING") {
|
|
1085
|
+
throw new HubError(409, "EVENT_PENDING", "Cancel the event before deleting it.");
|
|
1086
|
+
}
|
|
1087
|
+
this.db.prepare(`DELETE FROM responses WHERE event_id = ?`).run(id);
|
|
1088
|
+
this.db.prepare(`DELETE FROM events WHERE id = ?`).run(id);
|
|
1089
|
+
this.bus.publish({ type: "event.deleted", eventId: id });
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// src/services/policy.service.ts
|
|
1094
|
+
import fs4 from "node:fs";
|
|
1095
|
+
import YAML from "yaml";
|
|
1096
|
+
var PolicyService = class {
|
|
1097
|
+
constructor(file, log) {
|
|
1098
|
+
this.file = file;
|
|
1099
|
+
this.log = log;
|
|
1100
|
+
}
|
|
1101
|
+
file;
|
|
1102
|
+
log;
|
|
1103
|
+
rules = [];
|
|
1104
|
+
mtimeMs = -1;
|
|
1105
|
+
error = null;
|
|
1106
|
+
reloadIfChanged() {
|
|
1107
|
+
let mtime = -2;
|
|
1108
|
+
try {
|
|
1109
|
+
mtime = fs4.statSync(this.file).mtimeMs;
|
|
1110
|
+
} catch {
|
|
1111
|
+
}
|
|
1112
|
+
if (mtime === this.mtimeMs) return;
|
|
1113
|
+
this.mtimeMs = mtime;
|
|
1114
|
+
this.rules = [];
|
|
1115
|
+
this.error = null;
|
|
1116
|
+
if (mtime < 0) return;
|
|
1117
|
+
try {
|
|
1118
|
+
const raw = YAML.parse(fs4.readFileSync(this.file, "utf8")) ?? {};
|
|
1119
|
+
const parsed = PolicyFileSchema.safeParse(raw);
|
|
1120
|
+
if (!parsed.success) {
|
|
1121
|
+
this.error = parsed.error.message;
|
|
1122
|
+
this.log.warn("Ignoring invalid policy file", { file: this.file, error: this.error });
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
this.rules = parsed.data.permissions;
|
|
1126
|
+
this.log.info("Loaded permission policies", { count: this.rules.length });
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
this.error = err.message;
|
|
1129
|
+
this.log.warn("Cannot read policy file", { file: this.file, error: this.error });
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
list() {
|
|
1133
|
+
this.reloadIfChanged();
|
|
1134
|
+
return { file: this.file, rules: this.rules, error: this.error };
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* @param destructive when true an `allow` rule is downgraded to `ask`: destructive operations always
|
|
1138
|
+
* reach a human.
|
|
1139
|
+
*/
|
|
1140
|
+
evaluate(toolName, summary, destructive = false) {
|
|
1141
|
+
this.reloadIfChanged();
|
|
1142
|
+
for (const rule of this.rules) {
|
|
1143
|
+
if (rule.tool && !globMatch(rule.tool, toolName)) continue;
|
|
1144
|
+
if (!globMatch(rule.match, summary)) continue;
|
|
1145
|
+
if (rule.action === "allow" && (destructive || chainsCommands(toolName, summary))) {
|
|
1146
|
+
return { action: "ask", rule };
|
|
1147
|
+
}
|
|
1148
|
+
return { action: rule.action, rule };
|
|
1149
|
+
}
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
function chainsCommands(toolName, summary) {
|
|
1154
|
+
if (toolName !== "Bash" && toolName !== "PowerShell") return false;
|
|
1155
|
+
return /&&|\|\||[;|`\n]|\$\(|>|</.test(summary);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/transport/hook.transport.ts
|
|
1159
|
+
var HookTransport = class {
|
|
1160
|
+
/**
|
|
1161
|
+
* @param onAbandoned called when every waiter for an event has gone away and none came back within the
|
|
1162
|
+
* grace period, i.e. the hook exited: Claude Code resolved the request some other way.
|
|
1163
|
+
*/
|
|
1164
|
+
constructor(graceMs, onAbandoned) {
|
|
1165
|
+
this.graceMs = graceMs;
|
|
1166
|
+
this.onAbandoned = onAbandoned;
|
|
1167
|
+
}
|
|
1168
|
+
graceMs;
|
|
1169
|
+
onAbandoned;
|
|
1170
|
+
name = "hook";
|
|
1171
|
+
waiters = /* @__PURE__ */ new Map();
|
|
1172
|
+
idleTimers = /* @__PURE__ */ new Map();
|
|
1173
|
+
/** Register a waiter; returns a disposer that removes it (on timeout or client disconnect). */
|
|
1174
|
+
addWaiter(eventId, waiter) {
|
|
1175
|
+
this.clearIdleTimer(eventId);
|
|
1176
|
+
let set = this.waiters.get(eventId);
|
|
1177
|
+
if (!set) this.waiters.set(eventId, set = /* @__PURE__ */ new Set());
|
|
1178
|
+
set.add(waiter);
|
|
1179
|
+
return () => {
|
|
1180
|
+
const current = this.waiters.get(eventId);
|
|
1181
|
+
if (!current?.delete(waiter)) return;
|
|
1182
|
+
if (current.size === 0) {
|
|
1183
|
+
this.waiters.delete(eventId);
|
|
1184
|
+
this.armIdleTimer(eventId);
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
/** Start the abandonment clock for an event that has no waiters (used after restart too). */
|
|
1189
|
+
armIdleTimer(eventId, graceMs = this.graceMs) {
|
|
1190
|
+
this.clearIdleTimer(eventId);
|
|
1191
|
+
const t = setTimeout(() => {
|
|
1192
|
+
this.idleTimers.delete(eventId);
|
|
1193
|
+
if (!this.waiters.has(eventId)) this.onAbandoned(eventId);
|
|
1194
|
+
}, graceMs);
|
|
1195
|
+
t.unref?.();
|
|
1196
|
+
this.idleTimers.set(eventId, t);
|
|
1197
|
+
}
|
|
1198
|
+
clearIdleTimer(eventId) {
|
|
1199
|
+
const t = this.idleTimers.get(eventId);
|
|
1200
|
+
if (t) clearTimeout(t);
|
|
1201
|
+
this.idleTimers.delete(eventId);
|
|
1202
|
+
}
|
|
1203
|
+
settle(eventId, result) {
|
|
1204
|
+
const set = this.waiters.get(eventId);
|
|
1205
|
+
this.waiters.delete(eventId);
|
|
1206
|
+
this.clearIdleTimer(eventId);
|
|
1207
|
+
if (!set || set.size === 0) return false;
|
|
1208
|
+
for (const w of set) w.settle(result);
|
|
1209
|
+
return true;
|
|
1210
|
+
}
|
|
1211
|
+
async sendResponse(sessionId, response) {
|
|
1212
|
+
const set = this.waiters.get(response.eventId);
|
|
1213
|
+
if (!set) return false;
|
|
1214
|
+
for (const w of set) if (w.sessionId !== sessionId) set.delete(w);
|
|
1215
|
+
return this.settle(response.eventId, { state: "resolved", response });
|
|
1216
|
+
}
|
|
1217
|
+
cancel(_sessionId, eventId, reason) {
|
|
1218
|
+
this.settle(eventId, { state: "cancelled", reason });
|
|
1219
|
+
}
|
|
1220
|
+
isWaiting(eventId) {
|
|
1221
|
+
return (this.waiters.get(eventId)?.size ?? 0) > 0;
|
|
1222
|
+
}
|
|
1223
|
+
sessionHasWaiters(sessionId) {
|
|
1224
|
+
for (const set of this.waiters.values()) for (const w of set) if (w.sessionId === sessionId) return true;
|
|
1225
|
+
return false;
|
|
1226
|
+
}
|
|
1227
|
+
close() {
|
|
1228
|
+
for (const t of this.idleTimers.values()) clearTimeout(t);
|
|
1229
|
+
this.idleTimers.clear();
|
|
1230
|
+
for (const [eventId] of this.waiters) this.settle(eventId, { state: "pending" });
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// src/services/routing.service.ts
|
|
1235
|
+
var RoutingService = class {
|
|
1236
|
+
constructor(events, log, graceMs) {
|
|
1237
|
+
this.events = events;
|
|
1238
|
+
this.log = log;
|
|
1239
|
+
this.hook = new HookTransport(graceMs, (eventId) => this.events.abandoned(eventId));
|
|
1240
|
+
this.transports = [this.hook];
|
|
1241
|
+
events.setDelivery({
|
|
1242
|
+
deliver: async (ev, response) => {
|
|
1243
|
+
for (const t of this.transports) {
|
|
1244
|
+
if (await t.sendResponse(ev.sessionId, response)) {
|
|
1245
|
+
this.log.debug("Delivered", { sessionId: ev.sessionId, eventId: ev.id, transport: t.name });
|
|
1246
|
+
return true;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return false;
|
|
1250
|
+
},
|
|
1251
|
+
withdraw: (ev, reason) => {
|
|
1252
|
+
for (const t of this.transports) t.cancel(ev.sessionId, ev.id, reason);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
events;
|
|
1257
|
+
log;
|
|
1258
|
+
hook;
|
|
1259
|
+
transports;
|
|
1260
|
+
/**
|
|
1261
|
+
* Long-poll from a hook. Resolves as soon as the event is decided or withdrawn, or with
|
|
1262
|
+
* `pending` after timeoutMs. `onClose` must be wired to the HTTP request's close event so a
|
|
1263
|
+
* disconnected hook stops counting as a waiter.
|
|
1264
|
+
*/
|
|
1265
|
+
wait(eventId, sessionId, timeoutMs, onClose) {
|
|
1266
|
+
const ev = this.events.get(eventId);
|
|
1267
|
+
if (ev.sessionId !== sessionId) throw forbidden("SESSION_MISMATCH", "Event does not belong to the given session.");
|
|
1268
|
+
const immediate = this.settled(ev);
|
|
1269
|
+
if (immediate) return Promise.resolve(immediate);
|
|
1270
|
+
return new Promise((resolve) => {
|
|
1271
|
+
let done = false;
|
|
1272
|
+
let dispose = () => {
|
|
1273
|
+
};
|
|
1274
|
+
const finish = (result) => {
|
|
1275
|
+
if (done) return;
|
|
1276
|
+
done = true;
|
|
1277
|
+
clearTimeout(timer);
|
|
1278
|
+
dispose();
|
|
1279
|
+
if (result.state === "resolved") this.events.markDelivered(eventId);
|
|
1280
|
+
resolve(result);
|
|
1281
|
+
};
|
|
1282
|
+
const timer = setTimeout(() => finish({ state: "pending" }), timeoutMs);
|
|
1283
|
+
dispose = this.hook.addWaiter(eventId, { sessionId, settle: finish });
|
|
1284
|
+
onClose(() => {
|
|
1285
|
+
if (done) return;
|
|
1286
|
+
done = true;
|
|
1287
|
+
clearTimeout(timer);
|
|
1288
|
+
dispose();
|
|
1289
|
+
});
|
|
1290
|
+
const late = this.settled(this.events.get(eventId));
|
|
1291
|
+
if (late) finish(late);
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
settled(ev) {
|
|
1295
|
+
if ((ev.status === "PROCESSING" || ev.status === "RESOLVED") && ev.response) {
|
|
1296
|
+
if (ev.status === "PROCESSING") this.events.markDelivered(ev.id);
|
|
1297
|
+
return { state: "resolved", response: ev.response };
|
|
1298
|
+
}
|
|
1299
|
+
if (ev.status === "CANCELLED" || ev.status === "FAILED") {
|
|
1300
|
+
return { state: "cancelled", reason: String(ev.payload?.closeReason ?? ev.status) };
|
|
1301
|
+
}
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
/** After a restart, hook-backed pending requests get a (longer) window to re-attach before being abandoned. */
|
|
1305
|
+
armRecovered(pending, graceMs) {
|
|
1306
|
+
for (const ev of pending) {
|
|
1307
|
+
if (ev.payload?.transport === "hook") this.hook.armIdleTimer(ev.id, graceMs);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
sessionHasWaiters(sessionId) {
|
|
1311
|
+
return this.transports.some((t) => t.sessionHasWaiters(sessionId));
|
|
1312
|
+
}
|
|
1313
|
+
close() {
|
|
1314
|
+
this.hook.close();
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
// src/util/process.ts
|
|
1319
|
+
function isPidAlive(pid) {
|
|
1320
|
+
try {
|
|
1321
|
+
process.kill(pid, 0);
|
|
1322
|
+
return true;
|
|
1323
|
+
} catch (err) {
|
|
1324
|
+
return err.code === "EPERM";
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// src/hub.ts
|
|
1329
|
+
var Hub = class {
|
|
1330
|
+
constructor(config, log) {
|
|
1331
|
+
this.config = config;
|
|
1332
|
+
this.log = log;
|
|
1333
|
+
this.db = openDatabase(config.database);
|
|
1334
|
+
this.agents = new AgentService(this.db, this.bus, log);
|
|
1335
|
+
this.policies = new PolicyService(config.policyFile, log);
|
|
1336
|
+
this.events = new EventService(this.db, this.bus, this.agents, this.policies, log);
|
|
1337
|
+
this.routing = new RoutingService(this.events, log, config.waiterGraceMs);
|
|
1338
|
+
}
|
|
1339
|
+
config;
|
|
1340
|
+
log;
|
|
1341
|
+
db;
|
|
1342
|
+
bus = new EventBus();
|
|
1343
|
+
agents;
|
|
1344
|
+
events;
|
|
1345
|
+
policies;
|
|
1346
|
+
routing;
|
|
1347
|
+
startedAt = Date.now();
|
|
1348
|
+
sweeper = null;
|
|
1349
|
+
start(sweepIntervalMs = 5e3) {
|
|
1350
|
+
this.routing.armRecovered(this.events.pending(), Math.max(this.config.waiterGraceMs, 3e4));
|
|
1351
|
+
this.sweeper = setInterval(() => this.sweep(), sweepIntervalMs);
|
|
1352
|
+
this.sweeper.unref?.();
|
|
1353
|
+
}
|
|
1354
|
+
/** Mark dead sessions OFFLINE and expire stale permissions. */
|
|
1355
|
+
sweep() {
|
|
1356
|
+
const nowMs = Date.now();
|
|
1357
|
+
for (const agent of this.agents.list()) {
|
|
1358
|
+
if (agent.status === "OFFLINE") continue;
|
|
1359
|
+
let offline = false;
|
|
1360
|
+
if (agent.pid) {
|
|
1361
|
+
offline = !isPidAlive(agent.pid);
|
|
1362
|
+
} else if (!this.routing.sessionHasWaiters(agent.sessionId)) {
|
|
1363
|
+
const idleMs = nowMs - Date.parse(agent.lastSeenAt);
|
|
1364
|
+
const limit = agent.status === "WORKING" ? this.config.heartbeatTimeout * 10 : this.config.heartbeatTimeout;
|
|
1365
|
+
offline = idleMs > limit;
|
|
1366
|
+
}
|
|
1367
|
+
if (offline) {
|
|
1368
|
+
this.log.info("Agent offline", { sessionId: agent.sessionId });
|
|
1369
|
+
this.events.cancelAllForSession(agent.sessionId, "Session went offline.");
|
|
1370
|
+
this.agents.touch(agent.sessionId, "OFFLINE");
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
this.events.expirePermissions(this.config.permissionExpiryMs);
|
|
1374
|
+
}
|
|
1375
|
+
close() {
|
|
1376
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
1377
|
+
this.routing.close();
|
|
1378
|
+
this.db.close();
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
// src/websocket/websocket.ts
|
|
1383
|
+
function registerWebSocket(app, hub) {
|
|
1384
|
+
app.get("/ws", { websocket: true }, (socket) => {
|
|
1385
|
+
const send = (msg) => {
|
|
1386
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
|
1387
|
+
};
|
|
1388
|
+
send({ type: "hello", agents: hub.agents.list(), pending: hub.events.pending() });
|
|
1389
|
+
const unsubscribe = hub.bus.subscribe(send);
|
|
1390
|
+
const ping = setInterval(() => socket.readyState === socket.OPEN && socket.ping(), 2e4);
|
|
1391
|
+
socket.on("close", () => {
|
|
1392
|
+
clearInterval(ping);
|
|
1393
|
+
unsubscribe();
|
|
1394
|
+
});
|
|
1395
|
+
socket.on("error", () => socket.close());
|
|
1396
|
+
socket.on("message", () => {
|
|
1397
|
+
});
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/server.ts
|
|
1402
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
1403
|
+
function hostOf(value) {
|
|
1404
|
+
return value.startsWith("[") ? value.slice(0, value.indexOf("]") + 1) : value.split(":")[0];
|
|
1405
|
+
}
|
|
1406
|
+
async function buildServer(config, log) {
|
|
1407
|
+
const hub = new Hub(config, log);
|
|
1408
|
+
const app = Fastify({ logger: false, bodyLimit: 1024 * 1024, forceCloseConnections: true });
|
|
1409
|
+
const loopbackOnly = LOOPBACK_HOSTS.has(config.host) || config.host === "::1";
|
|
1410
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
1411
|
+
if (!loopbackOnly) return;
|
|
1412
|
+
const host = req.headers.host;
|
|
1413
|
+
if (host && !LOOPBACK_HOSTS.has(hostOf(host))) {
|
|
1414
|
+
return reply.code(403).send({ error: { code: "FORBIDDEN_HOST", message: "Host not allowed." } });
|
|
1415
|
+
}
|
|
1416
|
+
const origin = req.headers.origin;
|
|
1417
|
+
if (origin && origin !== "null") {
|
|
1418
|
+
let ok = false;
|
|
1419
|
+
try {
|
|
1420
|
+
ok = LOOPBACK_HOSTS.has(hostOf(new URL(origin).host));
|
|
1421
|
+
} catch {
|
|
1422
|
+
ok = false;
|
|
1423
|
+
}
|
|
1424
|
+
if (!ok) {
|
|
1425
|
+
return reply.code(403).send({ error: { code: "FORBIDDEN_ORIGIN", message: "Cross-origin requests are not allowed." } });
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
app.setErrorHandler((err, _req, reply) => {
|
|
1430
|
+
if (err instanceof HubError) {
|
|
1431
|
+
return reply.code(err.statusCode).send({ error: { code: err.code, message: err.message, details: err.details } });
|
|
1432
|
+
}
|
|
1433
|
+
const e = err;
|
|
1434
|
+
if (e.statusCode && e.statusCode >= 400 && e.statusCode < 500) {
|
|
1435
|
+
const code = e.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" ? "UNSUPPORTED_MEDIA_TYPE" : e.code?.startsWith("FST_ERR_CTP") || /JSON/i.test(e.message) ? "MALFORMED_JSON" : "BAD_REQUEST";
|
|
1436
|
+
return reply.code(e.statusCode).send({ error: { code, message: e.message } });
|
|
1437
|
+
}
|
|
1438
|
+
log.error("Unhandled error", { error: e.message });
|
|
1439
|
+
return reply.code(500).send({ error: { code: "INTERNAL_ERROR", message: "Internal server error." } });
|
|
1440
|
+
});
|
|
1441
|
+
await app.register(fastifyWebsocket, { options: { maxPayload: 64 * 1024 } });
|
|
1442
|
+
registerHealthRoutes(app, hub);
|
|
1443
|
+
registerAgentRoutes(app, hub);
|
|
1444
|
+
registerEventRoutes(app, hub);
|
|
1445
|
+
registerWebSocket(app, hub);
|
|
1446
|
+
const webDir2 = config.webDir;
|
|
1447
|
+
if (webDir2 && fs5.existsSync(path5.join(webDir2, "index.html"))) {
|
|
1448
|
+
await app.register(fastifyStatic, { root: webDir2, wildcard: false });
|
|
1449
|
+
app.setNotFoundHandler((req, reply) => {
|
|
1450
|
+
if (req.method === "GET" && !req.url.startsWith("/api") && !req.url.startsWith("/ws")) {
|
|
1451
|
+
return reply.type("text/html").sendFile("index.html");
|
|
1452
|
+
}
|
|
1453
|
+
return reply.code(404).send({ error: { code: "NOT_FOUND", message: "Route not found." } });
|
|
1454
|
+
});
|
|
1455
|
+
} else {
|
|
1456
|
+
app.setNotFoundHandler(
|
|
1457
|
+
(_req, reply) => reply.code(404).send({ error: { code: "NOT_FOUND", message: "Route not found." } })
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
app.addHook("onClose", async () => hub.close());
|
|
1461
|
+
return { app, hub };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// src/cli/daemon.ts
|
|
1465
|
+
var pidFile = (cfg) => path6.join(cfg.home, "hub.pid");
|
|
1466
|
+
var tokenFile = (cfg) => path6.join(cfg.home, "hub.token");
|
|
1467
|
+
var logFile = (cfg) => path6.join(cfg.home, "logs", "hub.log");
|
|
1468
|
+
var baseUrl = (cfg) => `http://${cfg.host === "0.0.0.0" ? "127.0.0.1" : cfg.host}:${cfg.port}`;
|
|
1469
|
+
function readPid(cfg) {
|
|
1470
|
+
try {
|
|
1471
|
+
const pid = Number(fs6.readFileSync(pidFile(cfg), "utf8").trim());
|
|
1472
|
+
return pid > 0 && isPidAlive(pid) ? pid : null;
|
|
1473
|
+
} catch {
|
|
1474
|
+
return null;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function health(cfg, timeoutMs = 1500) {
|
|
1478
|
+
try {
|
|
1479
|
+
const res = await fetch(`${baseUrl(cfg)}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
1480
|
+
return res.ok ? await res.json() : null;
|
|
1481
|
+
} catch {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function runForeground(cfg, opts) {
|
|
1486
|
+
fs6.mkdirSync(cfg.home, { recursive: true });
|
|
1487
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
1488
|
+
const log = new Logger(cfg.logLevel, opts.daemon ? logFile(cfg) : null, !opts.daemon);
|
|
1489
|
+
const { app, hub } = await buildServer({ ...cfg, adminToken: token }, log);
|
|
1490
|
+
hub.start();
|
|
1491
|
+
try {
|
|
1492
|
+
await app.listen({ port: cfg.port, host: cfg.host });
|
|
1493
|
+
} catch (err) {
|
|
1494
|
+
log.error("Failed to start", { error: err.message });
|
|
1495
|
+
hub.close();
|
|
1496
|
+
process.exit(1);
|
|
1497
|
+
}
|
|
1498
|
+
fs6.writeFileSync(pidFile(cfg), String(process.pid));
|
|
1499
|
+
fs6.writeFileSync(tokenFile(cfg), token, { mode: 384 });
|
|
1500
|
+
log.info("Server started", { url: baseUrl(cfg), database: cfg.database });
|
|
1501
|
+
let closing = false;
|
|
1502
|
+
const shutdown = async () => {
|
|
1503
|
+
if (closing) return;
|
|
1504
|
+
closing = true;
|
|
1505
|
+
log.info("Shutting down");
|
|
1506
|
+
await app.close();
|
|
1507
|
+
cleanupPid(cfg);
|
|
1508
|
+
log.close();
|
|
1509
|
+
process.exit(0);
|
|
1510
|
+
};
|
|
1511
|
+
process.on("SIGINT", shutdown);
|
|
1512
|
+
process.on("SIGTERM", shutdown);
|
|
1513
|
+
process.on("exit", () => cleanupPid(cfg));
|
|
1514
|
+
return { app, hub };
|
|
1515
|
+
}
|
|
1516
|
+
function cleanupPid(cfg) {
|
|
1517
|
+
try {
|
|
1518
|
+
if (fs6.readFileSync(pidFile(cfg), "utf8").trim() === String(process.pid)) {
|
|
1519
|
+
fs6.rmSync(pidFile(cfg), { force: true });
|
|
1520
|
+
fs6.rmSync(tokenFile(cfg), { force: true });
|
|
1521
|
+
}
|
|
1522
|
+
} catch {
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async function startDaemon(cfg, cliScript2) {
|
|
1526
|
+
const running = readPid(cfg);
|
|
1527
|
+
if (running && await health(cfg)) return { pid: running, already: true };
|
|
1528
|
+
if (await health(cfg)) throw new Error(`port ${cfg.port} is already serving another hub or program`);
|
|
1529
|
+
fs6.mkdirSync(path6.dirname(logFile(cfg)), { recursive: true });
|
|
1530
|
+
const out = fs6.openSync(logFile(cfg), "a");
|
|
1531
|
+
const child = spawn(process.execPath, [cliScript2, "start", "--foreground", "--daemon"], {
|
|
1532
|
+
detached: true,
|
|
1533
|
+
stdio: ["ignore", out, out],
|
|
1534
|
+
windowsHide: true,
|
|
1535
|
+
env: { ...process.env, CREWDESK_HOME: cfg.home, CREWDESK_PORT: String(cfg.port), CREWDESK_HOST: cfg.host }
|
|
1536
|
+
});
|
|
1537
|
+
child.unref();
|
|
1538
|
+
const deadline = Date.now() + 15e3;
|
|
1539
|
+
while (Date.now() < deadline) {
|
|
1540
|
+
if (child.exitCode !== null) break;
|
|
1541
|
+
if (await health(cfg, 500)) return { pid: child.pid, already: false };
|
|
1542
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1543
|
+
}
|
|
1544
|
+
throw new Error(`hub did not start; see ${logFile(cfg)}`);
|
|
1545
|
+
}
|
|
1546
|
+
async function stopDaemon(cfg) {
|
|
1547
|
+
const pid = readPid(cfg);
|
|
1548
|
+
let token = "";
|
|
1549
|
+
try {
|
|
1550
|
+
token = fs6.readFileSync(tokenFile(cfg), "utf8").trim();
|
|
1551
|
+
} catch {
|
|
1552
|
+
}
|
|
1553
|
+
if (token) {
|
|
1554
|
+
try {
|
|
1555
|
+
await fetch(`${baseUrl(cfg)}/api/admin/shutdown`, { method: "POST", headers: { "x-hub-token": token }, signal: AbortSignal.timeout(2e3) });
|
|
1556
|
+
} catch {
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (!pid) return Boolean(token);
|
|
1560
|
+
const deadline = Date.now() + 5e3;
|
|
1561
|
+
while (Date.now() < deadline && isPidAlive(pid)) await new Promise((r) => setTimeout(r, 100));
|
|
1562
|
+
if (isPidAlive(pid)) {
|
|
1563
|
+
try {
|
|
1564
|
+
process.kill(pid);
|
|
1565
|
+
} catch {
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
fs6.rmSync(pidFile(cfg), { force: true });
|
|
1569
|
+
fs6.rmSync(tokenFile(cfg), { force: true });
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// src/cli/hooks-config.ts
|
|
1574
|
+
import fs7 from "node:fs";
|
|
1575
|
+
import os2 from "node:os";
|
|
1576
|
+
import path7 from "node:path";
|
|
1577
|
+
var HOOK_MARKER = "crewdesk-hook";
|
|
1578
|
+
var HOOK_PLAN = [
|
|
1579
|
+
{ event: "SessionStart", sub: "register", timeout: 30, async: true },
|
|
1580
|
+
{ event: "UserPromptSubmit", sub: "prompt", timeout: 10, async: true },
|
|
1581
|
+
{ event: "Notification", sub: "notification", timeout: 10, async: true },
|
|
1582
|
+
{ event: "PermissionRequest", sub: "permission", timeout: 86400 },
|
|
1583
|
+
{ event: "PreToolUse", sub: "question", matcher: "AskUserQuestion", timeout: 86400 },
|
|
1584
|
+
{ event: "Stop", sub: "stop", timeout: 86400 },
|
|
1585
|
+
{ event: "SessionEnd", sub: "session-end", timeout: 5 }
|
|
1586
|
+
];
|
|
1587
|
+
var fwd = (p) => p.replace(/\\/g, "/");
|
|
1588
|
+
function hookCommand(hookScript2, sub, nodePath = process.execPath) {
|
|
1589
|
+
return `"${fwd(nodePath)}" "${fwd(hookScript2)}" ${sub}`;
|
|
1590
|
+
}
|
|
1591
|
+
function isOurs(h) {
|
|
1592
|
+
return typeof h.command === "string" && h.command.includes(HOOK_MARKER);
|
|
1593
|
+
}
|
|
1594
|
+
function detectConfigDirs(home = os2.homedir(), env = process.env) {
|
|
1595
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
1596
|
+
const add = (d) => dirs.add(path7.resolve(d));
|
|
1597
|
+
add(path7.join(home, ".claude"));
|
|
1598
|
+
const accounts = path7.join(home, ".claude-accounts");
|
|
1599
|
+
if (fs7.existsSync(accounts)) {
|
|
1600
|
+
for (const entry of fs7.readdirSync(accounts, { withFileTypes: true })) {
|
|
1601
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
1602
|
+
const d = path7.join(accounts, entry.name);
|
|
1603
|
+
if (fs7.existsSync(path7.join(d, "settings.json")) || fs7.existsSync(path7.join(d, ".claude.json"))) add(d);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (env.CLAUDE_CONFIG_DIR) add(env.CLAUDE_CONFIG_DIR);
|
|
1607
|
+
return [...dirs];
|
|
1608
|
+
}
|
|
1609
|
+
function readSettings(file) {
|
|
1610
|
+
if (!fs7.existsSync(file)) return {};
|
|
1611
|
+
const text = fs7.readFileSync(file, "utf8").replace(/^/, "");
|
|
1612
|
+
if (!text.trim()) return {};
|
|
1613
|
+
const parsed = JSON.parse(text);
|
|
1614
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${file} is not a JSON object`);
|
|
1615
|
+
return parsed;
|
|
1616
|
+
}
|
|
1617
|
+
function describeTarget(dir, home = os2.homedir()) {
|
|
1618
|
+
const settingsFile = path7.join(dir, "settings.json");
|
|
1619
|
+
let installed = false;
|
|
1620
|
+
try {
|
|
1621
|
+
const hooks = readSettings(settingsFile).hooks ?? {};
|
|
1622
|
+
installed = Object.values(hooks).some((groups) => Array.isArray(groups) && groups.some((g) => g?.hooks?.some?.(isOurs)));
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
const rel = path7.relative(home, dir);
|
|
1626
|
+
return {
|
|
1627
|
+
dir,
|
|
1628
|
+
label: rel && !rel.startsWith("..") ? `~/${fwd(rel)}` : dir,
|
|
1629
|
+
settingsFile,
|
|
1630
|
+
exists: fs7.existsSync(dir),
|
|
1631
|
+
installed
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
function stripOurHooks(hooks) {
|
|
1635
|
+
const out = {};
|
|
1636
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
1637
|
+
if (!Array.isArray(groups)) {
|
|
1638
|
+
out[event] = groups;
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
const kept = [];
|
|
1642
|
+
for (const g of groups) {
|
|
1643
|
+
if (!g || !Array.isArray(g.hooks)) {
|
|
1644
|
+
kept.push(g);
|
|
1645
|
+
continue;
|
|
1646
|
+
}
|
|
1647
|
+
const remaining = g.hooks.filter((h) => !isOurs(h));
|
|
1648
|
+
if (remaining.length === g.hooks.length) kept.push(g);
|
|
1649
|
+
else if (remaining.length > 0) kept.push({ ...g, hooks: remaining });
|
|
1650
|
+
}
|
|
1651
|
+
if (kept.length > 0) out[event] = kept;
|
|
1652
|
+
}
|
|
1653
|
+
return out;
|
|
1654
|
+
}
|
|
1655
|
+
function mergeOurHooks(hooks, hookScript2, nodePath = process.execPath) {
|
|
1656
|
+
const out = stripOurHooks(hooks);
|
|
1657
|
+
for (const p of HOOK_PLAN) {
|
|
1658
|
+
const hook = { type: "command", command: hookCommand(hookScript2, p.sub, nodePath), timeout: p.timeout };
|
|
1659
|
+
if (p.async) hook.async = true;
|
|
1660
|
+
const group = p.matcher ? { matcher: p.matcher, hooks: [hook] } : { hooks: [hook] };
|
|
1661
|
+
out[p.event] = [...out[p.event] ?? [], group];
|
|
1662
|
+
}
|
|
1663
|
+
return out;
|
|
1664
|
+
}
|
|
1665
|
+
function timestamp(d = /* @__PURE__ */ new Date()) {
|
|
1666
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
1667
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
1668
|
+
}
|
|
1669
|
+
function backupSettings(target, backupDir) {
|
|
1670
|
+
if (!fs7.existsSync(target.settingsFile)) return null;
|
|
1671
|
+
fs7.mkdirSync(backupDir, { recursive: true });
|
|
1672
|
+
const name = `${path7.basename(target.dir).replace(/^\./, "") || "claude"}-settings-${timestamp()}.json`;
|
|
1673
|
+
let dest = path7.join(backupDir, name);
|
|
1674
|
+
for (let i = 1; fs7.existsSync(dest); i++) dest = path7.join(backupDir, name.replace(/\.json$/, `-${i}.json`));
|
|
1675
|
+
fs7.copyFileSync(target.settingsFile, dest);
|
|
1676
|
+
return dest;
|
|
1677
|
+
}
|
|
1678
|
+
function writeValidated(file, data, backup) {
|
|
1679
|
+
const tmp = `${file}.crewdesk-${process.pid}.tmp`;
|
|
1680
|
+
try {
|
|
1681
|
+
fs7.mkdirSync(path7.dirname(file), { recursive: true });
|
|
1682
|
+
fs7.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
1683
|
+
JSON.parse(fs7.readFileSync(tmp, "utf8"));
|
|
1684
|
+
fs7.renameSync(tmp, file);
|
|
1685
|
+
const check = readSettings(file);
|
|
1686
|
+
if (JSON.stringify(check) !== JSON.stringify(data)) throw new Error("written settings do not match");
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
fs7.rmSync(tmp, { force: true });
|
|
1689
|
+
if (backup) fs7.copyFileSync(backup, file);
|
|
1690
|
+
throw new Error(`failed to update ${file} (${err.message}); original restored`);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
function installHooks(target, hookScript2, backupDir, dryRun = false) {
|
|
1694
|
+
const settings = readSettings(target.settingsFile);
|
|
1695
|
+
const before = JSON.stringify(settings.hooks ?? {});
|
|
1696
|
+
const hooks = mergeOurHooks(settings.hooks ?? {}, hookScript2);
|
|
1697
|
+
const changed = JSON.stringify(hooks) !== before;
|
|
1698
|
+
if (dryRun || !changed) return { target, backup: null, changed };
|
|
1699
|
+
const backup = backupSettings(target, backupDir);
|
|
1700
|
+
writeValidated(target.settingsFile, { ...settings, hooks }, backup);
|
|
1701
|
+
return { target, backup, changed };
|
|
1702
|
+
}
|
|
1703
|
+
function uninstallHooks(target, backupDir, dryRun = false) {
|
|
1704
|
+
if (!fs7.existsSync(target.settingsFile)) return { target, backup: null, changed: false };
|
|
1705
|
+
const settings = readSettings(target.settingsFile);
|
|
1706
|
+
const current = settings.hooks ?? {};
|
|
1707
|
+
const hooks = stripOurHooks(current);
|
|
1708
|
+
const changed = JSON.stringify(hooks) !== JSON.stringify(current);
|
|
1709
|
+
if (dryRun || !changed) return { target, backup: null, changed };
|
|
1710
|
+
const backup = backupSettings(target, backupDir);
|
|
1711
|
+
const next = { ...settings };
|
|
1712
|
+
if (Object.keys(hooks).length) next.hooks = hooks;
|
|
1713
|
+
else delete next.hooks;
|
|
1714
|
+
writeValidated(target.settingsFile, next, backup);
|
|
1715
|
+
return { target, backup, changed };
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// src/cli.ts
|
|
1719
|
+
var here = path8.dirname(fileURLToPath(import.meta.url));
|
|
1720
|
+
var root = path8.resolve(here, "../../..");
|
|
1721
|
+
var cliScript = process.env.CREWDESK_CLI_SCRIPT ?? path8.join(root, "bin", "crewdesk.mjs");
|
|
1722
|
+
var hookScript = process.env.CREWDESK_HOOK_SCRIPT ?? path8.join(root, "bin", "crewdesk-hook.mjs");
|
|
1723
|
+
var webDir = process.env.CREWDESK_WEB_DIR ?? path8.join(root, "apps", "web", "dist");
|
|
1724
|
+
var c = {
|
|
1725
|
+
ok: (s) => `\x1B[32m\u2713\x1B[0m ${s}`,
|
|
1726
|
+
bad: (s) => `\x1B[31m\u2717\x1B[0m ${s}`,
|
|
1727
|
+
warn: (s) => `\x1B[33m!\x1B[0m ${s}`,
|
|
1728
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
1729
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`
|
|
1730
|
+
};
|
|
1731
|
+
var HELP = `Crewdesk ${VERSION}
|
|
1732
|
+
|
|
1733
|
+
Usage: crewdesk <command> [options]
|
|
1734
|
+
|
|
1735
|
+
Commands:
|
|
1736
|
+
start [--foreground] Start the hub (background daemon by default)
|
|
1737
|
+
stop Stop the hub
|
|
1738
|
+
restart Restart the hub
|
|
1739
|
+
status Show hub status, agents and pending requests
|
|
1740
|
+
open Open the dashboard in the browser
|
|
1741
|
+
logs [-f] [-n N] Show the hub log
|
|
1742
|
+
configure Install Claude Code hooks (backs up settings first)
|
|
1743
|
+
--all | --dir <path> ... choose config dirs non-interactively
|
|
1744
|
+
--dry-run show what would change
|
|
1745
|
+
doctor Diagnose the installation
|
|
1746
|
+
uninstall Remove hooks and optionally the database
|
|
1747
|
+
|
|
1748
|
+
Environment: CREWDESK_HOME, CREWDESK_PORT, CREWDESK_HOST, CREWDESK_DB,
|
|
1749
|
+
CREWDESK_HEARTBEAT_TIMEOUT, LOG_LEVEL
|
|
1750
|
+
`;
|
|
1751
|
+
function flag(args, name) {
|
|
1752
|
+
return args.includes(name);
|
|
1753
|
+
}
|
|
1754
|
+
function flagValues(args, name) {
|
|
1755
|
+
const out = [];
|
|
1756
|
+
args.forEach((a, i) => {
|
|
1757
|
+
if (a === name && args[i + 1]) out.push(args[i + 1]);
|
|
1758
|
+
});
|
|
1759
|
+
return out;
|
|
1760
|
+
}
|
|
1761
|
+
async function ask(question) {
|
|
1762
|
+
if (!process.stdin.isTTY) return "";
|
|
1763
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1764
|
+
try {
|
|
1765
|
+
return (await rl.question(question)).trim();
|
|
1766
|
+
} finally {
|
|
1767
|
+
rl.close();
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
async function confirm(question, dflt = true) {
|
|
1771
|
+
const a = (await ask(`${question} ${dflt ? "[Y/n]" : "[y/N]"} `)).toLowerCase();
|
|
1772
|
+
if (!a) return dflt;
|
|
1773
|
+
return a.startsWith("y");
|
|
1774
|
+
}
|
|
1775
|
+
function openBrowser(url) {
|
|
1776
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
1777
|
+
console.log(`Opening ${url}`);
|
|
1778
|
+
exec(cmd, (err) => {
|
|
1779
|
+
if (err) console.log(`Could not open a browser. Visit ${url}`);
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
async function cmdStart(cfg, args) {
|
|
1783
|
+
if (flag(args, "--foreground")) {
|
|
1784
|
+
const daemon = flag(args, "--daemon");
|
|
1785
|
+
if (!daemon) console.log(c.bold("Crewdesk"));
|
|
1786
|
+
await runForeground({ ...cfg, webDir }, { daemon });
|
|
1787
|
+
if (!daemon) {
|
|
1788
|
+
console.log(`
|
|
1789
|
+
Dashboard:
|
|
1790
|
+
${baseUrl(cfg)}
|
|
1791
|
+
|
|
1792
|
+
Waiting for Claude Code sessions... (Ctrl+C to stop)`);
|
|
1793
|
+
}
|
|
1794
|
+
return;
|
|
1795
|
+
}
|
|
1796
|
+
const { pid, already } = await startDaemon(cfg, cliScript);
|
|
1797
|
+
console.log(c.bold("Crewdesk\n"));
|
|
1798
|
+
if (already) console.log(c.ok(`Already running (pid ${pid})`));
|
|
1799
|
+
else {
|
|
1800
|
+
console.log(c.ok("Server started"));
|
|
1801
|
+
console.log(c.ok("Database ready"));
|
|
1802
|
+
console.log(c.ok("WebSocket ready"));
|
|
1803
|
+
}
|
|
1804
|
+
if (!fs8.existsSync(path8.join(webDir, "index.html"))) console.log(c.warn("Web UI not built \u2014 run `pnpm build`"));
|
|
1805
|
+
console.log(`
|
|
1806
|
+
Dashboard:
|
|
1807
|
+
${baseUrl(cfg)}
|
|
1808
|
+
`);
|
|
1809
|
+
if (!already) console.log("Waiting for Claude Code sessions...");
|
|
1810
|
+
}
|
|
1811
|
+
async function cmdStatus(cfg) {
|
|
1812
|
+
const h = await health(cfg);
|
|
1813
|
+
if (!h) {
|
|
1814
|
+
console.log(c.bad(`Hub is not running on ${baseUrl(cfg)}`));
|
|
1815
|
+
process.exitCode = 1;
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
console.log(c.ok(`Hub running (pid ${h.pid}, up ${h.uptime}s, v${h.version}) \u2014 ${baseUrl(cfg)}`));
|
|
1819
|
+
const [agents, pending] = await Promise.all([
|
|
1820
|
+
fetch(`${baseUrl(cfg)}/api/agents`).then((r) => r.json()),
|
|
1821
|
+
fetch(`${baseUrl(cfg)}/api/events/pending`).then((r) => r.json())
|
|
1822
|
+
]);
|
|
1823
|
+
const online = agents.agents.filter((a) => a.status !== "OFFLINE");
|
|
1824
|
+
console.log(`
|
|
1825
|
+
${online.length} active agent(s), ${pending.events.length} need attention
|
|
1826
|
+
`);
|
|
1827
|
+
for (const a of agents.agents) {
|
|
1828
|
+
console.log(` ${a.status.padEnd(10)} ${a.name.padEnd(28)} ${c.dim(a.cwd ?? "")}${a.pendingCount ? ` (${a.pendingCount} pending)` : ""}`);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
async function cmdLogs(cfg, args) {
|
|
1832
|
+
const file = logFile(cfg);
|
|
1833
|
+
if (!fs8.existsSync(file)) {
|
|
1834
|
+
console.log(`No log yet at ${file}`);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
const n = Number(flagValues(args, "-n")[0] ?? 100);
|
|
1838
|
+
const lines = fs8.readFileSync(file, "utf8").split(/\r?\n/);
|
|
1839
|
+
process.stdout.write(lines.slice(-n - 1).join("\n"));
|
|
1840
|
+
if (!flag(args, "-f")) return;
|
|
1841
|
+
let size = fs8.statSync(file).size;
|
|
1842
|
+
fs8.watchFile(file, { interval: 500 }, (cur) => {
|
|
1843
|
+
if (cur.size < size) size = 0;
|
|
1844
|
+
if (cur.size === size) return;
|
|
1845
|
+
const fd = fs8.openSync(file, "r");
|
|
1846
|
+
const buf = Buffer.alloc(cur.size - size);
|
|
1847
|
+
fs8.readSync(fd, buf, 0, buf.length, size);
|
|
1848
|
+
fs8.closeSync(fd);
|
|
1849
|
+
size = cur.size;
|
|
1850
|
+
process.stdout.write(buf.toString("utf8"));
|
|
1851
|
+
});
|
|
1852
|
+
await new Promise(() => {
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
async function chooseTargets(args, purpose) {
|
|
1856
|
+
const explicit = flagValues(args, "--dir");
|
|
1857
|
+
if (explicit.length) return explicit.map((d) => describeTarget(path8.resolve(d)));
|
|
1858
|
+
const all = detectConfigDirs().map((d) => describeTarget(d));
|
|
1859
|
+
const candidates = purpose === "remove" ? all.filter((t) => t.installed) : all;
|
|
1860
|
+
if (flag(args, "--all") || flag(args, "--yes") || !process.stdin.isTTY) return candidates;
|
|
1861
|
+
if (candidates.length === 0) return [];
|
|
1862
|
+
console.log(`Detected Claude Code configuration directories:
|
|
1863
|
+
`);
|
|
1864
|
+
candidates.forEach(
|
|
1865
|
+
(t, i) => console.log(` [${i + 1}] ${t.label.padEnd(40)} ${t.installed ? c.dim("(hooks installed)") : t.exists ? "" : c.dim("(new)")}`)
|
|
1866
|
+
);
|
|
1867
|
+
const answer = await ask(`
|
|
1868
|
+
Which should ${purpose === "install" ? "get" : "lose"} the hooks? (e.g. 1,3 \u2014 Enter for all, 0 for none) `);
|
|
1869
|
+
if (!answer) return candidates;
|
|
1870
|
+
if (answer === "0") return [];
|
|
1871
|
+
const picked = new Set(answer.split(/[ ,]+/).map((s) => Number(s) - 1));
|
|
1872
|
+
return candidates.filter((_, i) => picked.has(i));
|
|
1873
|
+
}
|
|
1874
|
+
async function cmdConfigure(cfg, args) {
|
|
1875
|
+
fs8.mkdirSync(cfg.home, { recursive: true });
|
|
1876
|
+
if (!fs8.existsSync(configPath(cfg.home))) {
|
|
1877
|
+
const initial = { port: cfg.port, host: cfg.host, heartbeatTimeout: cfg.heartbeatTimeout, notifications: true, sound: true };
|
|
1878
|
+
fs8.writeFileSync(configPath(cfg.home), JSON.stringify(initial, null, 2) + "\n");
|
|
1879
|
+
console.log(c.ok(`Created ${configPath(cfg.home)}`));
|
|
1880
|
+
}
|
|
1881
|
+
const policy = cfg.policyFile;
|
|
1882
|
+
if (!fs8.existsSync(policy)) {
|
|
1883
|
+
fs8.writeFileSync(
|
|
1884
|
+
policy,
|
|
1885
|
+
`# Permission policies for Crewdesk. First matching rule wins.
|
|
1886
|
+
# action: allow | deny | ask. Destructive or chained commands are never auto-allowed.
|
|
1887
|
+
permissions: []
|
|
1888
|
+
# - tool: "Bash"
|
|
1889
|
+
# match: "git status"
|
|
1890
|
+
# action: allow
|
|
1891
|
+
`
|
|
1892
|
+
);
|
|
1893
|
+
}
|
|
1894
|
+
if (!fs8.existsSync(hookScript)) console.log(c.warn(`Hook script not found at ${hookScript} \u2014 run \`pnpm build\``));
|
|
1895
|
+
const targets = await chooseTargets(args, "install");
|
|
1896
|
+
if (targets.length === 0) {
|
|
1897
|
+
console.log("No configuration directories selected.");
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
const dry = flag(args, "--dry-run");
|
|
1901
|
+
const backups = path8.join(cfg.home, "backups");
|
|
1902
|
+
for (const t of targets) {
|
|
1903
|
+
try {
|
|
1904
|
+
const r = installHooks(t, hookScript, backups, dry);
|
|
1905
|
+
const note = !r.changed ? "already up to date" : dry ? "would install hooks" : "hooks installed";
|
|
1906
|
+
console.log(c.ok(`${t.label}: ${note}${r.backup ? c.dim(` (backup ${path8.basename(r.backup)})`) : ""}`));
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
console.log(c.bad(`${t.label}: ${err.message}`));
|
|
1909
|
+
process.exitCode = 1;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
if (!dry) console.log(`
|
|
1913
|
+
Restart running Claude Code sessions (or run /hooks) to pick up the new hooks.`);
|
|
1914
|
+
}
|
|
1915
|
+
async function cmdUninstall(cfg, args) {
|
|
1916
|
+
const targets = await chooseTargets(args, "remove");
|
|
1917
|
+
const backups = path8.join(cfg.home, "backups");
|
|
1918
|
+
for (const t of targets) {
|
|
1919
|
+
try {
|
|
1920
|
+
const r = uninstallHooks(t, backups, flag(args, "--dry-run"));
|
|
1921
|
+
console.log(c.ok(`${t.label}: ${r.changed ? "hooks removed" : "no hooks found"}`));
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
console.log(c.bad(`${t.label}: ${err.message}`));
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
if (readPid(cfg)) {
|
|
1927
|
+
await stopDaemon(cfg);
|
|
1928
|
+
console.log(c.ok("Hub stopped"));
|
|
1929
|
+
}
|
|
1930
|
+
const deleteDb = flag(args, "--delete-data") || !flag(args, "--keep-data") && await confirm("Delete the database and event history?", false);
|
|
1931
|
+
if (deleteDb) {
|
|
1932
|
+
for (const f of [cfg.database, `${cfg.database}-wal`, `${cfg.database}-shm`]) fs8.rmSync(f, { force: true });
|
|
1933
|
+
console.log(c.ok("Database deleted"));
|
|
1934
|
+
} else console.log(c.ok(`Database kept at ${cfg.database}`));
|
|
1935
|
+
console.log(`
|
|
1936
|
+
To remove the application: npm rm -g crewdesk (or pnpm unlink --global).`);
|
|
1937
|
+
}
|
|
1938
|
+
async function cmdDoctor(cfg) {
|
|
1939
|
+
console.log(c.bold("Crewdesk Doctor\n"));
|
|
1940
|
+
let problems = 0;
|
|
1941
|
+
const check = (ok, label, hint) => {
|
|
1942
|
+
console.log(ok ? c.ok(label) : c.bad(`${label}${hint ? c.dim(` \u2014 ${hint}`) : ""}`));
|
|
1943
|
+
if (!ok) problems++;
|
|
1944
|
+
};
|
|
1945
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
1946
|
+
check(major > 22 || major === 22 && minor >= 13, `Node.js ${process.versions.node}`, "Node >= 22.13 required (node:sqlite)");
|
|
1947
|
+
let sqliteOk = false;
|
|
1948
|
+
try {
|
|
1949
|
+
const { DatabaseSync: DatabaseSync2 } = await import("node:sqlite");
|
|
1950
|
+
new DatabaseSync2(":memory:").close();
|
|
1951
|
+
sqliteOk = true;
|
|
1952
|
+
} catch {
|
|
1953
|
+
}
|
|
1954
|
+
check(sqliteOk, "SQLite");
|
|
1955
|
+
let dbOk = false;
|
|
1956
|
+
try {
|
|
1957
|
+
fs8.mkdirSync(path8.dirname(cfg.database), { recursive: true });
|
|
1958
|
+
fs8.accessSync(path8.dirname(cfg.database), fs8.constants.W_OK);
|
|
1959
|
+
dbOk = true;
|
|
1960
|
+
} catch {
|
|
1961
|
+
}
|
|
1962
|
+
check(dbOk, `Database ${cfg.database}`, "directory not writable");
|
|
1963
|
+
const h = await health(cfg);
|
|
1964
|
+
check(Boolean(h), `Server ${baseUrl(cfg)}`, "not running \u2014 `crewdesk start`");
|
|
1965
|
+
if (h) check(h.database === "ok", `Port ${cfg.port} (hub pid ${h.pid})`);
|
|
1966
|
+
else {
|
|
1967
|
+
const net = await import("node:net");
|
|
1968
|
+
const free = await new Promise((resolve) => {
|
|
1969
|
+
const s = net.createServer().once("error", () => resolve(false)).once("listening", () => s.close(() => resolve(true)));
|
|
1970
|
+
s.listen(cfg.port, cfg.host);
|
|
1971
|
+
});
|
|
1972
|
+
check(free, `Port ${cfg.port}`, "in use by another program");
|
|
1973
|
+
}
|
|
1974
|
+
let claudeVersion = "";
|
|
1975
|
+
try {
|
|
1976
|
+
claudeVersion = execSync("claude --version", { encoding: "utf8", timeout: 1e4, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1977
|
+
} catch {
|
|
1978
|
+
}
|
|
1979
|
+
check(Boolean(claudeVersion), `Claude Code detected${claudeVersion ? ` (${claudeVersion})` : ""}`, "`claude` not on PATH");
|
|
1980
|
+
const targets = detectConfigDirs().map((d) => describeTarget(d));
|
|
1981
|
+
const installed = targets.filter((t) => t.installed);
|
|
1982
|
+
check(installed.length > 0, `Hook configuration (${installed.map((t) => t.label).join(", ") || "none"})`, "run `crewdesk configure`");
|
|
1983
|
+
for (const t of targets.filter((t2) => !t2.installed)) console.log(c.dim(` not installed in ${t.label}`));
|
|
1984
|
+
check(fs8.existsSync(hookScript) && fs8.existsSync(path8.join(root, "packages", "hook", "dist", "hook.js")), "Hook executable", "run `pnpm build`");
|
|
1985
|
+
check(fs8.existsSync(path8.join(webDir, "index.html")), "Web UI build", "run `pnpm build`");
|
|
1986
|
+
console.log(problems ? `
|
|
1987
|
+
${problems} problem(s) found.` : "\nEverything looks good.");
|
|
1988
|
+
if (problems) process.exitCode = 1;
|
|
1989
|
+
}
|
|
1990
|
+
async function firstRun(cfg) {
|
|
1991
|
+
console.log(c.bold("Welcome to Crewdesk\n"));
|
|
1992
|
+
console.log("This will:\n");
|
|
1993
|
+
console.log(" \u2713 Create a local database");
|
|
1994
|
+
console.log(" \u2713 Configure Claude Code hooks");
|
|
1995
|
+
console.log(" \u2713 Start the local server");
|
|
1996
|
+
console.log(" \u2713 Open the dashboard\n");
|
|
1997
|
+
if (!await confirm("Continue?")) return;
|
|
1998
|
+
await cmdConfigure(cfg, []);
|
|
1999
|
+
await cmdStart(cfg, []);
|
|
2000
|
+
openBrowser(baseUrl(cfg));
|
|
2001
|
+
}
|
|
2002
|
+
async function main(argv = process.argv.slice(2)) {
|
|
2003
|
+
const [cmd, ...args] = argv;
|
|
2004
|
+
const cfg = loadConfig();
|
|
2005
|
+
switch (cmd) {
|
|
2006
|
+
case "start":
|
|
2007
|
+
return cmdStart(cfg, args);
|
|
2008
|
+
case "stop":
|
|
2009
|
+
console.log(await stopDaemon(cfg) ? c.ok("Hub stopped") : "Hub is not running.");
|
|
2010
|
+
return;
|
|
2011
|
+
case "restart":
|
|
2012
|
+
await stopDaemon(cfg);
|
|
2013
|
+
return cmdStart(cfg, args);
|
|
2014
|
+
case "status":
|
|
2015
|
+
return cmdStatus(cfg);
|
|
2016
|
+
case "open":
|
|
2017
|
+
if (!await health(cfg)) await cmdStart(cfg, []);
|
|
2018
|
+
openBrowser(baseUrl(cfg));
|
|
2019
|
+
return;
|
|
2020
|
+
case "logs":
|
|
2021
|
+
return cmdLogs(cfg, args);
|
|
2022
|
+
case "configure":
|
|
2023
|
+
return cmdConfigure(cfg, args);
|
|
2024
|
+
case "doctor":
|
|
2025
|
+
return cmdDoctor(cfg);
|
|
2026
|
+
case "uninstall":
|
|
2027
|
+
return cmdUninstall(cfg, args);
|
|
2028
|
+
case "--version":
|
|
2029
|
+
case "-v":
|
|
2030
|
+
console.log(VERSION);
|
|
2031
|
+
return;
|
|
2032
|
+
case void 0:
|
|
2033
|
+
if (!fs8.existsSync(configPath(cfg.home)) && process.stdin.isTTY) return firstRun(cfg);
|
|
2034
|
+
console.log(HELP);
|
|
2035
|
+
return;
|
|
2036
|
+
default:
|
|
2037
|
+
console.log(HELP);
|
|
2038
|
+
if (cmd !== "help" && cmd !== "--help" && cmd !== "-h") process.exitCode = 1;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
var invokedDirectly = process.argv[1] && path8.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
2042
|
+
if (invokedDirectly || process.env.CREWDESK_RUN_CLI === "1") {
|
|
2043
|
+
main().catch((err) => {
|
|
2044
|
+
console.error(c.bad(err.message));
|
|
2045
|
+
process.exit(1);
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
export {
|
|
2049
|
+
main
|
|
2050
|
+
};
|