@hydra-acp/cli 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/dist/index.js ADDED
@@ -0,0 +1,3119 @@
1
+ // src/daemon/server.ts
2
+ import * as fs5 from "fs";
3
+ import * as fsp2 from "fs/promises";
4
+ import Fastify from "fastify";
5
+ import websocketPlugin from "@fastify/websocket";
6
+ import pino from "pino";
7
+ import createPinoRoll from "pino-roll";
8
+
9
+ // src/core/config.ts
10
+ import * as fs from "fs/promises";
11
+ import { homedir as homedir2 } from "os";
12
+ import { z } from "zod";
13
+
14
+ // src/core/paths.ts
15
+ import * as path from "path";
16
+ import * as os from "os";
17
+ var ROOT_ENV = "HYDRA_ACP_HOME";
18
+ function hydraHome() {
19
+ const override = process.env[ROOT_ENV];
20
+ if (override && override.length > 0) {
21
+ return path.resolve(override);
22
+ }
23
+ return path.join(os.homedir(), ".hydra-acp");
24
+ }
25
+ var paths = {
26
+ home: hydraHome,
27
+ config: () => path.join(hydraHome(), "config.json"),
28
+ pidFile: () => path.join(hydraHome(), "daemon.pid"),
29
+ logFile: () => path.join(hydraHome(), "daemon.log"),
30
+ currentLogFile: () => path.join(hydraHome(), "current.log"),
31
+ registryCache: () => path.join(hydraHome(), "registry.json"),
32
+ agentsDir: () => path.join(hydraHome(), "agents"),
33
+ agentDir: (id) => path.join(hydraHome(), "agents", id),
34
+ sessionsDir: () => path.join(hydraHome(), "sessions"),
35
+ sessionFile: (id) => path.join(hydraHome(), "sessions", `${id}.json`),
36
+ extensionsDir: () => path.join(hydraHome(), "extensions"),
37
+ extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
38
+ extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
39
+ tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
40
+ };
41
+
42
+ // src/core/config.ts
43
+ var REGISTRY_URL_DEFAULT = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
44
+ var TlsConfig = z.object({
45
+ cert: z.string(),
46
+ key: z.string()
47
+ });
48
+ var DaemonConfig = z.object({
49
+ host: z.string().default("127.0.0.1"),
50
+ port: z.number().int().positive().default(8765),
51
+ authToken: z.string().min(16),
52
+ logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
53
+ tls: TlsConfig.optional(),
54
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(30)
55
+ });
56
+ var RegistryConfig = z.object({
57
+ url: z.string().url().default(REGISTRY_URL_DEFAULT),
58
+ ttlHours: z.number().positive().default(24)
59
+ });
60
+ var TuiConfig = z.object({
61
+ // Minimum interval (ms) between full-screen repaints driven by content
62
+ // events (agent text chunks, tool/plan updates, elapsed-tick refreshes).
63
+ // User-action repaints — scrolling, prompt-row changes, modal open/close,
64
+ // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
65
+ // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
66
+ // or 0 to disable throttling entirely.
67
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3)
68
+ });
69
+ var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
70
+ var ExtensionBody = z.object({
71
+ // Optional: if omitted, the spawn command defaults to [name], so a
72
+ // package called `hydra-acp-slack` that exposes a `hydra-acp-slack` bin
73
+ // can be enabled with just an empty body `{}`.
74
+ command: z.array(z.string()).default([]),
75
+ args: z.array(z.string()).default([]),
76
+ env: z.record(z.string()).default({}),
77
+ enabled: z.boolean().default(true)
78
+ });
79
+ var HydraConfig = z.object({
80
+ daemon: DaemonConfig,
81
+ registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
82
+ defaultAgent: z.string().default("claude-acp"),
83
+ // Where new sessions land when POST /v1/sessions omits cwd. Stored as
84
+ // a literal string ("~", "~/dev", "$HOME/work") so the config file is
85
+ // portable across machines; expanded via expandHome at use time.
86
+ defaultCwd: z.string().default("~"),
87
+ // Cap on cold sessions shown in CLI `sessions` listing and the TUI
88
+ // picker. Live sessions are always included; cold are sorted by
89
+ // recency and truncated to this count. `--all` overrides in the CLI.
90
+ sessionListColdLimit: z.number().int().nonnegative().default(20),
91
+ extensions: z.record(ExtensionName, ExtensionBody).default({}),
92
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
93
+ });
94
+ function extensionList(config) {
95
+ return Object.entries(config.extensions).map(([name, body]) => ({
96
+ name,
97
+ ...body
98
+ }));
99
+ }
100
+ async function loadConfig() {
101
+ const configPath = paths.config();
102
+ let raw;
103
+ try {
104
+ raw = await fs.readFile(configPath, "utf8");
105
+ } catch (err) {
106
+ const e = err;
107
+ if (e.code === "ENOENT") {
108
+ throw new Error(
109
+ `No config found at ${configPath}. Run \`hydra-acp init\` to create one.`
110
+ );
111
+ }
112
+ throw err;
113
+ }
114
+ const parsed = JSON.parse(raw);
115
+ return HydraConfig.parse(parsed);
116
+ }
117
+ async function ensureConfig() {
118
+ try {
119
+ await fs.access(paths.config());
120
+ } catch (err) {
121
+ const e = err;
122
+ if (e.code !== "ENOENT") {
123
+ throw err;
124
+ }
125
+ const config = defaultConfig();
126
+ await writeConfig(config);
127
+ process.stderr.write(
128
+ `hydra-acp: initialized ${paths.config()} with a fresh auth token.
129
+ `
130
+ );
131
+ return config;
132
+ }
133
+ return loadConfig();
134
+ }
135
+ async function writeConfig(config) {
136
+ await fs.mkdir(paths.home(), { recursive: true });
137
+ await fs.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
138
+ encoding: "utf8",
139
+ mode: 384
140
+ });
141
+ }
142
+ function generateAuthToken() {
143
+ const bytes = new Uint8Array(32);
144
+ crypto.getRandomValues(bytes);
145
+ let hex = "";
146
+ for (const b of bytes) {
147
+ hex += b.toString(16).padStart(2, "0");
148
+ }
149
+ return `hydra_token_${hex}`;
150
+ }
151
+ function defaultConfig() {
152
+ return HydraConfig.parse({
153
+ daemon: {
154
+ authToken: generateAuthToken()
155
+ }
156
+ });
157
+ }
158
+ function expandHome(p) {
159
+ if (p === "~" || p === "$HOME") {
160
+ return homedir2();
161
+ }
162
+ if (p.startsWith("~/")) {
163
+ return homedir2() + p.slice(1);
164
+ }
165
+ if (p.startsWith("$HOME/")) {
166
+ return homedir2() + p.slice("$HOME".length);
167
+ }
168
+ return p;
169
+ }
170
+
171
+ // src/core/registry.ts
172
+ import * as fs2 from "fs/promises";
173
+ import { z as z2 } from "zod";
174
+ var NpxDistribution = z2.object({
175
+ package: z2.string(),
176
+ args: z2.array(z2.string()).optional(),
177
+ env: z2.record(z2.string()).optional()
178
+ });
179
+ var BinaryTarget = z2.object({
180
+ archive: z2.string().url().optional(),
181
+ cmd: z2.string().optional()
182
+ });
183
+ var BinaryDistribution = z2.object({
184
+ "darwin-aarch64": BinaryTarget.optional(),
185
+ "darwin-x86_64": BinaryTarget.optional(),
186
+ "linux-aarch64": BinaryTarget.optional(),
187
+ "linux-x86_64": BinaryTarget.optional(),
188
+ "windows-x86_64": BinaryTarget.optional(),
189
+ "windows-aarch64": BinaryTarget.optional()
190
+ });
191
+ var UvxDistribution = z2.object({
192
+ package: z2.string(),
193
+ args: z2.array(z2.string()).optional(),
194
+ env: z2.record(z2.string()).optional()
195
+ });
196
+ var Distribution = z2.object({
197
+ npx: NpxDistribution.optional(),
198
+ binary: BinaryDistribution.optional(),
199
+ uvx: UvxDistribution.optional()
200
+ });
201
+ var RegistryAgent = z2.object({
202
+ id: z2.string(),
203
+ name: z2.string(),
204
+ version: z2.string().optional(),
205
+ description: z2.string().optional(),
206
+ authors: z2.array(z2.string()).optional(),
207
+ license: z2.string().optional(),
208
+ icon: z2.string().optional(),
209
+ repository: z2.string().optional(),
210
+ website: z2.string().optional(),
211
+ distribution: Distribution
212
+ });
213
+ var RegistryDocument = z2.object({
214
+ version: z2.string(),
215
+ agents: z2.array(RegistryAgent),
216
+ extensions: z2.array(z2.unknown()).optional()
217
+ });
218
+ var Registry = class {
219
+ constructor(config) {
220
+ this.config = config;
221
+ }
222
+ config;
223
+ cache;
224
+ async load() {
225
+ if (this.cache && this.isFresh(this.cache.fetchedAt)) {
226
+ return this.cache.data;
227
+ }
228
+ const onDisk = await this.readDiskCache();
229
+ if (onDisk && this.isFresh(onDisk.fetchedAt)) {
230
+ this.cache = onDisk;
231
+ return onDisk.data;
232
+ }
233
+ try {
234
+ const fresh = await this.fetchFromNetwork();
235
+ this.cache = fresh;
236
+ await this.writeDiskCache(fresh);
237
+ return fresh.data;
238
+ } catch (err) {
239
+ if (onDisk) {
240
+ this.cache = onDisk;
241
+ return onDisk.data;
242
+ }
243
+ throw err;
244
+ }
245
+ }
246
+ async refresh() {
247
+ const fresh = await this.fetchFromNetwork();
248
+ this.cache = fresh;
249
+ await this.writeDiskCache(fresh);
250
+ return fresh.data;
251
+ }
252
+ async getAgent(id) {
253
+ const doc = await this.load();
254
+ const exact = doc.agents.find((a) => a.id === id);
255
+ if (exact) {
256
+ return exact;
257
+ }
258
+ return doc.agents.find((a) => npxPackageBasename(a) === id);
259
+ }
260
+ isFresh(fetchedAt) {
261
+ const ageMs = Date.now() - fetchedAt;
262
+ const ttlMs = this.config.registry.ttlHours * 60 * 60 * 1e3;
263
+ return ageMs < ttlMs;
264
+ }
265
+ async fetchFromNetwork() {
266
+ const response = await fetch(this.config.registry.url);
267
+ if (!response.ok) {
268
+ throw new Error(`Registry fetch failed: HTTP ${response.status}`);
269
+ }
270
+ const json = await response.json();
271
+ const data = RegistryDocument.parse(json);
272
+ return { fetchedAt: Date.now(), data };
273
+ }
274
+ async readDiskCache() {
275
+ try {
276
+ const raw = await fs2.readFile(paths.registryCache(), "utf8");
277
+ const parsed = JSON.parse(raw);
278
+ if (typeof parsed.fetchedAt === "number" && parsed.data && Array.isArray(parsed.data.agents)) {
279
+ return parsed;
280
+ }
281
+ } catch (err) {
282
+ const e = err;
283
+ if (e.code !== "ENOENT") {
284
+ throw err;
285
+ }
286
+ }
287
+ return void 0;
288
+ }
289
+ async writeDiskCache(cache) {
290
+ await fs2.mkdir(paths.home(), { recursive: true });
291
+ await fs2.writeFile(
292
+ paths.registryCache(),
293
+ JSON.stringify(cache, null, 2) + "\n",
294
+ "utf8"
295
+ );
296
+ }
297
+ };
298
+ function npxPackageBasename(agent) {
299
+ const pkg = agent.distribution.npx?.package;
300
+ if (!pkg) {
301
+ return void 0;
302
+ }
303
+ const lastSlash = pkg.lastIndexOf("/");
304
+ const afterSlash = lastSlash === -1 ? pkg : pkg.slice(lastSlash + 1);
305
+ const atIdx = afterSlash.lastIndexOf("@");
306
+ return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
307
+ }
308
+ function planSpawn(agent, extraArgs = []) {
309
+ if (agent.distribution.npx) {
310
+ const npx = agent.distribution.npx;
311
+ const args = ["-y", npx.package, ...npx.args ?? [], ...extraArgs];
312
+ return {
313
+ command: "npx",
314
+ args,
315
+ env: npx.env ?? {}
316
+ };
317
+ }
318
+ if (agent.distribution.binary) {
319
+ throw new Error(
320
+ `Agent ${agent.id} uses binary distribution; not yet supported in hydra-acp. PRs welcome.`
321
+ );
322
+ }
323
+ if (agent.distribution.uvx) {
324
+ const uvx = agent.distribution.uvx;
325
+ const args = [uvx.package, ...uvx.args ?? [], ...extraArgs];
326
+ return {
327
+ command: "uvx",
328
+ args,
329
+ env: uvx.env ?? {}
330
+ };
331
+ }
332
+ throw new Error(`Agent ${agent.id} has no usable distribution method.`);
333
+ }
334
+
335
+ // src/core/agent-instance.ts
336
+ import { spawn } from "child_process";
337
+
338
+ // src/acp/types.ts
339
+ import { z as z3 } from "zod";
340
+ var JsonRpcErrorCodes = {
341
+ ParseError: -32700,
342
+ InvalidRequest: -32600,
343
+ MethodNotFound: -32601,
344
+ InvalidParams: -32602,
345
+ InternalError: -32603,
346
+ SessionNotFound: -32001,
347
+ PermissionDenied: -32002,
348
+ AlreadyAttached: -32003,
349
+ AgentNotInstalled: -32005
350
+ };
351
+ var InitializeParams = z3.object({
352
+ protocolVersion: z3.number().optional(),
353
+ clientCapabilities: z3.record(z3.unknown()).optional(),
354
+ clientInfo: z3.object({
355
+ name: z3.string(),
356
+ version: z3.string().optional()
357
+ }).optional()
358
+ });
359
+ var HistoryPolicy = z3.enum(["full", "pending_only", "none"]);
360
+ var SessionNewParams = z3.object({
361
+ cwd: z3.string(),
362
+ agentId: z3.string().optional(),
363
+ mcpServers: z3.array(z3.unknown()).optional()
364
+ });
365
+ var SessionResumeHints = z3.object({
366
+ upstreamSessionId: z3.string(),
367
+ agentId: z3.string(),
368
+ cwd: z3.string(),
369
+ title: z3.string().optional(),
370
+ agentArgs: z3.array(z3.string()).optional()
371
+ });
372
+ var SessionAttachParams = z3.object({
373
+ sessionId: z3.string(),
374
+ historyPolicy: HistoryPolicy.default("full"),
375
+ clientInfo: z3.object({
376
+ name: z3.string(),
377
+ version: z3.string().optional()
378
+ }).optional(),
379
+ _meta: z3.record(z3.unknown()).optional()
380
+ });
381
+ var HYDRA_META_KEY = "hydra-acp";
382
+ function extractHydraMeta(meta) {
383
+ if (!meta) {
384
+ return {};
385
+ }
386
+ const namespaced = meta[HYDRA_META_KEY];
387
+ if (!namespaced || typeof namespaced !== "object" || Array.isArray(namespaced)) {
388
+ return {};
389
+ }
390
+ const obj = namespaced;
391
+ const out = {};
392
+ if (typeof obj.upstreamSessionId === "string") {
393
+ out.upstreamSessionId = obj.upstreamSessionId;
394
+ }
395
+ if (typeof obj.agentId === "string") {
396
+ out.agentId = obj.agentId;
397
+ }
398
+ if (typeof obj.cwd === "string") {
399
+ out.cwd = obj.cwd;
400
+ }
401
+ if (typeof obj.name === "string") {
402
+ out.name = obj.name;
403
+ }
404
+ if (Array.isArray(obj.agentArgs) && obj.agentArgs.every((a) => typeof a === "string")) {
405
+ out.agentArgs = obj.agentArgs;
406
+ }
407
+ if (obj.resume) {
408
+ const parsed = SessionResumeHints.safeParse(obj.resume);
409
+ if (parsed.success) {
410
+ out.resume = parsed.data;
411
+ }
412
+ }
413
+ return out;
414
+ }
415
+ function mergeMeta(passthrough, ours) {
416
+ return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
417
+ }
418
+ var SessionDetachParams = z3.object({
419
+ sessionId: z3.string()
420
+ });
421
+ var SessionListParams = z3.object({
422
+ cwd: z3.string().optional(),
423
+ cursor: z3.string().optional(),
424
+ limit: z3.number().int().positive().max(200).optional()
425
+ });
426
+ var SessionListEntry = z3.object({
427
+ sessionId: z3.string(),
428
+ upstreamSessionId: z3.string().optional(),
429
+ cwd: z3.string(),
430
+ title: z3.string().optional(),
431
+ agentId: z3.string().optional(),
432
+ updatedAt: z3.string(),
433
+ attachedClients: z3.number().int().nonnegative(),
434
+ status: z3.enum(["live", "cold"]).default("live"),
435
+ _meta: z3.record(z3.unknown()).optional()
436
+ });
437
+ var SessionListResult = z3.object({
438
+ sessions: z3.array(SessionListEntry),
439
+ nextCursor: z3.string().optional()
440
+ });
441
+ var SessionPromptParams = z3.object({
442
+ sessionId: z3.string(),
443
+ prompt: z3.array(z3.unknown())
444
+ });
445
+ var SessionCancelParams = z3.object({
446
+ sessionId: z3.string()
447
+ });
448
+ var ProxyInitializeParams = z3.object({
449
+ protocolVersion: z3.number().optional(),
450
+ proxyInfo: z3.object({
451
+ name: z3.string(),
452
+ version: z3.string().optional()
453
+ }).optional(),
454
+ successor: z3.object({
455
+ command: z3.array(z3.string()),
456
+ env: z3.record(z3.string()).optional()
457
+ }).optional()
458
+ });
459
+
460
+ // src/acp/framing.ts
461
+ function ndjsonStreamFromStdio(stdout, stdin) {
462
+ let buffer = "";
463
+ const messageHandlers = [];
464
+ const closeHandlers = [];
465
+ let closed = false;
466
+ const emitClose = (err) => {
467
+ if (closed) {
468
+ return;
469
+ }
470
+ closed = true;
471
+ for (const handler of closeHandlers) {
472
+ handler(err);
473
+ }
474
+ };
475
+ stdout.setEncoding("utf8");
476
+ stdout.on("data", (chunk) => {
477
+ buffer += chunk;
478
+ let newlineIndex = buffer.indexOf("\n");
479
+ while (newlineIndex !== -1) {
480
+ const line = buffer.slice(0, newlineIndex).trim();
481
+ buffer = buffer.slice(newlineIndex + 1);
482
+ if (line.length > 0) {
483
+ try {
484
+ const parsed = JSON.parse(line);
485
+ for (const handler of messageHandlers) {
486
+ handler(parsed);
487
+ }
488
+ } catch (err) {
489
+ for (const handler of messageHandlers) {
490
+ handler({
491
+ jsonrpc: "2.0",
492
+ id: 0,
493
+ error: {
494
+ code: JsonRpcErrorCodes.ParseError,
495
+ message: `Failed to parse ndjson line: ${err.message}`
496
+ }
497
+ });
498
+ }
499
+ }
500
+ }
501
+ newlineIndex = buffer.indexOf("\n");
502
+ }
503
+ });
504
+ stdout.on("end", () => emitClose());
505
+ stdout.on("error", (err) => emitClose(err));
506
+ stdin.on("error", (err) => emitClose(err));
507
+ return {
508
+ async send(message) {
509
+ if (closed) {
510
+ throw new Error("stream is closed");
511
+ }
512
+ const line = JSON.stringify(message) + "\n";
513
+ await new Promise((resolve2, reject) => {
514
+ stdin.write(line, (err) => {
515
+ if (err) {
516
+ reject(err);
517
+ return;
518
+ }
519
+ resolve2();
520
+ });
521
+ });
522
+ },
523
+ onMessage(handler) {
524
+ messageHandlers.push(handler);
525
+ },
526
+ onClose(handler) {
527
+ closeHandlers.push(handler);
528
+ },
529
+ async close() {
530
+ stdin.end();
531
+ emitClose();
532
+ }
533
+ };
534
+ }
535
+
536
+ // src/acp/connection.ts
537
+ import { nanoid } from "nanoid";
538
+ var JsonRpcConnection = class {
539
+ constructor(stream) {
540
+ this.stream = stream;
541
+ this.stream.onMessage((m) => this.handleIncoming(m));
542
+ this.stream.onClose((err) => this.handleClose(err));
543
+ }
544
+ stream;
545
+ requestHandlers = /* @__PURE__ */ new Map();
546
+ defaultRequestHandler;
547
+ notificationHandlers = /* @__PURE__ */ new Map();
548
+ pending = /* @__PURE__ */ new Map();
549
+ closed = false;
550
+ closeHandlers = [];
551
+ onRequest(method, handler) {
552
+ this.requestHandlers.set(method, handler);
553
+ }
554
+ setDefaultHandler(handler) {
555
+ this.defaultRequestHandler = handler;
556
+ }
557
+ onNotification(method, handler) {
558
+ this.notificationHandlers.set(method, handler);
559
+ }
560
+ onClose(handler) {
561
+ this.closeHandlers.push(handler);
562
+ }
563
+ async request(method, params) {
564
+ return this.requestWithId(method, params).response;
565
+ }
566
+ // Same as request() but exposes the JSON-RPC id assigned to the outbound
567
+ // message. Used when the caller needs to correlate later sideband signals
568
+ // (e.g. permission fan-out) with the specific recipient's request id.
569
+ requestWithId(method, params) {
570
+ if (this.closed) {
571
+ return {
572
+ id: "",
573
+ response: Promise.reject(new Error("connection is closed"))
574
+ };
575
+ }
576
+ const id = nanoid();
577
+ const message = { jsonrpc: "2.0", id, method, params };
578
+ const response = new Promise((resolve2, reject) => {
579
+ this.pending.set(id, {
580
+ resolve: (result) => resolve2(result),
581
+ reject
582
+ });
583
+ this.stream.send(message).catch((err) => {
584
+ this.pending.delete(id);
585
+ reject(err);
586
+ });
587
+ });
588
+ return { id, response };
589
+ }
590
+ notify(method, params) {
591
+ if (this.closed) {
592
+ return Promise.resolve();
593
+ }
594
+ const message = { jsonrpc: "2.0", method, params };
595
+ return this.stream.send(message);
596
+ }
597
+ async close() {
598
+ if (this.closed) {
599
+ return;
600
+ }
601
+ await this.stream.close();
602
+ }
603
+ handleIncoming(message) {
604
+ if ("method" in message) {
605
+ if ("id" in message && message.id !== void 0) {
606
+ this.handleRequest(message).catch(() => void 0);
607
+ } else {
608
+ this.handleNotification(message);
609
+ }
610
+ } else if ("id" in message) {
611
+ this.handleResponse(message);
612
+ }
613
+ }
614
+ async handleRequest(req) {
615
+ const handler = this.requestHandlers.get(req.method) ?? this.defaultRequestHandler;
616
+ if (!handler) {
617
+ await this.sendError(req.id, {
618
+ code: JsonRpcErrorCodes.MethodNotFound,
619
+ message: `Method not found: ${req.method}`
620
+ }).catch(() => void 0);
621
+ return;
622
+ }
623
+ try {
624
+ const result = await handler(req.params, req.method);
625
+ const response = {
626
+ jsonrpc: "2.0",
627
+ id: req.id,
628
+ result
629
+ };
630
+ await this.stream.send(response).catch(() => void 0);
631
+ } catch (err) {
632
+ const error = err;
633
+ await this.sendError(req.id, {
634
+ code: error.code ?? JsonRpcErrorCodes.InternalError,
635
+ message: error.message,
636
+ data: error.data
637
+ }).catch(() => void 0);
638
+ }
639
+ }
640
+ handleNotification(note) {
641
+ const handler = this.notificationHandlers.get(note.method);
642
+ if (handler) {
643
+ handler(note.params, note.method);
644
+ }
645
+ }
646
+ handleResponse(res) {
647
+ const pending = this.pending.get(res.id);
648
+ if (!pending) {
649
+ return;
650
+ }
651
+ this.pending.delete(res.id);
652
+ if (res.error) {
653
+ const err = new Error(res.error.message);
654
+ err.code = res.error.code;
655
+ err.data = res.error.data;
656
+ pending.reject(err);
657
+ } else {
658
+ pending.resolve(res.result);
659
+ }
660
+ }
661
+ async sendError(id, error) {
662
+ const response = {
663
+ jsonrpc: "2.0",
664
+ id,
665
+ error
666
+ };
667
+ await this.stream.send(response);
668
+ }
669
+ handleClose(err) {
670
+ if (this.closed) {
671
+ return;
672
+ }
673
+ this.closed = true;
674
+ for (const pending of this.pending.values()) {
675
+ pending.reject(err ?? new Error("connection closed"));
676
+ }
677
+ this.pending.clear();
678
+ for (const handler of this.closeHandlers) {
679
+ handler(err);
680
+ }
681
+ }
682
+ };
683
+
684
+ // src/core/agent-instance.ts
685
+ var AgentInstance = class _AgentInstance {
686
+ agentId;
687
+ cwd;
688
+ connection;
689
+ child;
690
+ exited = false;
691
+ exitHandlers = [];
692
+ constructor(opts, child) {
693
+ this.agentId = opts.agentId;
694
+ this.cwd = opts.cwd;
695
+ this.child = child;
696
+ if (!child.stdout || !child.stdin) {
697
+ throw new Error("agent subprocess missing stdio");
698
+ }
699
+ const stream = ndjsonStreamFromStdio(child.stdout, child.stdin);
700
+ this.connection = new JsonRpcConnection(stream);
701
+ child.stderr?.setEncoding("utf8");
702
+ child.stderr?.on("data", (chunk) => {
703
+ process.stderr.write(`[${opts.agentId}] ${chunk}`);
704
+ });
705
+ child.on("exit", (code, signal) => {
706
+ this.exited = true;
707
+ for (const handler of this.exitHandlers) {
708
+ handler(code, signal);
709
+ }
710
+ });
711
+ }
712
+ static spawn(opts) {
713
+ const env = {
714
+ ...process.env,
715
+ ...opts.plan.env,
716
+ ...opts.extraEnv ?? {}
717
+ };
718
+ const child = spawn(opts.plan.command, opts.plan.args, {
719
+ cwd: opts.cwd,
720
+ env,
721
+ stdio: ["pipe", "pipe", "pipe"]
722
+ });
723
+ return new _AgentInstance(opts, child);
724
+ }
725
+ onExit(handler) {
726
+ this.exitHandlers.push(handler);
727
+ }
728
+ isAlive() {
729
+ return !this.exited;
730
+ }
731
+ async kill(signal = "SIGTERM") {
732
+ if (this.exited) {
733
+ return;
734
+ }
735
+ await this.connection.close().catch(() => void 0);
736
+ this.child.kill(signal);
737
+ }
738
+ };
739
+
740
+ // src/core/session.ts
741
+ import { customAlphabet } from "nanoid";
742
+
743
+ // src/core/hydra-commands.ts
744
+ var HYDRA_COMMANDS = [
745
+ {
746
+ verb: "title",
747
+ name: "/hydra title",
748
+ description: "Regenerate the session title via the agent (or set manually with an arg)"
749
+ },
750
+ {
751
+ verb: "switch",
752
+ name: "/hydra switch",
753
+ argsHint: "<agent>",
754
+ description: "Swap the agent backing this session, preserving context"
755
+ }
756
+ ];
757
+ var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
758
+ function hydraCommandsAsAdvertised() {
759
+ return HYDRA_COMMANDS.map((c) => ({
760
+ name: c.argsHint ? `${c.name} ${c.argsHint}` : c.name,
761
+ description: c.description
762
+ }));
763
+ }
764
+
765
+ // src/core/session.ts
766
+ var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
767
+ var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
768
+ var HYDRA_SESSION_PREFIX = "hydra_session_";
769
+ var Session = class {
770
+ sessionId;
771
+ cwd;
772
+ // agent / agentId / upstreamSessionId are mutable so /hydra switch can
773
+ // replace the underlying agent process while keeping the same Session
774
+ // record. agentMeta is the metadata returned by the agent at session/new
775
+ // time; it gets refreshed on switch too.
776
+ agentId;
777
+ agent;
778
+ upstreamSessionId;
779
+ agentMeta;
780
+ agentArgs;
781
+ title;
782
+ updatedAt;
783
+ clients = /* @__PURE__ */ new Map();
784
+ history = [];
785
+ promptQueue = [];
786
+ promptInFlight = false;
787
+ closed = false;
788
+ closeHandlers = [];
789
+ titleHandlers = [];
790
+ // True once we've observed our first session/prompt; gates the
791
+ // first-prompt-seeded title so subsequent prompts don't churn it.
792
+ firstPromptSeeded = false;
793
+ // Permission requests that have been broadcast to one or more
794
+ // clients but have not yet resolved. Replayed to clients that
795
+ // attach mid-flight so a late joiner sees the prompt instead of an
796
+ // empty session waiting on something they can't see. Settled entries
797
+ // are removed so this map only ever contains live requests.
798
+ inFlightPermissions = /* @__PURE__ */ new Set();
799
+ // While set, agent-emitted session/update notifications are
800
+ // captured into `chunks` and NOT broadcast to clients. Used by
801
+ // /hydra slash-command sub-prompts (e.g. title regeneration) so
802
+ // the conversation log doesn't get polluted with the meta-prompt
803
+ // and its reply.
804
+ internalPromptCapture;
805
+ idleTimeoutMs;
806
+ idleTimer;
807
+ spawnReplacementAgent;
808
+ agentChangeHandlers = [];
809
+ // Last available_commands_update we observed from the agent. Stored so
810
+ // we can re-broadcast a merged (hydra ∪ agent) list whenever either
811
+ // half changes — most importantly when a fresh client attaches and
812
+ // replays history, since the in-cache update is the daemon's merged
813
+ // form (the agent's raw form is never broadcast).
814
+ agentAdvertisedCommands = [];
815
+ constructor(init) {
816
+ this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
817
+ this.cwd = init.cwd;
818
+ this.agentId = init.agentId;
819
+ this.agent = init.agent;
820
+ this.upstreamSessionId = init.upstreamSessionId;
821
+ this.agentMeta = init.agentMeta;
822
+ this.agentArgs = init.agentArgs;
823
+ this.title = init.title;
824
+ this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
825
+ this.spawnReplacementAgent = init.spawnReplacementAgent;
826
+ this.updatedAt = Date.now();
827
+ this.wireAgent(this.agent);
828
+ this.broadcastMergedCommands();
829
+ }
830
+ broadcastMergedCommands() {
831
+ const merged = [
832
+ ...hydraCommandsAsAdvertised(),
833
+ ...this.agentAdvertisedCommands
834
+ ];
835
+ this.recordAndBroadcast("session/update", {
836
+ sessionId: this.upstreamSessionId,
837
+ update: {
838
+ sessionUpdate: "available_commands_update",
839
+ availableCommands: merged
840
+ }
841
+ });
842
+ }
843
+ // Register session/update, session/request_permission, and onExit
844
+ // handlers on an agent connection. Re-run on every /hydra switch so
845
+ // the new agent is plumbed identically. The exit handler's identity
846
+ // check is what makes switching safe: when the *old* agent exits as
847
+ // part of a swap, this.agent has already been replaced, so we no-op
848
+ // and don't tear the session down.
849
+ wireAgent(agent) {
850
+ agent.connection.onNotification("session/update", (params) => {
851
+ if (this.internalPromptCapture) {
852
+ captureInternalChunk(this.internalPromptCapture, params);
853
+ return;
854
+ }
855
+ const agentCmds = extractAdvertisedCommands(params);
856
+ if (agentCmds !== null) {
857
+ this.agentAdvertisedCommands = agentCmds;
858
+ this.broadcastMergedCommands();
859
+ return;
860
+ }
861
+ this.maybeApplyAgentSessionInfo(params);
862
+ this.recordAndBroadcast("session/update", params);
863
+ });
864
+ agent.connection.onRequest("session/request_permission", async (params) => {
865
+ return this.handlePermissionRequest(params);
866
+ });
867
+ agent.onExit(() => {
868
+ if (this.agent !== agent) {
869
+ return;
870
+ }
871
+ this.markClosed({ deleteRecord: false });
872
+ });
873
+ }
874
+ onAgentChange(handler) {
875
+ this.agentChangeHandlers.push(handler);
876
+ }
877
+ get attachedCount() {
878
+ return this.clients.size;
879
+ }
880
+ attach(client, historyPolicy) {
881
+ if (this.closed) {
882
+ throw withCode(
883
+ new Error("session is closed"),
884
+ JsonRpcErrorCodes.SessionNotFound
885
+ );
886
+ }
887
+ if (this.clients.has(client.clientId)) {
888
+ throw withCode(
889
+ new Error(`client ${client.clientId} is already attached`),
890
+ JsonRpcErrorCodes.AlreadyAttached
891
+ );
892
+ }
893
+ this.clients.set(client.clientId, client);
894
+ this.updatedAt = Date.now();
895
+ this.cancelIdleTimer();
896
+ if (historyPolicy === "none") {
897
+ return [];
898
+ }
899
+ if (historyPolicy === "pending_only") {
900
+ return [];
901
+ }
902
+ return [...this.history];
903
+ }
904
+ // Dispatch in-flight permission requests to a freshly-attached
905
+ // client. Called by the daemon's WS handler *after* it finishes
906
+ // replaying history, so the prompt lands at the bottom of the
907
+ // transcript rather than above the conversation.
908
+ replayPendingPermissions(client) {
909
+ for (const pending of this.inFlightPermissions) {
910
+ pending.addClient(client);
911
+ }
912
+ }
913
+ detach(clientId) {
914
+ if (this.clients.delete(clientId)) {
915
+ this.updatedAt = Date.now();
916
+ this.maybeStartIdleTimer();
917
+ }
918
+ }
919
+ async prompt(clientId, params) {
920
+ const client = this.clients.get(clientId);
921
+ if (!client) {
922
+ throw withCode(
923
+ new Error("client not attached"),
924
+ JsonRpcErrorCodes.SessionNotFound
925
+ );
926
+ }
927
+ const promptText = extractPromptText(
928
+ (params ?? {}).prompt
929
+ ).trim();
930
+ if (promptText.startsWith("/hydra")) {
931
+ return this.handleSlashCommand(promptText);
932
+ }
933
+ this.broadcastPromptReceived(client, params);
934
+ this.maybeSeedTitleFromPrompt(params);
935
+ return this.enqueuePrompt(async () => {
936
+ const response = await this.agent.connection.request(
937
+ "session/prompt",
938
+ {
939
+ ...params,
940
+ sessionId: this.upstreamSessionId
941
+ }
942
+ );
943
+ this.broadcastTurnComplete(client.clientId, response);
944
+ return response;
945
+ });
946
+ }
947
+ broadcastPromptReceived(client, params) {
948
+ const promptParams = params ?? {};
949
+ const sentBy = { clientId: client.clientId };
950
+ if (client.clientInfo?.name) {
951
+ sentBy.name = client.clientInfo.name;
952
+ }
953
+ if (client.clientInfo?.version) {
954
+ sentBy.version = client.clientInfo.version;
955
+ }
956
+ this.recordAndBroadcast(
957
+ "session/update",
958
+ {
959
+ sessionId: this.sessionId,
960
+ update: {
961
+ sessionUpdate: "prompt_received",
962
+ prompt: promptParams.prompt,
963
+ sentBy
964
+ }
965
+ },
966
+ client.clientId
967
+ );
968
+ const text = extractPromptText(promptParams.prompt);
969
+ if (text.length > 0) {
970
+ this.recordAndBroadcast(
971
+ "session/update",
972
+ {
973
+ sessionId: this.sessionId,
974
+ update: {
975
+ sessionUpdate: "user_message_chunk",
976
+ content: { type: "text", text },
977
+ _meta: { "hydra-acp": { compatFor: "prompt_received" } }
978
+ }
979
+ },
980
+ client.clientId
981
+ );
982
+ }
983
+ }
984
+ broadcastTurnComplete(originatorClientId, response) {
985
+ const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
986
+ const update = {
987
+ sessionUpdate: "turn_complete"
988
+ };
989
+ if (stopReason !== void 0) {
990
+ update.stopReason = stopReason;
991
+ }
992
+ this.recordAndBroadcast(
993
+ "session/update",
994
+ {
995
+ sessionId: this.sessionId,
996
+ update
997
+ },
998
+ originatorClientId
999
+ );
1000
+ }
1001
+ async cancel(clientId) {
1002
+ const client = this.clients.get(clientId);
1003
+ if (!client) {
1004
+ throw withCode(
1005
+ new Error("client not attached"),
1006
+ JsonRpcErrorCodes.SessionNotFound
1007
+ );
1008
+ }
1009
+ await this.agent.connection.notify("session/cancel", {
1010
+ sessionId: this.upstreamSessionId
1011
+ });
1012
+ }
1013
+ async forwardRequest(method, params) {
1014
+ return this.agent.connection.request(method, this.rewriteForAgent(params));
1015
+ }
1016
+ rewriteForAgent(params) {
1017
+ if (params && typeof params === "object" && !Array.isArray(params)) {
1018
+ const obj = params;
1019
+ if (obj.sessionId === this.sessionId) {
1020
+ return { ...obj, sessionId: this.upstreamSessionId };
1021
+ }
1022
+ }
1023
+ return params;
1024
+ }
1025
+ async close(opts = {}) {
1026
+ if (this.closed) {
1027
+ return;
1028
+ }
1029
+ this.cancelIdleTimer();
1030
+ await this.agent.kill().catch(() => void 0);
1031
+ this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
1032
+ }
1033
+ onClose(handler) {
1034
+ this.closeHandlers.push(handler);
1035
+ }
1036
+ // Subscribe to title updates. The SessionManager hooks this to
1037
+ // persist the new title to disk so a daemon restart restores it.
1038
+ onTitleChange(handler) {
1039
+ this.titleHandlers.push(handler);
1040
+ }
1041
+ // Update the canonical title and broadcast a session_info_update to
1042
+ // every attached client. Clients that already speak the spec's
1043
+ // session_info_update need no hydra-specific wiring to pick this up.
1044
+ // Idempotent on identical values.
1045
+ setTitle(title) {
1046
+ const trimmed = title.trim();
1047
+ if (!trimmed || trimmed === this.title) {
1048
+ return;
1049
+ }
1050
+ this.title = trimmed;
1051
+ this.recordAndBroadcast("session/update", {
1052
+ sessionId: this.sessionId,
1053
+ update: {
1054
+ sessionUpdate: "session_info_update",
1055
+ title: trimmed,
1056
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1057
+ }
1058
+ });
1059
+ for (const handler of this.titleHandlers) {
1060
+ try {
1061
+ handler(trimmed);
1062
+ } catch {
1063
+ }
1064
+ }
1065
+ }
1066
+ // First-prompt heuristic: derive a session title from the first
1067
+ // session/prompt's text. Replaces whatever was set at session/new
1068
+ // (typically an editor frame name like "Claude Agent @ hydra-acp")
1069
+ // — the first prompt is a better summary for cross-client display
1070
+ // than the editor's static frame label. Subsequent prompts don't
1071
+ // touch the title; that'd flap as conversations evolved.
1072
+ maybeSeedTitleFromPrompt(params) {
1073
+ if (this.firstPromptSeeded) {
1074
+ return;
1075
+ }
1076
+ const promptParams = params ?? {};
1077
+ const text = extractPromptText(promptParams.prompt);
1078
+ const seed = firstLine(text, 80);
1079
+ if (!seed) {
1080
+ return;
1081
+ }
1082
+ this.firstPromptSeeded = true;
1083
+ this.setTitle(seed);
1084
+ }
1085
+ // Pick up an agent-emitted session_info_update and store its title
1086
+ // as our canonical record. The notification is also forwarded to
1087
+ // clients via the surrounding recordAndBroadcast call. Authoritative
1088
+ // — overrides our placeholder.
1089
+ maybeApplyAgentSessionInfo(params) {
1090
+ const obj = params ?? {};
1091
+ const update = obj.update ?? {};
1092
+ if (update.sessionUpdate !== "session_info_update") {
1093
+ return;
1094
+ }
1095
+ if (typeof update.title !== "string") {
1096
+ return;
1097
+ }
1098
+ const trimmed = update.title.trim();
1099
+ if (!trimmed || trimmed === this.title) {
1100
+ return;
1101
+ }
1102
+ this.title = trimmed;
1103
+ this.firstPromptSeeded = true;
1104
+ for (const handler of this.titleHandlers) {
1105
+ try {
1106
+ handler(trimmed);
1107
+ } catch {
1108
+ }
1109
+ }
1110
+ }
1111
+ // Dispatch a /hydra <verb> [args] slash command typed in any client's
1112
+ // composer. Returns a synthesized session/prompt response so the
1113
+ // caller's promise resolves like a normal turn. To add a verb: append
1114
+ // an entry to HYDRA_COMMANDS (drives validation + client advertising)
1115
+ // and a dispatch case in the switch below.
1116
+ async handleSlashCommand(text) {
1117
+ const rest = text.slice("/hydra".length).trim();
1118
+ const match = rest.match(/^(\S+)(?:\s+([\s\S]*))?$/);
1119
+ const verb = match?.[1] ?? "";
1120
+ const arg = (match?.[2] ?? "").trim();
1121
+ if (verb === "") {
1122
+ return { stopReason: "end_turn" };
1123
+ }
1124
+ if (!HYDRA_COMMANDS.some((c) => c.verb === verb)) {
1125
+ const known = HYDRA_COMMANDS.map((c) => c.verb).join(", ");
1126
+ const err = new Error(
1127
+ `unknown /hydra verb: ${verb} (known: ${known})`
1128
+ );
1129
+ err.code = JsonRpcErrorCodes.InvalidParams;
1130
+ throw err;
1131
+ }
1132
+ switch (verb) {
1133
+ case "title":
1134
+ return this.runTitleCommand(arg);
1135
+ case "switch":
1136
+ return this.runSwitchCommand(arg);
1137
+ default: {
1138
+ const err = new Error(
1139
+ `no dispatcher for /hydra verb ${verb}`
1140
+ );
1141
+ err.code = JsonRpcErrorCodes.InternalError;
1142
+ throw err;
1143
+ }
1144
+ }
1145
+ }
1146
+ // Runs as a normal queued prompt (so it serializes after any in-flight
1147
+ // turn). With an arg, sets the title directly. Without one, runs a
1148
+ // suppressed sub-prompt to the agent and uses its reply as the title.
1149
+ // Either path ends with setTitle, which broadcasts session_info_update
1150
+ // — that's what every other client observes.
1151
+ runTitleCommand(arg) {
1152
+ return this.enqueuePrompt(async () => {
1153
+ if (arg) {
1154
+ this.setTitle(arg);
1155
+ return { stopReason: "end_turn" };
1156
+ }
1157
+ return this.runTitleRegen();
1158
+ });
1159
+ }
1160
+ async runTitleRegen() {
1161
+ const collected = await this.runInternalPrompt(
1162
+ "Reply with ONLY a short title (\u226480 chars) summarizing this conversation so far. No quotes, no markdown, no explanation."
1163
+ );
1164
+ const title = firstLine(collected.trim(), 80);
1165
+ if (title) {
1166
+ this.setTitle(title);
1167
+ }
1168
+ return { stopReason: "end_turn" };
1169
+ }
1170
+ // Send a prompt to the underlying agent and capture its reply chunks
1171
+ // privately (no fan-out to clients, no recording into history). Used
1172
+ // by /hydra title's regen path and /hydra switch's transcript-injection
1173
+ // path. Returns the joined agent_message_chunk text.
1174
+ async runInternalPrompt(text) {
1175
+ if (this.internalPromptCapture) {
1176
+ throw new Error("internal prompt already in flight");
1177
+ }
1178
+ const capture = { chunks: [] };
1179
+ this.internalPromptCapture = capture;
1180
+ try {
1181
+ await this.agent.connection.request("session/prompt", {
1182
+ sessionId: this.upstreamSessionId,
1183
+ prompt: [{ type: "text", text }]
1184
+ });
1185
+ return capture.chunks.join("");
1186
+ } finally {
1187
+ this.internalPromptCapture = void 0;
1188
+ }
1189
+ }
1190
+ // Swap the underlying agent process while keeping the same Session
1191
+ // record. Spawns the new agent first so a failure leaves the old one
1192
+ // intact; then injects a synthesized transcript so the new agent has
1193
+ // context for the next turn.
1194
+ runSwitchCommand(newAgentId) {
1195
+ if (!newAgentId) {
1196
+ throw withCode(
1197
+ new Error("/hydra switch requires an agent id"),
1198
+ JsonRpcErrorCodes.InvalidParams
1199
+ );
1200
+ }
1201
+ if (newAgentId === this.agentId) {
1202
+ throw withCode(
1203
+ new Error(`already on agent ${newAgentId}`),
1204
+ JsonRpcErrorCodes.InvalidParams
1205
+ );
1206
+ }
1207
+ if (!this.spawnReplacementAgent) {
1208
+ throw withCode(
1209
+ new Error("agent switching not configured for this session"),
1210
+ JsonRpcErrorCodes.InternalError
1211
+ );
1212
+ }
1213
+ const spawnAgent = this.spawnReplacementAgent;
1214
+ return this.enqueuePrompt(async () => {
1215
+ const oldAgentId = this.agentId;
1216
+ const transcript = this.buildSwitchTranscript(oldAgentId);
1217
+ const fresh = await spawnAgent({
1218
+ agentId: newAgentId,
1219
+ cwd: this.cwd,
1220
+ agentArgs: this.agentArgs
1221
+ });
1222
+ this.wireAgent(fresh.agent);
1223
+ const oldAgent = this.agent;
1224
+ this.agent = fresh.agent;
1225
+ this.agentId = newAgentId;
1226
+ this.upstreamSessionId = fresh.upstreamSessionId;
1227
+ this.agentMeta = fresh.agentMeta;
1228
+ this.agentAdvertisedCommands = [];
1229
+ this.broadcastMergedCommands();
1230
+ await oldAgent.kill().catch(() => void 0);
1231
+ if (transcript) {
1232
+ await this.runInternalPrompt(transcript).catch(() => void 0);
1233
+ }
1234
+ this.broadcastAgentSwitch(oldAgentId, newAgentId);
1235
+ const info = {
1236
+ agentId: this.agentId,
1237
+ upstreamSessionId: this.upstreamSessionId
1238
+ };
1239
+ for (const handler of this.agentChangeHandlers) {
1240
+ try {
1241
+ handler(info);
1242
+ } catch {
1243
+ }
1244
+ }
1245
+ return { stopReason: "end_turn" };
1246
+ });
1247
+ }
1248
+ // Walk this.history (rewritten-for-clients notification cache) and
1249
+ // produce a labeled transcript suitable for handing to a fresh agent.
1250
+ // Includes user prompts, agent replies, and tool-call outcomes; skips
1251
+ // hydra-synthesized markers (so multi-hop switches don't accumulate
1252
+ // banners) and other update kinds we don't think the next agent
1253
+ // benefits from re-seeing (plans, thoughts, mode/model/usage).
1254
+ buildSwitchTranscript(prevAgentId) {
1255
+ const lines = [];
1256
+ for (const note of this.history) {
1257
+ if (note.method !== "session/update") {
1258
+ continue;
1259
+ }
1260
+ const params = note.params ?? {};
1261
+ const update = params.update;
1262
+ if (!update) {
1263
+ continue;
1264
+ }
1265
+ const meta = update._meta;
1266
+ if (meta?.["hydra-acp"]?.synthetic) {
1267
+ continue;
1268
+ }
1269
+ const kind = update.sessionUpdate;
1270
+ if (kind === "prompt_received") {
1271
+ const text = extractPromptText(update.prompt);
1272
+ if (text) {
1273
+ lines.push({ speaker: "user", text });
1274
+ }
1275
+ } else if (kind === "agent_message_chunk") {
1276
+ const content = update.content;
1277
+ const text = content?.text;
1278
+ if (text) {
1279
+ lines.push({ speaker: `agent: ${prevAgentId}`, text });
1280
+ }
1281
+ } else if (kind === "tool_call" || kind === "tool_call_update") {
1282
+ const status = update.status;
1283
+ const title = update.title;
1284
+ if (status === "completed" || status === "failed") {
1285
+ lines.push({
1286
+ speaker: "tool",
1287
+ text: `${title ?? "?"} ${status}`
1288
+ });
1289
+ }
1290
+ }
1291
+ }
1292
+ if (lines.length === 0) {
1293
+ return "";
1294
+ }
1295
+ const coalesced = [];
1296
+ let current;
1297
+ for (const line of lines) {
1298
+ if (current && current.speaker === line.speaker) {
1299
+ current.text += line.text;
1300
+ } else {
1301
+ if (current) {
1302
+ coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1303
+ }
1304
+ current = { speaker: line.speaker, text: line.text };
1305
+ }
1306
+ }
1307
+ if (current) {
1308
+ coalesced.push(`<${current.speaker}>: ${current.text.trim()}`);
1309
+ }
1310
+ return [
1311
+ `You are taking over this conversation from ${prevAgentId}. Below is the transcript so far.`,
1312
+ `Each line is prefixed with its speaker. Continue from where ${prevAgentId} left off, responding to the user's most recent message.`,
1313
+ "",
1314
+ "--- begin transcript ---",
1315
+ ...coalesced,
1316
+ "--- end transcript ---"
1317
+ ].join("\n");
1318
+ }
1319
+ // Tell every attached client (a) the agent identity has changed
1320
+ // (session_info_update with an agentId field — clients that already
1321
+ // listen for title updates pick this up; older clients ignore unknown
1322
+ // fields harmlessly) and (b) drop a visible banner into the transcript
1323
+ // so users see the switch rather than just suddenly getting answers
1324
+ // from a different agent. Both updates carry _meta["hydra-acp"].synthetic
1325
+ // so a future /hydra switch's transcript builder filters them out.
1326
+ broadcastAgentSwitch(oldAgentId, newAgentId) {
1327
+ this.recordAndBroadcast("session/update", {
1328
+ sessionId: this.sessionId,
1329
+ update: {
1330
+ sessionUpdate: "session_info_update",
1331
+ agentId: newAgentId,
1332
+ _meta: { "hydra-acp": { synthetic: true } }
1333
+ }
1334
+ });
1335
+ this.recordAndBroadcast("session/update", {
1336
+ sessionId: this.sessionId,
1337
+ update: {
1338
+ sessionUpdate: "agent_message_chunk",
1339
+ content: {
1340
+ type: "text",
1341
+ text: `
1342
+ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1343
+ `
1344
+ },
1345
+ _meta: { "hydra-acp": { synthetic: true } }
1346
+ }
1347
+ });
1348
+ }
1349
+ markClosed(opts) {
1350
+ if (this.closed) {
1351
+ return;
1352
+ }
1353
+ this.closed = true;
1354
+ this.cancelIdleTimer();
1355
+ for (const client of this.clients.values()) {
1356
+ void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
1357
+ }
1358
+ this.clients.clear();
1359
+ for (const handler of this.closeHandlers) {
1360
+ handler(opts);
1361
+ }
1362
+ }
1363
+ maybeStartIdleTimer() {
1364
+ if (this.closed || this.clients.size > 0 || this.idleTimeoutMs <= 0) {
1365
+ return;
1366
+ }
1367
+ if (this.idleTimer) {
1368
+ return;
1369
+ }
1370
+ this.idleTimer = setTimeout(() => {
1371
+ this.idleTimer = void 0;
1372
+ void this.close({ deleteRecord: false }).catch(() => void 0);
1373
+ }, this.idleTimeoutMs);
1374
+ if (typeof this.idleTimer.unref === "function") {
1375
+ this.idleTimer.unref();
1376
+ }
1377
+ }
1378
+ cancelIdleTimer() {
1379
+ if (this.idleTimer) {
1380
+ clearTimeout(this.idleTimer);
1381
+ this.idleTimer = void 0;
1382
+ }
1383
+ }
1384
+ rewriteForClient(params) {
1385
+ if (params && typeof params === "object" && !Array.isArray(params)) {
1386
+ const obj = params;
1387
+ if (obj.sessionId === this.upstreamSessionId) {
1388
+ return { ...obj, sessionId: this.sessionId };
1389
+ }
1390
+ }
1391
+ return params;
1392
+ }
1393
+ recordAndBroadcast(method, params, excludeClientId) {
1394
+ const rewritten = this.rewriteForClient(params);
1395
+ this.history.push({ method, params: rewritten, recordedAt: Date.now() });
1396
+ if (this.history.length > 1e3) {
1397
+ this.history = this.history.slice(-500);
1398
+ }
1399
+ this.updatedAt = Date.now();
1400
+ for (const client of this.clients.values()) {
1401
+ if (excludeClientId && client.clientId === excludeClientId) {
1402
+ continue;
1403
+ }
1404
+ void client.connection.notify(method, rewritten).catch(() => void 0);
1405
+ }
1406
+ }
1407
+ async handlePermissionRequest(params) {
1408
+ const initialClients = [...this.clients.values()];
1409
+ if (initialClients.length === 0) {
1410
+ throw withCode(
1411
+ new Error("no clients attached to handle permission request"),
1412
+ JsonRpcErrorCodes.PermissionDenied
1413
+ );
1414
+ }
1415
+ const clientParams = this.rewriteForClient(params);
1416
+ return new Promise((resolve2, reject) => {
1417
+ let settled = false;
1418
+ const outbound = [];
1419
+ const entry = { addClient: sendTo };
1420
+ this.inFlightPermissions.add(entry);
1421
+ const settle = (fn) => {
1422
+ if (settled) {
1423
+ return;
1424
+ }
1425
+ settled = true;
1426
+ this.inFlightPermissions.delete(entry);
1427
+ fn();
1428
+ };
1429
+ function sendTo(client) {
1430
+ if (settled) {
1431
+ return;
1432
+ }
1433
+ const { id, response } = client.connection.requestWithId(
1434
+ "session/request_permission",
1435
+ clientParams
1436
+ );
1437
+ outbound.push({ client, id });
1438
+ void response.then((result) => {
1439
+ settle(() => {
1440
+ for (const o of outbound) {
1441
+ if (o.client.clientId === client.clientId) {
1442
+ continue;
1443
+ }
1444
+ void o.client.connection.notify("session/permission_resolved", {
1445
+ ...clientParams,
1446
+ requestId: o.id,
1447
+ resolvedBy: client.clientId,
1448
+ result
1449
+ }).catch(() => void 0);
1450
+ }
1451
+ resolve2(result);
1452
+ });
1453
+ }).catch((err) => {
1454
+ settle(() => reject(err));
1455
+ });
1456
+ }
1457
+ for (const client of initialClients) {
1458
+ sendTo(client);
1459
+ }
1460
+ });
1461
+ }
1462
+ async enqueuePrompt(task) {
1463
+ return new Promise((resolve2, reject) => {
1464
+ const run = async () => {
1465
+ try {
1466
+ const result = await task();
1467
+ resolve2(result);
1468
+ } catch (err) {
1469
+ reject(err);
1470
+ }
1471
+ };
1472
+ this.promptQueue.push(run);
1473
+ void this.drainQueue();
1474
+ });
1475
+ }
1476
+ async drainQueue() {
1477
+ if (this.promptInFlight) {
1478
+ return;
1479
+ }
1480
+ this.promptInFlight = true;
1481
+ try {
1482
+ while (this.promptQueue.length > 0) {
1483
+ const next = this.promptQueue.shift();
1484
+ if (next) {
1485
+ await next();
1486
+ }
1487
+ }
1488
+ } finally {
1489
+ this.promptInFlight = false;
1490
+ }
1491
+ }
1492
+ };
1493
+ function withCode(err, code) {
1494
+ err.code = code;
1495
+ return err;
1496
+ }
1497
+ function captureInternalChunk(capture, params) {
1498
+ const obj = params ?? {};
1499
+ const update = obj.update ?? {};
1500
+ if (update.sessionUpdate !== "agent_message_chunk") {
1501
+ return;
1502
+ }
1503
+ const content = update.content ?? {};
1504
+ if (typeof content.text === "string") {
1505
+ capture.chunks.push(content.text);
1506
+ }
1507
+ }
1508
+ function extractAdvertisedCommands(params) {
1509
+ const obj = params ?? {};
1510
+ const update = obj.update ?? {};
1511
+ if (update.sessionUpdate !== "available_commands_update") {
1512
+ return null;
1513
+ }
1514
+ const list = update.availableCommands ?? update.commands;
1515
+ if (!Array.isArray(list)) {
1516
+ return [];
1517
+ }
1518
+ const out = [];
1519
+ for (const raw of list) {
1520
+ if (!raw || typeof raw !== "object") {
1521
+ continue;
1522
+ }
1523
+ const c = raw;
1524
+ if (typeof c.name !== "string" || c.name.length === 0) {
1525
+ continue;
1526
+ }
1527
+ const cmd = { name: c.name };
1528
+ if (typeof c.description === "string") {
1529
+ cmd.description = c.description;
1530
+ }
1531
+ out.push(cmd);
1532
+ }
1533
+ return out;
1534
+ }
1535
+ function extractPromptText(prompt) {
1536
+ if (typeof prompt === "string") {
1537
+ return prompt;
1538
+ }
1539
+ if (!Array.isArray(prompt)) {
1540
+ return "";
1541
+ }
1542
+ return prompt.map((b) => {
1543
+ if (b && typeof b === "object" && typeof b.text === "string") {
1544
+ return b.text;
1545
+ }
1546
+ return "";
1547
+ }).join("");
1548
+ }
1549
+ function firstLine(text, max) {
1550
+ for (const raw of text.split(/\r?\n/)) {
1551
+ const line = raw.trim();
1552
+ if (!line) {
1553
+ continue;
1554
+ }
1555
+ return line.length > max ? `${line.slice(0, max)}\u2026` : line;
1556
+ }
1557
+ return void 0;
1558
+ }
1559
+
1560
+ // src/core/session-store.ts
1561
+ import * as fs3 from "fs/promises";
1562
+ import * as path2 from "path";
1563
+ import { z as z4 } from "zod";
1564
+ var SessionRecord = z4.object({
1565
+ version: z4.literal(1),
1566
+ sessionId: z4.string(),
1567
+ upstreamSessionId: z4.string(),
1568
+ agentId: z4.string(),
1569
+ cwd: z4.string(),
1570
+ title: z4.string().optional(),
1571
+ agentArgs: z4.array(z4.string()).optional(),
1572
+ createdAt: z4.string(),
1573
+ updatedAt: z4.string()
1574
+ });
1575
+ var SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
1576
+ function assertSafeId(id) {
1577
+ if (!SESSION_ID_PATTERN.test(id)) {
1578
+ throw new Error(`unsafe session id: ${id}`);
1579
+ }
1580
+ }
1581
+ var SessionStore = class {
1582
+ async write(record) {
1583
+ assertSafeId(record.sessionId);
1584
+ await fs3.mkdir(paths.sessionsDir(), { recursive: true });
1585
+ const full = { version: 1, ...record };
1586
+ await fs3.writeFile(
1587
+ paths.sessionFile(record.sessionId),
1588
+ JSON.stringify(full, null, 2) + "\n",
1589
+ { encoding: "utf8", mode: 384 }
1590
+ );
1591
+ }
1592
+ async read(sessionId) {
1593
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
1594
+ return void 0;
1595
+ }
1596
+ let raw;
1597
+ try {
1598
+ raw = await fs3.readFile(paths.sessionFile(sessionId), "utf8");
1599
+ } catch (err) {
1600
+ const e = err;
1601
+ if (e.code === "ENOENT") {
1602
+ return void 0;
1603
+ }
1604
+ throw err;
1605
+ }
1606
+ try {
1607
+ return SessionRecord.parse(JSON.parse(raw));
1608
+ } catch {
1609
+ return void 0;
1610
+ }
1611
+ }
1612
+ async delete(sessionId) {
1613
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
1614
+ return;
1615
+ }
1616
+ try {
1617
+ await fs3.unlink(paths.sessionFile(sessionId));
1618
+ } catch (err) {
1619
+ const e = err;
1620
+ if (e.code !== "ENOENT") {
1621
+ throw err;
1622
+ }
1623
+ }
1624
+ }
1625
+ async list() {
1626
+ let entries;
1627
+ try {
1628
+ entries = await fs3.readdir(paths.sessionsDir());
1629
+ } catch (err) {
1630
+ const e = err;
1631
+ if (e.code === "ENOENT") {
1632
+ return [];
1633
+ }
1634
+ throw err;
1635
+ }
1636
+ const records = [];
1637
+ for (const entry of entries) {
1638
+ if (!entry.endsWith(".json")) {
1639
+ continue;
1640
+ }
1641
+ const id = entry.slice(0, -".json".length);
1642
+ const record = await this.read(id);
1643
+ if (record) {
1644
+ records.push(record);
1645
+ }
1646
+ }
1647
+ return records;
1648
+ }
1649
+ };
1650
+ function recordFromMemorySession(args) {
1651
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1652
+ return {
1653
+ sessionId: args.sessionId,
1654
+ upstreamSessionId: args.upstreamSessionId,
1655
+ agentId: args.agentId,
1656
+ cwd: args.cwd,
1657
+ title: args.title,
1658
+ agentArgs: args.agentArgs,
1659
+ createdAt: args.createdAt ?? now,
1660
+ updatedAt: args.updatedAt ?? now
1661
+ };
1662
+ }
1663
+
1664
+ // src/core/session-manager.ts
1665
+ var SessionManager = class {
1666
+ constructor(registry, spawner, store, options = {}) {
1667
+ this.registry = registry;
1668
+ this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
1669
+ this.store = store ?? new SessionStore();
1670
+ this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
1671
+ }
1672
+ registry;
1673
+ sessions = /* @__PURE__ */ new Map();
1674
+ resurrectionInflight = /* @__PURE__ */ new Map();
1675
+ spawner;
1676
+ store;
1677
+ idleTimeoutMs;
1678
+ async create(params) {
1679
+ const fresh = await this.bootstrapAgent({
1680
+ agentId: params.agentId,
1681
+ cwd: params.cwd,
1682
+ agentArgs: params.agentArgs,
1683
+ mcpServers: params.mcpServers
1684
+ });
1685
+ const session = new Session({
1686
+ cwd: params.cwd,
1687
+ agentId: params.agentId,
1688
+ agent: fresh.agent,
1689
+ upstreamSessionId: fresh.upstreamSessionId,
1690
+ agentMeta: fresh.agentMeta,
1691
+ title: params.title,
1692
+ agentArgs: params.agentArgs,
1693
+ idleTimeoutMs: this.idleTimeoutMs,
1694
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
1695
+ });
1696
+ await this.attachManagerHooks(session);
1697
+ return session;
1698
+ }
1699
+ async resurrect(params) {
1700
+ const existing = this.sessions.get(params.hydraSessionId);
1701
+ if (existing) {
1702
+ if (existing.upstreamSessionId !== params.upstreamSessionId) {
1703
+ const err = new Error(
1704
+ `session ${params.hydraSessionId} already exists with a different upstream id`
1705
+ );
1706
+ err.code = JsonRpcErrorCodes.AlreadyAttached;
1707
+ throw err;
1708
+ }
1709
+ return existing;
1710
+ }
1711
+ const inflight = this.resurrectionInflight.get(params.hydraSessionId);
1712
+ if (inflight) {
1713
+ return inflight;
1714
+ }
1715
+ const promise = this.doResurrect(params);
1716
+ this.resurrectionInflight.set(params.hydraSessionId, promise);
1717
+ try {
1718
+ return await promise;
1719
+ } finally {
1720
+ this.resurrectionInflight.delete(params.hydraSessionId);
1721
+ }
1722
+ }
1723
+ async doResurrect(params) {
1724
+ const existing = this.sessions.get(params.hydraSessionId);
1725
+ if (existing) {
1726
+ return existing;
1727
+ }
1728
+ const agentDef = await this.registry.getAgent(params.agentId);
1729
+ if (!agentDef) {
1730
+ const err = new Error(
1731
+ `agent ${params.agentId} not found in registry; cannot resurrect`
1732
+ );
1733
+ err.code = JsonRpcErrorCodes.AgentNotInstalled;
1734
+ throw err;
1735
+ }
1736
+ const plan = planSpawn(agentDef, params.agentArgs ?? []);
1737
+ const agent = this.spawner({
1738
+ agentId: params.agentId,
1739
+ cwd: params.cwd,
1740
+ plan
1741
+ });
1742
+ await agent.connection.request("initialize", {
1743
+ protocolVersion: 1,
1744
+ clientCapabilities: {},
1745
+ clientInfo: { name: "hydra", version: "0.1.0" }
1746
+ });
1747
+ let loadResult;
1748
+ try {
1749
+ loadResult = await agent.connection.request("session/load", {
1750
+ sessionId: params.upstreamSessionId,
1751
+ cwd: params.cwd,
1752
+ mcpServers: []
1753
+ });
1754
+ } catch (err) {
1755
+ await agent.kill().catch(() => void 0);
1756
+ throw new Error(
1757
+ `agent ${params.agentId} failed to load upstream session ${params.upstreamSessionId}: ${err.message}`
1758
+ );
1759
+ }
1760
+ const session = new Session({
1761
+ sessionId: params.hydraSessionId,
1762
+ cwd: params.cwd,
1763
+ agentId: params.agentId,
1764
+ agent,
1765
+ upstreamSessionId: params.upstreamSessionId,
1766
+ agentMeta: loadResult?._meta,
1767
+ title: params.title,
1768
+ agentArgs: params.agentArgs,
1769
+ idleTimeoutMs: this.idleTimeoutMs,
1770
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
1771
+ });
1772
+ await this.attachManagerHooks(session);
1773
+ return session;
1774
+ }
1775
+ // Bootstrap a fresh agent process: registry resolve → spawn → initialize
1776
+ // → session/new. Shared by create() and the /hydra switch path so both
1777
+ // go through the same env / capabilities / error-handling.
1778
+ async bootstrapAgent(params) {
1779
+ const agentDef = await this.registry.getAgent(params.agentId);
1780
+ if (!agentDef) {
1781
+ const err = new Error(
1782
+ `agent ${params.agentId} not found in registry`
1783
+ );
1784
+ err.code = JsonRpcErrorCodes.AgentNotInstalled;
1785
+ throw err;
1786
+ }
1787
+ const plan = planSpawn(agentDef, params.agentArgs ?? []);
1788
+ const agent = this.spawner({
1789
+ agentId: params.agentId,
1790
+ cwd: params.cwd,
1791
+ plan
1792
+ });
1793
+ try {
1794
+ await agent.connection.request("initialize", {
1795
+ protocolVersion: 1,
1796
+ clientCapabilities: {},
1797
+ clientInfo: { name: "hydra", version: "0.1.0" }
1798
+ });
1799
+ const newResult = await agent.connection.request("session/new", {
1800
+ cwd: params.cwd,
1801
+ mcpServers: params.mcpServers ?? []
1802
+ });
1803
+ return {
1804
+ agent,
1805
+ upstreamSessionId: newResult.sessionId,
1806
+ agentMeta: newResult._meta
1807
+ };
1808
+ } catch (err) {
1809
+ await agent.kill().catch(() => void 0);
1810
+ throw err;
1811
+ }
1812
+ }
1813
+ // Hooks that bridge a Session into the manager's persistence/listing
1814
+ // bookkeeping. Called from both create() and resurrect() so the same
1815
+ // session record + lifecycle handlers are wired regardless of origin.
1816
+ // Returns once the initial disk record is written — callers should
1817
+ // await so a subsequent /hydra switch's persistAgentChange (which
1818
+ // does read-then-write) finds the file in place.
1819
+ async attachManagerHooks(session) {
1820
+ session.onClose(({ deleteRecord }) => {
1821
+ this.sessions.delete(session.sessionId);
1822
+ if (deleteRecord) {
1823
+ void this.store.delete(session.sessionId).catch(() => void 0);
1824
+ }
1825
+ });
1826
+ session.onTitleChange((title) => {
1827
+ void this.persistTitle(session.sessionId, title).catch(() => void 0);
1828
+ });
1829
+ session.onAgentChange(({ agentId, upstreamSessionId }) => {
1830
+ void this.persistAgentChange(session.sessionId, agentId, upstreamSessionId).catch(
1831
+ () => void 0
1832
+ );
1833
+ });
1834
+ this.sessions.set(session.sessionId, session);
1835
+ await this.store.write(
1836
+ recordFromMemorySession({
1837
+ sessionId: session.sessionId,
1838
+ upstreamSessionId: session.upstreamSessionId,
1839
+ agentId: session.agentId,
1840
+ cwd: session.cwd,
1841
+ title: session.title,
1842
+ agentArgs: session.agentArgs
1843
+ })
1844
+ ).catch(() => void 0);
1845
+ }
1846
+ async loadFromDisk(sessionId) {
1847
+ const record = await this.store.read(sessionId);
1848
+ if (!record) {
1849
+ return void 0;
1850
+ }
1851
+ return {
1852
+ hydraSessionId: record.sessionId,
1853
+ upstreamSessionId: record.upstreamSessionId,
1854
+ agentId: record.agentId,
1855
+ cwd: record.cwd,
1856
+ title: record.title,
1857
+ agentArgs: record.agentArgs
1858
+ };
1859
+ }
1860
+ get(sessionId) {
1861
+ return this.sessions.get(sessionId);
1862
+ }
1863
+ // Resolve a user-typed session id (which may have the hydra_session_
1864
+ // prefix stripped — that's what `sessions list` and the picker show) to
1865
+ // the canonical form that actually exists. Tries the input as-given
1866
+ // first, then with the prefix prepended. Returns undefined if neither
1867
+ // form resolves to a live or stored session. Foreign ids (anything not
1868
+ // following our prefix convention) pass through via the first lookup.
1869
+ async resolveCanonicalId(input) {
1870
+ if (this.sessions.has(input) || await this.store.read(input)) {
1871
+ return input;
1872
+ }
1873
+ if (input.startsWith(HYDRA_SESSION_PREFIX)) {
1874
+ return void 0;
1875
+ }
1876
+ const prefixed = HYDRA_SESSION_PREFIX + input;
1877
+ if (this.sessions.has(prefixed) || await this.store.read(prefixed)) {
1878
+ return prefixed;
1879
+ }
1880
+ return void 0;
1881
+ }
1882
+ require(sessionId) {
1883
+ const session = this.sessions.get(sessionId);
1884
+ if (!session) {
1885
+ const err = new Error(`session ${sessionId} not found`);
1886
+ err.code = JsonRpcErrorCodes.SessionNotFound;
1887
+ throw err;
1888
+ }
1889
+ return session;
1890
+ }
1891
+ async list(filter = {}) {
1892
+ const entries = [];
1893
+ const liveIds = /* @__PURE__ */ new Set();
1894
+ for (const session of this.sessions.values()) {
1895
+ if (filter.cwd && session.cwd !== filter.cwd) {
1896
+ continue;
1897
+ }
1898
+ liveIds.add(session.sessionId);
1899
+ entries.push({
1900
+ sessionId: session.sessionId,
1901
+ upstreamSessionId: session.upstreamSessionId,
1902
+ cwd: session.cwd,
1903
+ title: session.title,
1904
+ agentId: session.agentId,
1905
+ updatedAt: new Date(session.updatedAt).toISOString(),
1906
+ attachedClients: session.attachedCount,
1907
+ status: "live"
1908
+ });
1909
+ }
1910
+ const records = await this.store.list().catch(() => []);
1911
+ for (const r of records) {
1912
+ if (liveIds.has(r.sessionId)) {
1913
+ continue;
1914
+ }
1915
+ if (filter.cwd && r.cwd !== filter.cwd) {
1916
+ continue;
1917
+ }
1918
+ entries.push({
1919
+ sessionId: r.sessionId,
1920
+ upstreamSessionId: r.upstreamSessionId,
1921
+ cwd: r.cwd,
1922
+ title: r.title,
1923
+ agentId: r.agentId,
1924
+ updatedAt: r.updatedAt,
1925
+ attachedClients: 0,
1926
+ status: "cold"
1927
+ });
1928
+ }
1929
+ entries.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : -1);
1930
+ return entries;
1931
+ }
1932
+ async deleteRecord(sessionId) {
1933
+ const record = await this.store.read(sessionId);
1934
+ if (!record) {
1935
+ return false;
1936
+ }
1937
+ await this.store.delete(sessionId).catch(() => void 0);
1938
+ return true;
1939
+ }
1940
+ // Persist a title update from Session.setTitle. The on-disk record
1941
+ // was written at create time; updating it here keeps the session
1942
+ // record's title in sync with what was broadcast to clients so a
1943
+ // daemon restart (and later resurrect) restores the same title.
1944
+ async persistTitle(sessionId, title) {
1945
+ const record = await this.store.read(sessionId);
1946
+ if (!record) {
1947
+ return;
1948
+ }
1949
+ await this.store.write({
1950
+ ...record,
1951
+ title,
1952
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1953
+ });
1954
+ }
1955
+ // Persist an agent swap from /hydra switch. The on-disk record's
1956
+ // agentId + upstreamSessionId both rotate so a daemon restart (and
1957
+ // later resurrect) brings the session back up on the agent the user
1958
+ // most recently switched to, not the one it was originally created on.
1959
+ async persistAgentChange(sessionId, agentId, upstreamSessionId) {
1960
+ const record = await this.store.read(sessionId);
1961
+ if (!record) {
1962
+ return;
1963
+ }
1964
+ await this.store.write({
1965
+ ...record,
1966
+ agentId,
1967
+ upstreamSessionId,
1968
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1969
+ });
1970
+ }
1971
+ async closeAll() {
1972
+ const sessions = [...this.sessions.values()];
1973
+ await Promise.allSettled(sessions.map((s) => s.close()));
1974
+ this.sessions.clear();
1975
+ }
1976
+ };
1977
+
1978
+ // src/core/extensions.ts
1979
+ import { spawn as spawn2 } from "child_process";
1980
+ import * as fs4 from "fs";
1981
+ import * as fsp from "fs/promises";
1982
+ import * as path3 from "path";
1983
+ var RESTART_BASE_MS = 1e3;
1984
+ var RESTART_CAP_MS = 6e4;
1985
+ var STOP_GRACE_MS = 3e3;
1986
+ var ExtensionManager = class {
1987
+ entries = /* @__PURE__ */ new Map();
1988
+ stopping = false;
1989
+ context;
1990
+ constructor(extensions, context) {
1991
+ this.context = context;
1992
+ for (const ext of extensions) {
1993
+ this.entries.set(ext.name, this.makeEntry(ext));
1994
+ }
1995
+ }
1996
+ setContext(context) {
1997
+ this.context = context;
1998
+ }
1999
+ async start() {
2000
+ if (!this.context) {
2001
+ throw new Error("ExtensionManager: setContext must be called before start");
2002
+ }
2003
+ await fsp.mkdir(paths.extensionsDir(), { recursive: true });
2004
+ await this.reapOrphans();
2005
+ for (const entry of this.entries.values()) {
2006
+ if (!entry.config.enabled) {
2007
+ continue;
2008
+ }
2009
+ this.spawn(entry, 0);
2010
+ }
2011
+ }
2012
+ async stop() {
2013
+ this.stopping = true;
2014
+ const tasks = [];
2015
+ for (const entry of this.entries.values()) {
2016
+ if (entry.restartTimer) {
2017
+ clearTimeout(entry.restartTimer);
2018
+ entry.restartTimer = void 0;
2019
+ }
2020
+ const child = entry.child;
2021
+ if (!child) {
2022
+ continue;
2023
+ }
2024
+ try {
2025
+ child.kill("SIGTERM");
2026
+ } catch {
2027
+ }
2028
+ tasks.push(
2029
+ new Promise((resolve2) => {
2030
+ if (child.exitCode !== null || child.signalCode !== null) {
2031
+ resolve2();
2032
+ return;
2033
+ }
2034
+ const timer = setTimeout(() => {
2035
+ try {
2036
+ child.kill("SIGKILL");
2037
+ } catch {
2038
+ }
2039
+ resolve2();
2040
+ }, STOP_GRACE_MS);
2041
+ child.on("exit", () => {
2042
+ clearTimeout(timer);
2043
+ resolve2();
2044
+ });
2045
+ })
2046
+ );
2047
+ }
2048
+ await Promise.allSettled(tasks);
2049
+ for (const entry of this.entries.values()) {
2050
+ try {
2051
+ entry.logStream?.end();
2052
+ } catch {
2053
+ }
2054
+ entry.child = void 0;
2055
+ entry.logStream = void 0;
2056
+ entry.pid = void 0;
2057
+ }
2058
+ }
2059
+ list() {
2060
+ return [...this.entries.values()].map((entry) => this.infoFor(entry));
2061
+ }
2062
+ get(name) {
2063
+ const entry = this.entries.get(name);
2064
+ return entry ? this.infoFor(entry) : void 0;
2065
+ }
2066
+ has(name) {
2067
+ return this.entries.has(name);
2068
+ }
2069
+ async startByName(name) {
2070
+ const entry = this.entries.get(name);
2071
+ if (!entry) {
2072
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
2073
+ }
2074
+ if (entry.child) {
2075
+ throw withCode2(new Error(`extension ${name} already running`), "CONFLICT");
2076
+ }
2077
+ if (entry.restartTimer) {
2078
+ clearTimeout(entry.restartTimer);
2079
+ entry.restartTimer = void 0;
2080
+ }
2081
+ entry.manuallyStopped = false;
2082
+ entry.restartCount = 0;
2083
+ this.spawn(entry, 0);
2084
+ return this.infoFor(entry);
2085
+ }
2086
+ async stopByName(name) {
2087
+ const entry = this.entries.get(name);
2088
+ if (!entry) {
2089
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
2090
+ }
2091
+ entry.manuallyStopped = true;
2092
+ if (entry.restartTimer) {
2093
+ clearTimeout(entry.restartTimer);
2094
+ entry.restartTimer = void 0;
2095
+ }
2096
+ const child = entry.child;
2097
+ if (!child) {
2098
+ return this.infoFor(entry);
2099
+ }
2100
+ await this.terminate(entry, child);
2101
+ return this.infoFor(entry);
2102
+ }
2103
+ async restartByName(name) {
2104
+ await this.stopByName(name);
2105
+ return this.startByName(name);
2106
+ }
2107
+ // Register a new extension and (if enabled) start it. Used by the
2108
+ // POST /v1/extensions endpoint so `hydra-acp extensions add` can take
2109
+ // effect without a daemon restart.
2110
+ register(config) {
2111
+ if (this.entries.has(config.name)) {
2112
+ throw withCode2(
2113
+ new Error(`extension ${config.name} already exists`),
2114
+ "CONFLICT"
2115
+ );
2116
+ }
2117
+ if (!this.context) {
2118
+ throw new Error("ExtensionManager: setContext must be called before register");
2119
+ }
2120
+ const entry = this.makeEntry(config);
2121
+ this.entries.set(config.name, entry);
2122
+ if (config.enabled) {
2123
+ this.spawn(entry, 0);
2124
+ }
2125
+ return this.infoFor(entry);
2126
+ }
2127
+ async unregister(name) {
2128
+ const entry = this.entries.get(name);
2129
+ if (!entry) {
2130
+ throw withCode2(new Error(`unknown extension: ${name}`), "NOT_FOUND");
2131
+ }
2132
+ entry.manuallyStopped = true;
2133
+ if (entry.restartTimer) {
2134
+ clearTimeout(entry.restartTimer);
2135
+ entry.restartTimer = void 0;
2136
+ }
2137
+ const child = entry.child;
2138
+ if (child) {
2139
+ await this.terminate(entry, child);
2140
+ }
2141
+ try {
2142
+ entry.logStream?.end();
2143
+ } catch {
2144
+ }
2145
+ this.entries.delete(name);
2146
+ }
2147
+ async terminate(entry, child) {
2148
+ if (child.exitCode !== null || child.signalCode !== null) {
2149
+ return;
2150
+ }
2151
+ const exited = new Promise((resolve2) => {
2152
+ entry.exitWaiters.push(resolve2);
2153
+ });
2154
+ try {
2155
+ child.kill("SIGTERM");
2156
+ } catch {
2157
+ }
2158
+ const killTimer = setTimeout(() => {
2159
+ try {
2160
+ child.kill("SIGKILL");
2161
+ } catch {
2162
+ }
2163
+ }, STOP_GRACE_MS);
2164
+ if (typeof killTimer.unref === "function") {
2165
+ killTimer.unref();
2166
+ }
2167
+ try {
2168
+ await exited;
2169
+ } finally {
2170
+ clearTimeout(killTimer);
2171
+ }
2172
+ }
2173
+ infoFor(entry) {
2174
+ let status;
2175
+ if (entry.child) {
2176
+ status = "running";
2177
+ } else if (entry.restartTimer) {
2178
+ status = "restarting";
2179
+ } else if (!entry.config.enabled) {
2180
+ status = "disabled";
2181
+ } else {
2182
+ status = "stopped";
2183
+ }
2184
+ return {
2185
+ name: entry.config.name,
2186
+ status,
2187
+ pid: entry.pid,
2188
+ enabled: entry.config.enabled,
2189
+ restartCount: entry.restartCount,
2190
+ startedAt: entry.startedAt,
2191
+ lastExitCode: entry.lastExitCode,
2192
+ logPath: paths.extensionLogFile(entry.config.name)
2193
+ };
2194
+ }
2195
+ makeEntry(config) {
2196
+ return {
2197
+ config,
2198
+ child: void 0,
2199
+ logStream: void 0,
2200
+ restartTimer: void 0,
2201
+ pid: void 0,
2202
+ startedAt: void 0,
2203
+ restartCount: 0,
2204
+ lastExitCode: void 0,
2205
+ manuallyStopped: false,
2206
+ exitWaiters: []
2207
+ };
2208
+ }
2209
+ async reapOrphans() {
2210
+ let entries;
2211
+ try {
2212
+ entries = await fsp.readdir(paths.extensionsDir());
2213
+ } catch (err) {
2214
+ const e = err;
2215
+ if (e.code === "ENOENT") {
2216
+ return;
2217
+ }
2218
+ throw err;
2219
+ }
2220
+ for (const entry of entries) {
2221
+ if (!entry.endsWith(".pid")) {
2222
+ continue;
2223
+ }
2224
+ const pidPath = path3.join(paths.extensionsDir(), entry);
2225
+ let pid;
2226
+ try {
2227
+ const raw = await fsp.readFile(pidPath, "utf8");
2228
+ const parsed = Number.parseInt(raw.trim(), 10);
2229
+ if (Number.isInteger(parsed) && parsed > 0) {
2230
+ pid = parsed;
2231
+ }
2232
+ } catch {
2233
+ }
2234
+ if (typeof pid === "number" && isAlive(pid)) {
2235
+ try {
2236
+ process.kill(pid, "SIGTERM");
2237
+ } catch {
2238
+ }
2239
+ const deadline = Date.now() + STOP_GRACE_MS;
2240
+ while (Date.now() < deadline && isAlive(pid)) {
2241
+ await new Promise((r) => setTimeout(r, 50));
2242
+ }
2243
+ if (isAlive(pid)) {
2244
+ try {
2245
+ process.kill(pid, "SIGKILL");
2246
+ } catch {
2247
+ }
2248
+ }
2249
+ }
2250
+ await fsp.unlink(pidPath).catch(() => void 0);
2251
+ }
2252
+ }
2253
+ spawn(entry, attempt) {
2254
+ if (this.stopping || entry.manuallyStopped) {
2255
+ return;
2256
+ }
2257
+ const ctx = this.context;
2258
+ if (!ctx) {
2259
+ throw new Error("ExtensionManager.spawn called before setContext");
2260
+ }
2261
+ const ext = entry.config;
2262
+ const command = ext.command.length > 0 ? ext.command : [ext.name];
2263
+ const logStream = fs4.createWriteStream(paths.extensionLogFile(ext.name), {
2264
+ flags: "a"
2265
+ });
2266
+ logStream.write(
2267
+ `[hydra-acp] ${(/* @__PURE__ */ new Date()).toISOString()} starting extension ${ext.name} (attempt ${attempt + 1})
2268
+ `
2269
+ );
2270
+ const env = {
2271
+ ...process.env,
2272
+ HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
2273
+ HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
2274
+ HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
2275
+ HYDRA_ACP_TOKEN: ctx.daemonToken,
2276
+ HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
2277
+ HYDRA_ACP_HOME: ctx.hydraHome,
2278
+ HYDRA_ACP_EXTENSION_NAME: ext.name,
2279
+ ...ext.env
2280
+ };
2281
+ const [cmd, ...baseArgs] = command;
2282
+ if (cmd === void 0) {
2283
+ logStream.write(`[hydra-acp] extension ${ext.name} has empty command
2284
+ `);
2285
+ logStream.end();
2286
+ return;
2287
+ }
2288
+ const args = [...baseArgs, ...ext.args];
2289
+ let child;
2290
+ try {
2291
+ child = spawn2(cmd, args, {
2292
+ env,
2293
+ stdio: ["ignore", "pipe", "pipe"],
2294
+ detached: false
2295
+ });
2296
+ } catch (err) {
2297
+ logStream.write(
2298
+ `[hydra-acp] failed to spawn ${ext.name}: ${err.message}
2299
+ `
2300
+ );
2301
+ logStream.end();
2302
+ this.scheduleRestart(entry, attempt);
2303
+ return;
2304
+ }
2305
+ if (child.stdout) {
2306
+ child.stdout.pipe(logStream, { end: false });
2307
+ }
2308
+ if (child.stderr) {
2309
+ child.stderr.pipe(logStream, { end: false });
2310
+ }
2311
+ if (typeof child.pid === "number") {
2312
+ try {
2313
+ fs4.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2314
+ `, {
2315
+ encoding: "utf8",
2316
+ mode: 384
2317
+ });
2318
+ } catch (err) {
2319
+ logStream.write(
2320
+ `[hydra-acp] failed to write pid file for ${ext.name}: ${err.message}
2321
+ `
2322
+ );
2323
+ }
2324
+ }
2325
+ entry.child = child;
2326
+ entry.logStream = logStream;
2327
+ entry.pid = typeof child.pid === "number" ? child.pid : void 0;
2328
+ entry.startedAt = Date.now();
2329
+ entry.lastExitCode = void 0;
2330
+ child.on("error", (err) => {
2331
+ logStream.write(
2332
+ `[hydra-acp] extension ${ext.name} error: ${err.message}
2333
+ `
2334
+ );
2335
+ });
2336
+ child.on("exit", (code, signal) => {
2337
+ try {
2338
+ fs4.unlinkSync(paths.extensionPidFile(ext.name));
2339
+ } catch {
2340
+ }
2341
+ logStream.write(
2342
+ `[hydra-acp] extension ${ext.name} exited code=${code ?? "null"} signal=${signal ?? "null"}
2343
+ `
2344
+ );
2345
+ entry.child = void 0;
2346
+ entry.pid = void 0;
2347
+ entry.lastExitCode = typeof code === "number" ? code : void 0;
2348
+ const waiters = entry.exitWaiters.splice(0);
2349
+ for (const resolve2 of waiters) {
2350
+ resolve2();
2351
+ }
2352
+ if (this.stopping || entry.manuallyStopped) {
2353
+ try {
2354
+ logStream.end();
2355
+ } catch {
2356
+ }
2357
+ entry.logStream = void 0;
2358
+ return;
2359
+ }
2360
+ entry.restartCount += 1;
2361
+ this.scheduleRestart(entry, attempt + 1);
2362
+ });
2363
+ }
2364
+ scheduleRestart(entry, attempt) {
2365
+ if (this.stopping || entry.manuallyStopped) {
2366
+ return;
2367
+ }
2368
+ const delay = Math.min(
2369
+ RESTART_BASE_MS * 2 ** Math.min(attempt, 10),
2370
+ RESTART_CAP_MS
2371
+ );
2372
+ entry.restartTimer = setTimeout(() => {
2373
+ entry.restartTimer = void 0;
2374
+ this.spawn(entry, attempt);
2375
+ }, delay);
2376
+ if (typeof entry.restartTimer.unref === "function") {
2377
+ entry.restartTimer.unref();
2378
+ }
2379
+ }
2380
+ };
2381
+ function isAlive(pid) {
2382
+ try {
2383
+ process.kill(pid, 0);
2384
+ return true;
2385
+ } catch {
2386
+ return false;
2387
+ }
2388
+ }
2389
+ function withCode2(err, code) {
2390
+ err.code = code;
2391
+ return err;
2392
+ }
2393
+
2394
+ // src/daemon/auth.ts
2395
+ var BEARER_PREFIX = "Bearer ";
2396
+ function bearerAuth(opts) {
2397
+ return async function authMiddleware(request, reply) {
2398
+ const header = request.headers.authorization;
2399
+ if (!header || !header.startsWith(BEARER_PREFIX)) {
2400
+ reply.code(401).send({ error: "Missing bearer token" });
2401
+ return;
2402
+ }
2403
+ const token = header.slice(BEARER_PREFIX.length).trim();
2404
+ if (!constantTimeEqual(token, opts.config.daemon.authToken)) {
2405
+ reply.code(403).send({ error: "Invalid token" });
2406
+ return;
2407
+ }
2408
+ };
2409
+ }
2410
+ function tokenFromUpgradeRequest(req) {
2411
+ const proto = req.headers["sec-websocket-protocol"];
2412
+ const protoString = Array.isArray(proto) ? proto.join(",") : proto;
2413
+ if (protoString) {
2414
+ for (const part of protoString.split(",")) {
2415
+ const trimmed = part.trim();
2416
+ const prefix = "hydra-acp-token.";
2417
+ if (trimmed.startsWith(prefix)) {
2418
+ return trimmed.slice(prefix.length);
2419
+ }
2420
+ }
2421
+ }
2422
+ if (req.url) {
2423
+ try {
2424
+ const u = new URL(req.url, "http://localhost");
2425
+ const queryToken = u.searchParams.get("token");
2426
+ if (queryToken) {
2427
+ return queryToken;
2428
+ }
2429
+ } catch {
2430
+ return void 0;
2431
+ }
2432
+ }
2433
+ return void 0;
2434
+ }
2435
+ function constantTimeEqual(a, b) {
2436
+ if (a.length !== b.length) {
2437
+ return false;
2438
+ }
2439
+ let mismatch = 0;
2440
+ for (let i = 0; i < a.length; i += 1) {
2441
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
2442
+ }
2443
+ return mismatch === 0;
2444
+ }
2445
+
2446
+ // src/daemon/routes/sessions.ts
2447
+ function registerSessionRoutes(app, manager, defaults) {
2448
+ app.get("/v1/sessions", async (request) => {
2449
+ const query = request.query;
2450
+ const all = query?.all === "true" || query?.all === "1";
2451
+ const sessions = await manager.list({ cwd: query?.cwd, all });
2452
+ return { sessions };
2453
+ });
2454
+ app.post("/v1/sessions", async (request, reply) => {
2455
+ const body = request.body ?? {};
2456
+ const cwd = expandHome(body.cwd ?? defaults.cwd);
2457
+ const agentId = body.agentId ?? defaults.agentId;
2458
+ try {
2459
+ const session = await manager.create({
2460
+ cwd,
2461
+ agentId,
2462
+ mcpServers: body.mcpServers
2463
+ });
2464
+ reply.code(201).send({
2465
+ sessionId: session.sessionId,
2466
+ agentId: session.agentId,
2467
+ cwd: session.cwd
2468
+ });
2469
+ } catch (err) {
2470
+ reply.code(500).send({ error: err.message });
2471
+ }
2472
+ });
2473
+ app.delete("/v1/sessions/:id", async (request, reply) => {
2474
+ const raw = request.params.id;
2475
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
2476
+ const session = manager.get(id);
2477
+ if (session) {
2478
+ await session.close({ deleteRecord: true });
2479
+ reply.code(204).send();
2480
+ return;
2481
+ }
2482
+ const removed = await manager.deleteRecord(id);
2483
+ if (!removed) {
2484
+ reply.code(404).send({ error: "session not found" });
2485
+ return;
2486
+ }
2487
+ reply.code(204).send();
2488
+ });
2489
+ }
2490
+
2491
+ // src/daemon/routes/agents.ts
2492
+ function registerAgentRoutes(app, registry) {
2493
+ app.get("/v1/agents", async () => {
2494
+ const doc = await registry.load();
2495
+ return {
2496
+ version: doc.version,
2497
+ agents: doc.agents.map((a) => ({
2498
+ id: a.id,
2499
+ name: a.name,
2500
+ version: a.version,
2501
+ description: a.description,
2502
+ distributions: Object.keys(a.distribution)
2503
+ }))
2504
+ };
2505
+ });
2506
+ app.get("/v1/registry", async () => {
2507
+ return registry.load();
2508
+ });
2509
+ app.post("/v1/registry/refresh", async () => {
2510
+ const doc = await registry.refresh();
2511
+ return { version: doc.version, agentCount: doc.agents.length };
2512
+ });
2513
+ }
2514
+
2515
+ // src/daemon/routes/health.ts
2516
+ function registerHealthRoutes(app, version) {
2517
+ app.get("/v1/health", { config: { skipAuth: true } }, async () => {
2518
+ return { status: "ok", version };
2519
+ });
2520
+ }
2521
+
2522
+ // src/daemon/routes/extensions.ts
2523
+ var NAME_RE = /^[A-Za-z0-9._-]+$/;
2524
+ function registerExtensionRoutes(app, extensions) {
2525
+ app.get("/v1/extensions", async () => {
2526
+ return { extensions: extensions.list() };
2527
+ });
2528
+ app.get("/v1/extensions/:name", async (request, reply) => {
2529
+ const name = request.params.name;
2530
+ const info = extensions.get(name);
2531
+ if (!info) {
2532
+ reply.code(404).send({ error: `unknown extension: ${name}` });
2533
+ return;
2534
+ }
2535
+ return info;
2536
+ });
2537
+ app.post("/v1/extensions", async (request, reply) => {
2538
+ const body = request.body ?? {};
2539
+ const parsed = parseRegisterBody(body);
2540
+ if ("error" in parsed) {
2541
+ reply.code(400).send({ error: parsed.error });
2542
+ return;
2543
+ }
2544
+ try {
2545
+ const info = extensions.register(parsed.config);
2546
+ reply.code(201).send(info);
2547
+ } catch (err) {
2548
+ sendError(reply, err);
2549
+ }
2550
+ });
2551
+ app.delete("/v1/extensions/:name", async (request, reply) => {
2552
+ const name = request.params.name;
2553
+ try {
2554
+ await extensions.unregister(name);
2555
+ reply.code(204).send();
2556
+ } catch (err) {
2557
+ sendError(reply, err);
2558
+ }
2559
+ });
2560
+ app.post("/v1/extensions/:name/start", async (request, reply) => {
2561
+ const name = request.params.name;
2562
+ try {
2563
+ const info = await extensions.startByName(name);
2564
+ reply.code(200).send(info);
2565
+ } catch (err) {
2566
+ sendError(reply, err);
2567
+ }
2568
+ });
2569
+ app.post("/v1/extensions/:name/stop", async (request, reply) => {
2570
+ const name = request.params.name;
2571
+ try {
2572
+ const info = await extensions.stopByName(name);
2573
+ reply.code(200).send(info);
2574
+ } catch (err) {
2575
+ sendError(reply, err);
2576
+ }
2577
+ });
2578
+ app.post("/v1/extensions/:name/restart", async (request, reply) => {
2579
+ const name = request.params.name;
2580
+ try {
2581
+ const info = await extensions.restartByName(name);
2582
+ reply.code(200).send(info);
2583
+ } catch (err) {
2584
+ sendError(reply, err);
2585
+ }
2586
+ });
2587
+ }
2588
+ function sendError(reply, err) {
2589
+ const code = err.code;
2590
+ const message = err.message ?? "unknown error";
2591
+ if (code === "NOT_FOUND") {
2592
+ reply.code(404).send({ error: message });
2593
+ return;
2594
+ }
2595
+ if (code === "CONFLICT") {
2596
+ reply.code(409).send({ error: message });
2597
+ return;
2598
+ }
2599
+ reply.code(500).send({ error: message });
2600
+ }
2601
+ function parseRegisterBody(body) {
2602
+ const name = body.name;
2603
+ if (typeof name !== "string" || !NAME_RE.test(name)) {
2604
+ return { error: "name must match [A-Za-z0-9._-]+" };
2605
+ }
2606
+ const command = body.command;
2607
+ if (command !== void 0 && (!Array.isArray(command) || command.some((c) => typeof c !== "string"))) {
2608
+ return { error: "command must be string[]" };
2609
+ }
2610
+ const args = body.args;
2611
+ if (args !== void 0 && (!Array.isArray(args) || args.some((a) => typeof a !== "string"))) {
2612
+ return { error: "args must be string[]" };
2613
+ }
2614
+ const env = body.env;
2615
+ if (env !== void 0 && (typeof env !== "object" || env === null || Array.isArray(env))) {
2616
+ return { error: "env must be an object of string\u2192string" };
2617
+ }
2618
+ if (env && Object.values(env).some((v) => typeof v !== "string")) {
2619
+ return { error: "env values must be strings" };
2620
+ }
2621
+ const enabled = body.enabled;
2622
+ if (enabled !== void 0 && typeof enabled !== "boolean") {
2623
+ return { error: "enabled must be a boolean" };
2624
+ }
2625
+ return {
2626
+ config: {
2627
+ name,
2628
+ command: command ?? [],
2629
+ args: args ?? [],
2630
+ env: env ?? {},
2631
+ enabled: enabled === void 0 ? true : enabled
2632
+ }
2633
+ };
2634
+ }
2635
+
2636
+ // src/daemon/acp-ws.ts
2637
+ import { nanoid as nanoid2 } from "nanoid";
2638
+
2639
+ // src/acp/ws-stream.ts
2640
+ function wsToMessageStream(ws) {
2641
+ const messageHandlers = [];
2642
+ const closeHandlers = [];
2643
+ let closed = false;
2644
+ const emitClose = (err) => {
2645
+ if (closed) {
2646
+ return;
2647
+ }
2648
+ closed = true;
2649
+ for (const handler of closeHandlers) {
2650
+ handler(err);
2651
+ }
2652
+ };
2653
+ ws.on("message", (data, isBinary) => {
2654
+ if (isBinary) {
2655
+ return;
2656
+ }
2657
+ const text = data.toString("utf8");
2658
+ try {
2659
+ const parsed = JSON.parse(text);
2660
+ for (const handler of messageHandlers) {
2661
+ handler(parsed);
2662
+ }
2663
+ } catch (err) {
2664
+ for (const handler of messageHandlers) {
2665
+ handler({
2666
+ jsonrpc: "2.0",
2667
+ id: 0,
2668
+ error: {
2669
+ code: JsonRpcErrorCodes.ParseError,
2670
+ message: `Failed to parse WS frame: ${err.message}`
2671
+ }
2672
+ });
2673
+ }
2674
+ }
2675
+ });
2676
+ ws.on("close", () => emitClose());
2677
+ ws.on("error", (err) => emitClose(err));
2678
+ return {
2679
+ async send(message) {
2680
+ if (closed) {
2681
+ throw new Error("ws is closed");
2682
+ }
2683
+ const text = JSON.stringify(message);
2684
+ await new Promise((resolve2, reject) => {
2685
+ ws.send(text, (err) => {
2686
+ if (err) {
2687
+ reject(err);
2688
+ return;
2689
+ }
2690
+ resolve2();
2691
+ });
2692
+ });
2693
+ },
2694
+ onMessage(handler) {
2695
+ messageHandlers.push(handler);
2696
+ },
2697
+ onClose(handler) {
2698
+ closeHandlers.push(handler);
2699
+ },
2700
+ async close() {
2701
+ if (closed) {
2702
+ return;
2703
+ }
2704
+ ws.close();
2705
+ emitClose();
2706
+ }
2707
+ };
2708
+ }
2709
+
2710
+ // src/daemon/acp-ws.ts
2711
+ var HYDRA_VERSION = "0.1.0";
2712
+ var HYDRA_PROTOCOL_VERSION = 1;
2713
+ function registerAcpWsEndpoint(app, deps) {
2714
+ app.get("/acp", { websocket: true }, (socket, request) => {
2715
+ const token = tokenFromUpgradeRequest({
2716
+ headers: request.headers,
2717
+ url: request.url
2718
+ });
2719
+ if (!token || !constantTimeEqual(token, deps.config.daemon.authToken)) {
2720
+ socket.close(4401, "Unauthorized");
2721
+ return;
2722
+ }
2723
+ const stream = wsToMessageStream(socket);
2724
+ const connection = new JsonRpcConnection(stream);
2725
+ const state = {
2726
+ clientId: `hydra_client_${nanoid2(12)}`,
2727
+ attached: /* @__PURE__ */ new Map()
2728
+ };
2729
+ connection.onClose(() => {
2730
+ for (const att of state.attached.values()) {
2731
+ const session = deps.manager.get(att.sessionId);
2732
+ session?.detach(att.clientId);
2733
+ }
2734
+ state.attached.clear();
2735
+ });
2736
+ connection.onRequest("initialize", async (raw) => {
2737
+ InitializeParams.parse(raw ?? {});
2738
+ return buildInitializeResult();
2739
+ });
2740
+ connection.onRequest("proxy/initialize", async (raw) => {
2741
+ ProxyInitializeParams.parse(raw ?? {});
2742
+ return buildInitializeResult();
2743
+ });
2744
+ connection.onRequest("session/new", async (raw) => {
2745
+ const params = SessionNewParams.parse(raw);
2746
+ const hydraMeta = extractHydraMeta(
2747
+ raw?._meta
2748
+ );
2749
+ const session = await deps.manager.create({
2750
+ cwd: params.cwd,
2751
+ agentId: params.agentId ?? deps.defaultAgent,
2752
+ mcpServers: params.mcpServers,
2753
+ title: hydraMeta.name,
2754
+ agentArgs: hydraMeta.agentArgs
2755
+ });
2756
+ const client = bindClientToSession(connection, session, state);
2757
+ session.attach(client, "full");
2758
+ state.attached.set(session.sessionId, {
2759
+ sessionId: session.sessionId,
2760
+ clientId: client.clientId
2761
+ });
2762
+ return {
2763
+ sessionId: session.sessionId,
2764
+ _meta: buildResponseMeta(session)
2765
+ };
2766
+ });
2767
+ connection.onRequest("session/attach", async (raw) => {
2768
+ const params = SessionAttachParams.parse(raw);
2769
+ const hydraHints = extractHydraMeta(params._meta).resume;
2770
+ app.log.info(
2771
+ `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints}`
2772
+ );
2773
+ const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
2774
+ let session = deps.manager.get(lookupId);
2775
+ if (!session) {
2776
+ let resurrectParams = hydraHints ? {
2777
+ hydraSessionId: params.sessionId,
2778
+ upstreamSessionId: hydraHints.upstreamSessionId,
2779
+ agentId: hydraHints.agentId,
2780
+ cwd: hydraHints.cwd,
2781
+ title: hydraHints.title,
2782
+ agentArgs: hydraHints.agentArgs
2783
+ } : await deps.manager.loadFromDisk(lookupId);
2784
+ if (!resurrectParams) {
2785
+ const err = new Error(
2786
+ `session ${params.sessionId} not found and no resume hints provided`
2787
+ );
2788
+ err.code = JsonRpcErrorCodes.SessionNotFound;
2789
+ throw err;
2790
+ }
2791
+ session = await deps.manager.resurrect(resurrectParams);
2792
+ }
2793
+ const client = bindClientToSession(
2794
+ connection,
2795
+ session,
2796
+ state,
2797
+ params.clientInfo
2798
+ );
2799
+ const replay = session.attach(client, params.historyPolicy);
2800
+ state.attached.set(session.sessionId, {
2801
+ sessionId: session.sessionId,
2802
+ clientId: client.clientId
2803
+ });
2804
+ app.log.info(
2805
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size}`
2806
+ );
2807
+ for (const note of replay) {
2808
+ await connection.notify(note.method, note.params);
2809
+ }
2810
+ session.replayPendingPermissions(client);
2811
+ return {
2812
+ sessionId: session.sessionId,
2813
+ replayed: replay.length,
2814
+ _meta: buildResponseMeta(session)
2815
+ };
2816
+ });
2817
+ connection.onRequest("session/detach", async (raw) => {
2818
+ const params = SessionDetachParams.parse(raw);
2819
+ const att = state.attached.get(params.sessionId);
2820
+ if (!att) {
2821
+ const err = new Error("client not attached to that session");
2822
+ err.code = JsonRpcErrorCodes.SessionNotFound;
2823
+ throw err;
2824
+ }
2825
+ const session = deps.manager.get(params.sessionId);
2826
+ session?.detach(att.clientId);
2827
+ state.attached.delete(params.sessionId);
2828
+ return { detached: true };
2829
+ });
2830
+ connection.onRequest("session/list", async (raw) => {
2831
+ const params = SessionListParams.parse(raw ?? {});
2832
+ const sessions = await deps.manager.list({ cwd: params.cwd });
2833
+ const result = { sessions };
2834
+ return result;
2835
+ });
2836
+ connection.onRequest("session/prompt", async (raw) => {
2837
+ const params = SessionPromptParams.parse(raw);
2838
+ const att = state.attached.get(params.sessionId);
2839
+ if (!att) {
2840
+ app.log.warn(
2841
+ `session/prompt rejected: not attached sessionId=${params.sessionId} attachedKeys=[${[...state.attached.keys()].join(",")}]`
2842
+ );
2843
+ const err = new Error("not attached to session");
2844
+ err.code = JsonRpcErrorCodes.SessionNotFound;
2845
+ throw err;
2846
+ }
2847
+ const session = deps.manager.require(params.sessionId);
2848
+ return session.prompt(att.clientId, params);
2849
+ });
2850
+ const handleCancelParams = (raw) => {
2851
+ let params;
2852
+ try {
2853
+ params = SessionCancelParams.parse(raw);
2854
+ } catch (err) {
2855
+ app.log.warn(
2856
+ `session/cancel: invalid params: ${err.message}`
2857
+ );
2858
+ return;
2859
+ }
2860
+ const att = state.attached.get(params.sessionId);
2861
+ if (!att) {
2862
+ return;
2863
+ }
2864
+ const session = deps.manager.get(params.sessionId);
2865
+ if (!session) {
2866
+ return;
2867
+ }
2868
+ session.cancel(att.clientId).catch((err) => {
2869
+ app.log.warn(
2870
+ `session/cancel for ${params.sessionId}: ${err.message}`
2871
+ );
2872
+ });
2873
+ };
2874
+ connection.onNotification("session/cancel", handleCancelParams);
2875
+ connection.onRequest("session/cancel", async (raw) => {
2876
+ handleCancelParams(raw);
2877
+ return null;
2878
+ });
2879
+ connection.onRequest("session/load", async (raw) => {
2880
+ const rawObj = raw ?? {};
2881
+ const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
2882
+ if (!rawSessionId) {
2883
+ const err = new Error("session/load requires sessionId");
2884
+ err.code = JsonRpcErrorCodes.InvalidParams;
2885
+ throw err;
2886
+ }
2887
+ const sessionId = await deps.manager.resolveCanonicalId(rawSessionId) ?? rawSessionId;
2888
+ let session = deps.manager.get(sessionId);
2889
+ if (!session) {
2890
+ const fromDisk = await deps.manager.loadFromDisk(sessionId);
2891
+ if (!fromDisk) {
2892
+ const err = new Error(
2893
+ `session ${rawSessionId} not found in memory or on disk`
2894
+ );
2895
+ err.code = JsonRpcErrorCodes.SessionNotFound;
2896
+ throw err;
2897
+ }
2898
+ session = await deps.manager.resurrect(fromDisk);
2899
+ }
2900
+ const client = bindClientToSession(connection, session, state);
2901
+ const replay = session.attach(client, "pending_only");
2902
+ state.attached.set(session.sessionId, {
2903
+ sessionId: session.sessionId,
2904
+ clientId: client.clientId
2905
+ });
2906
+ for (const note of replay) {
2907
+ await connection.notify(note.method, note.params);
2908
+ }
2909
+ session.replayPendingPermissions(client);
2910
+ return {
2911
+ sessionId: session.sessionId,
2912
+ _meta: buildResponseMeta(session)
2913
+ };
2914
+ });
2915
+ connection.setDefaultHandler(async (rawParams, method) => {
2916
+ if (!method.startsWith("session/") || rawParams === null || typeof rawParams !== "object") {
2917
+ const err = new Error(`Method not found: ${method}`);
2918
+ err.code = JsonRpcErrorCodes.MethodNotFound;
2919
+ throw err;
2920
+ }
2921
+ const sessionId = rawParams.sessionId;
2922
+ if (typeof sessionId !== "string") {
2923
+ const err = new Error(`Method not found: ${method}`);
2924
+ err.code = JsonRpcErrorCodes.MethodNotFound;
2925
+ throw err;
2926
+ }
2927
+ const session = deps.manager.get(sessionId);
2928
+ if (!session) {
2929
+ const err = new Error(`session ${sessionId} not found`);
2930
+ err.code = JsonRpcErrorCodes.SessionNotFound;
2931
+ throw err;
2932
+ }
2933
+ return session.forwardRequest(method, rawParams);
2934
+ });
2935
+ });
2936
+ }
2937
+ function buildResponseMeta(session) {
2938
+ const ours = {
2939
+ upstreamSessionId: session.upstreamSessionId,
2940
+ agentId: session.agentId,
2941
+ cwd: session.cwd
2942
+ };
2943
+ if (session.title !== void 0) {
2944
+ ours.name = session.title;
2945
+ }
2946
+ if (session.agentArgs && session.agentArgs.length > 0) {
2947
+ ours.agentArgs = session.agentArgs;
2948
+ }
2949
+ return mergeMeta(session.agentMeta, ours);
2950
+ }
2951
+ function buildInitializeResult() {
2952
+ return {
2953
+ protocolVersion: HYDRA_PROTOCOL_VERSION,
2954
+ agentInfo: { name: "hydra", version: HYDRA_VERSION },
2955
+ agentCapabilities: {
2956
+ // hydra is a transparent proxy: prompt blocks and MCP server configs are
2957
+ // forwarded to the underlying agent unchanged. We claim the union of
2958
+ // relevant capabilities; the agent ultimately decides what it accepts.
2959
+ promptCapabilities: {
2960
+ image: true,
2961
+ audio: true,
2962
+ embeddedContext: true
2963
+ },
2964
+ mcpCapabilities: {
2965
+ http: true,
2966
+ sse: true
2967
+ },
2968
+ loadSession: true,
2969
+ sessionCapabilities: {
2970
+ attach: {},
2971
+ list: true
2972
+ }
2973
+ },
2974
+ authMethods: [
2975
+ {
2976
+ id: "bearer-token",
2977
+ description: "Bearer token presented at WS upgrade"
2978
+ }
2979
+ ]
2980
+ };
2981
+ }
2982
+ function bindClientToSession(connection, session, state, clientInfo) {
2983
+ void state;
2984
+ void session;
2985
+ return {
2986
+ clientId: `cli_${nanoid2(8)}`,
2987
+ connection,
2988
+ clientInfo
2989
+ };
2990
+ }
2991
+
2992
+ // src/daemon/server.ts
2993
+ var HYDRA_VERSION2 = "0.1.0";
2994
+ async function startDaemon(config) {
2995
+ ensureLoopbackOrTls(config);
2996
+ const httpsOptions = config.daemon.tls ? {
2997
+ key: await fsp2.readFile(config.daemon.tls.key),
2998
+ cert: await fsp2.readFile(config.daemon.tls.cert)
2999
+ } : void 0;
3000
+ await fsp2.mkdir(paths.home(), { recursive: true });
3001
+ const { stream: logStream, fileStream } = await buildLogStream(
3002
+ config.daemon.logLevel
3003
+ );
3004
+ const app = Fastify({
3005
+ logger: {
3006
+ level: config.daemon.logLevel,
3007
+ stream: logStream
3008
+ },
3009
+ https: httpsOptions ?? null
3010
+ });
3011
+ await app.register(websocketPlugin);
3012
+ const auth = bearerAuth({ config });
3013
+ app.addHook("onRequest", async (request, reply) => {
3014
+ if (request.routeOptions.config?.skipAuth) {
3015
+ return;
3016
+ }
3017
+ if (request.url === "/acp" || request.url?.startsWith("/acp?")) {
3018
+ return;
3019
+ }
3020
+ await auth(request, reply);
3021
+ });
3022
+ const registry = new Registry(config);
3023
+ const manager = new SessionManager(registry, void 0, void 0, {
3024
+ idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3
3025
+ });
3026
+ const extensions = new ExtensionManager(extensionList(config));
3027
+ registerHealthRoutes(app, HYDRA_VERSION2);
3028
+ registerSessionRoutes(app, manager, {
3029
+ agentId: config.defaultAgent,
3030
+ cwd: config.defaultCwd
3031
+ });
3032
+ registerAgentRoutes(app, registry);
3033
+ registerExtensionRoutes(app, extensions);
3034
+ registerAcpWsEndpoint(app, {
3035
+ config,
3036
+ manager,
3037
+ defaultAgent: config.defaultAgent
3038
+ });
3039
+ await app.listen({ host: config.daemon.host, port: config.daemon.port });
3040
+ const address = app.server.address();
3041
+ const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
3042
+ await fsp2.mkdir(paths.home(), { recursive: true });
3043
+ await fsp2.writeFile(
3044
+ paths.pidFile(),
3045
+ JSON.stringify({
3046
+ pid: process.pid,
3047
+ host: config.daemon.host,
3048
+ port: boundPort,
3049
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
3050
+ }) + "\n",
3051
+ { encoding: "utf8", mode: 384 }
3052
+ );
3053
+ const scheme = config.daemon.tls ? "https" : "http";
3054
+ const wsScheme = config.daemon.tls ? "wss" : "ws";
3055
+ extensions.setContext({
3056
+ daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
3057
+ daemonHost: config.daemon.host,
3058
+ daemonPort: boundPort,
3059
+ daemonToken: config.daemon.authToken,
3060
+ daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
3061
+ hydraHome: paths.home()
3062
+ });
3063
+ await extensions.start();
3064
+ const shutdown = async () => {
3065
+ await extensions.stop();
3066
+ await manager.closeAll();
3067
+ await app.close();
3068
+ try {
3069
+ fs5.unlinkSync(paths.pidFile());
3070
+ } catch {
3071
+ }
3072
+ try {
3073
+ fileStream.flushSync();
3074
+ } catch {
3075
+ }
3076
+ };
3077
+ return { app, manager, registry, extensions, shutdown };
3078
+ }
3079
+ async function buildLogStream(level) {
3080
+ const fileStream = await createPinoRoll({
3081
+ file: paths.logFile(),
3082
+ size: "10m",
3083
+ frequency: "daily",
3084
+ mkdir: true,
3085
+ symlink: true
3086
+ });
3087
+ const stderrStream = pino.destination(2);
3088
+ const stream = pino.multistream([
3089
+ { stream: fileStream, level },
3090
+ { stream: stderrStream, level }
3091
+ ]);
3092
+ return { stream, fileStream };
3093
+ }
3094
+ function ensureLoopbackOrTls(config) {
3095
+ const host = config.daemon.host;
3096
+ const isLoopback = host === "127.0.0.1" || host === "::1" || host === "localhost" || host === "[::1]";
3097
+ if (!isLoopback && !config.daemon.tls) {
3098
+ throw new Error(
3099
+ `Refusing to bind to non-loopback host ${host} without TLS configured.`
3100
+ );
3101
+ }
3102
+ }
3103
+ export {
3104
+ AgentInstance,
3105
+ JsonRpcConnection,
3106
+ Registry,
3107
+ Session,
3108
+ SessionManager,
3109
+ defaultConfig,
3110
+ ensureConfig,
3111
+ generateAuthToken,
3112
+ loadConfig,
3113
+ ndjsonStreamFromStdio,
3114
+ paths,
3115
+ planSpawn,
3116
+ startDaemon,
3117
+ writeConfig,
3118
+ wsToMessageStream
3119
+ };