@hydra-acp/cli 0.1.22 → 0.1.23

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/daemon/server.ts
2
- import * as fs10 from "fs";
2
+ import * as fs14 from "fs";
3
3
  import * as fsp4 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -7,7 +7,7 @@ import pino from "pino";
7
7
  import createPinoRoll from "pino-roll";
8
8
 
9
9
  // src/core/config.ts
10
- import * as fs from "fs/promises";
10
+ import * as fs2 from "fs/promises";
11
11
  import { homedir as homedir2 } from "os";
12
12
  import { z } from "zod";
13
13
 
@@ -62,6 +62,12 @@ var paths = {
62
62
  sessionDir: (id) => path.join(hydraHome(), "sessions", id),
63
63
  sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
64
64
  historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
65
+ // Persisted prompt queue for a session. ndjson, one record per
66
+ // entry. Survives daemon restarts so queued prompts get a chance to
67
+ // run rather than being silently lost. Entries are removed BEFORE
68
+ // the agent invocation (see Session.drainQueue) so a crash mid-
69
+ // generation doesn't double-run on restart.
70
+ queueFile: (id) => path.join(hydraHome(), "sessions", id, "queue.ndjson"),
65
71
  extensionsDir: () => path.join(hydraHome(), "extensions"),
66
72
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
67
73
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -69,16 +75,70 @@ var paths = {
69
75
  tuiLogFile: () => path.join(hydraHome(), "tui.log")
70
76
  };
71
77
 
78
+ // src/core/service-token.ts
79
+ import * as fs from "fs/promises";
80
+ function generateServiceToken() {
81
+ const bytes = new Uint8Array(32);
82
+ crypto.getRandomValues(bytes);
83
+ let hex = "";
84
+ for (const b of bytes) {
85
+ hex += b.toString(16).padStart(2, "0");
86
+ }
87
+ return `hydra_token_${hex}`;
88
+ }
89
+ async function readServiceToken() {
90
+ try {
91
+ const text = await fs.readFile(paths.authToken(), "utf8");
92
+ const trimmed = text.trim();
93
+ return trimmed.length > 0 ? trimmed : void 0;
94
+ } catch (err) {
95
+ const e = err;
96
+ if (e.code === "ENOENT") {
97
+ return void 0;
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+ async function loadServiceToken() {
103
+ const token = await readServiceToken();
104
+ if (!token) {
105
+ throw new Error(
106
+ `No service token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
107
+ );
108
+ }
109
+ return token;
110
+ }
111
+ async function writeServiceToken(token) {
112
+ await fs.mkdir(paths.home(), { recursive: true });
113
+ await fs.writeFile(paths.authToken(), token + "\n", {
114
+ encoding: "utf8",
115
+ mode: 384
116
+ });
117
+ }
118
+ async function ensureServiceToken() {
119
+ const existing = await readServiceToken();
120
+ if (existing) {
121
+ return existing;
122
+ }
123
+ const token = generateServiceToken();
124
+ await writeServiceToken(token);
125
+ process.stderr.write(
126
+ `hydra-acp: initialized ${paths.authToken()} with a fresh service token.
127
+ `
128
+ );
129
+ return token;
130
+ }
131
+
72
132
  // src/core/config.ts
73
133
  var REGISTRY_URL_DEFAULT = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
74
134
  var TlsConfig = z.object({
75
135
  cert: z.string(),
76
136
  key: z.string()
77
137
  });
138
+ var DEFAULT_DAEMON_PORT = 55514;
78
139
  var DaemonConfig = z.object({
79
140
  host: z.string().default("127.0.0.1"),
80
- port: z.number().int().positive().default(8765),
81
- authToken: z.string().min(16),
141
+ port: z.number().int().positive().default(DEFAULT_DAEMON_PORT),
82
142
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
83
143
  tls: TlsConfig.optional(),
84
144
  sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
@@ -139,7 +199,7 @@ var ExtensionBody = z.object({
139
199
  enabled: z.boolean().default(true)
140
200
  });
141
201
  var HydraConfig = z.object({
142
- daemon: DaemonConfig,
202
+ daemon: DaemonConfig.default({}),
143
203
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
144
204
  defaultAgent: z.string().default("claude-acp"),
145
205
  // Optional per-agent default model id. When a brand-new agent process
@@ -159,6 +219,11 @@ var HydraConfig = z.object({
159
219
  // recency and truncated to this count. `--all` overrides in the CLI.
160
220
  sessionListColdLimit: z.number().int().nonnegative().default(20),
161
221
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
222
+ // npm registry URL used when installing npm-distributed agents into
223
+ // ~/.hydra-acp/agents. Overrides the global ~/.npmrc registry so a
224
+ // corporate .npmrc pointing at an internal registry doesn't break
225
+ // public-package installs. Omit to let npm use its own defaults.
226
+ npmRegistry: z.string().url().optional(),
162
227
  tui: TuiConfig.default({
163
228
  repaintThrottleMs: 1e3,
164
229
  maxScrollbackLines: 1e4,
@@ -168,9 +233,6 @@ var HydraConfig = z.object({
168
233
  progressIndicator: true
169
234
  })
170
235
  });
171
- var HydraConfigReadOnly = HydraConfig.extend({
172
- daemon: DaemonConfig.omit({ authToken: true }).default({})
173
- });
174
236
  function extensionList(config) {
175
237
  return Object.entries(config.extensions).map(([name, body]) => ({
176
238
  name,
@@ -180,7 +242,7 @@ function extensionList(config) {
180
242
  async function readConfigFile() {
181
243
  let raw;
182
244
  try {
183
- raw = await fs.readFile(paths.config(), "utf8");
245
+ raw = await fs2.readFile(paths.config(), "utf8");
184
246
  } catch (err) {
185
247
  const e = err;
186
248
  if (e.code === "ENOENT") {
@@ -190,44 +252,34 @@ async function readConfigFile() {
190
252
  }
191
253
  return JSON.parse(raw);
192
254
  }
193
- async function loadAuthToken() {
194
- let tokenFile;
255
+ async function migrateLegacyAuthToken() {
256
+ const raw = await readConfigFile();
257
+ const daemon = raw.daemon;
258
+ const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
259
+ if (!legacy) {
260
+ return;
261
+ }
262
+ let tokenFileExists = false;
195
263
  try {
196
- const text = await fs.readFile(paths.authToken(), "utf8");
197
- const trimmed = text.trim();
198
- if (trimmed.length > 0) {
199
- tokenFile = trimmed;
200
- }
264
+ await fs2.access(paths.authToken());
265
+ tokenFileExists = true;
201
266
  } catch (err) {
202
267
  const e = err;
203
268
  if (e.code !== "ENOENT") {
204
269
  throw err;
205
270
  }
206
271
  }
207
- const raw = await readConfigFile();
208
- const daemon = raw.daemon;
209
- const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
210
- if (tokenFile && legacy) {
272
+ if (tokenFileExists) {
211
273
  throw new Error(
212
274
  `Auth token present in both ${paths.authToken()} and ${paths.config()} (daemon.authToken). Remove daemon.authToken from config.json to resolve.`
213
275
  );
214
276
  }
215
- if (tokenFile) {
216
- return tokenFile;
217
- }
218
- if (legacy) {
219
- await migrateLegacyAuthToken(raw, daemon, legacy);
220
- return legacy;
221
- }
222
- return void 0;
223
- }
224
- async function migrateLegacyAuthToken(raw, daemon, token) {
225
- await writeAuthToken(token);
277
+ await writeServiceToken(legacy);
226
278
  delete daemon.authToken;
227
279
  if (Object.keys(daemon).length === 0) {
228
280
  delete raw.daemon;
229
281
  }
230
- await fs.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
282
+ await fs2.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
231
283
  encoding: "utf8",
232
284
  mode: 384
233
285
  });
@@ -236,61 +288,19 @@ async function migrateLegacyAuthToken(raw, daemon, token) {
236
288
  `
237
289
  );
238
290
  }
239
- async function writeAuthToken(token) {
240
- await fs.mkdir(paths.home(), { recursive: true });
241
- await fs.writeFile(paths.authToken(), token + "\n", {
242
- encoding: "utf8",
243
- mode: 384
244
- });
245
- }
246
291
  async function loadConfig() {
247
- const token = await loadAuthToken();
248
- if (!token) {
249
- throw new Error(
250
- `No auth token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
251
- );
252
- }
253
- const raw = await readConfigFile();
254
- const daemon = raw.daemon ??= {};
255
- daemon.authToken = token;
256
- return HydraConfig.parse(raw);
257
- }
258
- async function ensureConfig() {
259
- if (!await loadAuthToken()) {
260
- const token = generateAuthToken();
261
- await writeAuthToken(token);
262
- process.stderr.write(
263
- `hydra-acp: initialized ${paths.authToken()} with a fresh auth token.
264
- `
265
- );
266
- }
267
- return loadConfig();
292
+ await migrateLegacyAuthToken();
293
+ return HydraConfig.parse(await readConfigFile());
268
294
  }
269
295
  async function writeConfig(config) {
270
- await fs.mkdir(paths.home(), { recursive: true });
271
- const { daemon, ...rest } = config;
272
- const { authToken: _authToken, ...daemonRest } = daemon;
273
- const onDisk = { ...rest, daemon: daemonRest };
274
- await fs.writeFile(paths.config(), JSON.stringify(onDisk, null, 2) + "\n", {
296
+ await fs2.mkdir(paths.home(), { recursive: true });
297
+ await fs2.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
275
298
  encoding: "utf8",
276
299
  mode: 384
277
300
  });
278
301
  }
279
- function generateAuthToken() {
280
- const bytes = new Uint8Array(32);
281
- crypto.getRandomValues(bytes);
282
- let hex = "";
283
- for (const b of bytes) {
284
- hex += b.toString(16).padStart(2, "0");
285
- }
286
- return `hydra_token_${hex}`;
287
- }
288
302
  function defaultConfig() {
289
- return HydraConfig.parse({
290
- daemon: {
291
- authToken: generateAuthToken()
292
- }
293
- });
303
+ return HydraConfig.parse({});
294
304
  }
295
305
  function expandHome(p) {
296
306
  if (p === "~" || p === "$HOME") {
@@ -306,11 +316,11 @@ function expandHome(p) {
306
316
  }
307
317
 
308
318
  // src/core/registry.ts
309
- import * as fs3 from "fs/promises";
319
+ import * as fs4 from "fs/promises";
310
320
  import { z as z2 } from "zod";
311
321
 
312
322
  // src/core/binary-install.ts
313
- import * as fs2 from "fs";
323
+ import * as fs3 from "fs";
314
324
  import * as fsp from "fs/promises";
315
325
  import * as path2 from "path";
316
326
  import { spawn } from "child_process";
@@ -415,7 +425,7 @@ async function downloadTo(args) {
415
425
  );
416
426
  }
417
427
  const total = Number(response.headers.get("content-length") ?? "0");
418
- const out = fs2.createWriteStream(dest);
428
+ const out = fs3.createWriteStream(dest);
419
429
  const nodeStream = Readable.fromWeb(response.body);
420
430
  let received = 0;
421
431
  let lastEmit = Date.now();
@@ -541,7 +551,8 @@ async function ensureNpmPackage(args) {
541
551
  await installInto({
542
552
  agentId: args.agentId,
543
553
  packageSpec: args.packageSpec,
544
- installDir
554
+ installDir,
555
+ registry: args.registry
545
556
  });
546
557
  if (!await fileExists2(binPath)) {
547
558
  throw new Error(
@@ -559,7 +570,8 @@ async function installInto(args) {
559
570
  );
560
571
  await runNpmInstall({
561
572
  packageSpec: args.packageSpec,
562
- cwd: tempDir
573
+ cwd: tempDir,
574
+ registry: args.registry
563
575
  });
564
576
  try {
565
577
  await fsp2.rename(tempDir, args.installDir);
@@ -583,9 +595,10 @@ async function installInto(args) {
583
595
  }
584
596
  function runNpmInstall(args) {
585
597
  return new Promise((resolve3, reject) => {
598
+ const registryArgs = args.registry ? ["--registry", args.registry] : [];
586
599
  const child = spawn2(
587
600
  "npm",
588
- ["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
601
+ ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
589
602
  {
590
603
  cwd: args.cwd,
591
604
  stdio: ["ignore", "pipe", "pipe"]
@@ -738,7 +751,7 @@ var Registry = class {
738
751
  async readDiskCache() {
739
752
  let text;
740
753
  try {
741
- text = await fs3.readFile(paths.registryCache(), "utf8");
754
+ text = await fs4.readFile(paths.registryCache(), "utf8");
742
755
  } catch (err) {
743
756
  const e = err;
744
757
  if (e.code === "ENOENT") {
@@ -764,7 +777,7 @@ var Registry = class {
764
777
  // without a lock file: the loser of the rename race just gets its
765
778
  // version replaced by the winner's.
766
779
  async writeDiskCache(cache) {
767
- await fs3.mkdir(paths.home(), { recursive: true });
780
+ await fs4.mkdir(paths.home(), { recursive: true });
768
781
  const final = paths.registryCache();
769
782
  const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
770
783
  const body = JSON.stringify(
@@ -773,10 +786,10 @@ var Registry = class {
773
786
  2
774
787
  ) + "\n";
775
788
  try {
776
- await fs3.writeFile(tmp, body, "utf8");
777
- await fs3.rename(tmp, final);
789
+ await fs4.writeFile(tmp, body, "utf8");
790
+ await fs4.rename(tmp, final);
778
791
  } catch (err) {
779
- await fs3.unlink(tmp).catch(() => void 0);
792
+ await fs4.unlink(tmp).catch(() => void 0);
780
793
  throw err;
781
794
  }
782
795
  }
@@ -794,7 +807,7 @@ function npxPackageBasename(agent) {
794
807
  const atIdx = afterSlash.lastIndexOf("@");
795
808
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
796
809
  }
797
- async function planSpawn(agent, callerArgs = []) {
810
+ async function planSpawn(agent, callerArgs = [], options = {}) {
798
811
  if (agent.distribution.npx) {
799
812
  const npx = agent.distribution.npx;
800
813
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
@@ -810,7 +823,8 @@ async function planSpawn(agent, callerArgs = []) {
810
823
  agentId: agent.id,
811
824
  version: agent.version ?? "current",
812
825
  packageSpec: npx.package,
813
- bin
826
+ bin,
827
+ registry: options.npmRegistry
814
828
  });
815
829
  return {
816
830
  command: binPath,
@@ -995,6 +1009,58 @@ function extractHydraMeta(meta) {
995
1009
  out.availableCommands = cmds;
996
1010
  }
997
1011
  }
1012
+ if (typeof obj.promptQueueing === "boolean") {
1013
+ out.promptQueueing = obj.promptQueueing;
1014
+ }
1015
+ if (Array.isArray(obj.queue)) {
1016
+ const entries = [];
1017
+ for (const raw of obj.queue) {
1018
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1019
+ continue;
1020
+ }
1021
+ const r = raw;
1022
+ const orig = r.originator;
1023
+ if (typeof r.messageId !== "string" || !orig || typeof orig.clientId !== "string" || !Array.isArray(r.prompt) || typeof r.position !== "number" || typeof r.enqueuedAt !== "number") {
1024
+ continue;
1025
+ }
1026
+ const originator = { clientId: orig.clientId };
1027
+ if (typeof orig.name === "string") originator.name = orig.name;
1028
+ if (typeof orig.version === "string") originator.version = orig.version;
1029
+ entries.push({
1030
+ messageId: r.messageId,
1031
+ originator,
1032
+ prompt: r.prompt,
1033
+ position: r.position,
1034
+ enqueuedAt: r.enqueuedAt
1035
+ });
1036
+ }
1037
+ if (entries.length > 0) {
1038
+ out.queue = entries;
1039
+ }
1040
+ }
1041
+ if (Array.isArray(obj.availableModes)) {
1042
+ const modes = [];
1043
+ for (const raw of obj.availableModes) {
1044
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1045
+ continue;
1046
+ }
1047
+ const m = raw;
1048
+ if (typeof m.id !== "string") {
1049
+ continue;
1050
+ }
1051
+ const mode = { id: m.id };
1052
+ if (typeof m.name === "string") {
1053
+ mode.name = m.name;
1054
+ }
1055
+ if (typeof m.description === "string") {
1056
+ mode.description = m.description;
1057
+ }
1058
+ modes.push(mode);
1059
+ }
1060
+ if (modes.length > 0) {
1061
+ out.availableModes = modes;
1062
+ }
1063
+ }
998
1064
  return out;
999
1065
  }
1000
1066
  function mergeMeta(passthrough, ours) {
@@ -1047,6 +1113,49 @@ var SessionPromptParams = z3.object({
1047
1113
  var SessionCancelParams = z3.object({
1048
1114
  sessionId: z3.string()
1049
1115
  });
1116
+ var PromptOriginatorSchema = z3.object({
1117
+ clientId: z3.string(),
1118
+ name: z3.string().optional(),
1119
+ version: z3.string().optional()
1120
+ });
1121
+ var PromptQueueAddedParams = z3.object({
1122
+ sessionId: z3.string(),
1123
+ messageId: z3.string(),
1124
+ originator: PromptOriginatorSchema,
1125
+ prompt: z3.array(z3.unknown()),
1126
+ // 0 = head (currently in-flight). At enqueue time the new entry's
1127
+ // position equals the count of entries already ahead of it.
1128
+ position: z3.number().int().nonnegative(),
1129
+ queueDepth: z3.number().int().positive(),
1130
+ enqueuedAt: z3.number()
1131
+ });
1132
+ var PromptQueueUpdatedParams = z3.object({
1133
+ sessionId: z3.string(),
1134
+ messageId: z3.string(),
1135
+ prompt: z3.array(z3.unknown())
1136
+ });
1137
+ var PromptQueueRemovedParams = z3.object({
1138
+ sessionId: z3.string(),
1139
+ messageId: z3.string(),
1140
+ reason: z3.enum(["started", "cancelled", "abandoned"])
1141
+ });
1142
+ var CancelPromptParams = z3.object({
1143
+ sessionId: z3.string(),
1144
+ messageId: z3.string()
1145
+ });
1146
+ var CancelPromptResult = z3.object({
1147
+ cancelled: z3.boolean(),
1148
+ reason: z3.enum(["ok", "not_found", "already_running"])
1149
+ });
1150
+ var UpdatePromptParams = z3.object({
1151
+ sessionId: z3.string(),
1152
+ messageId: z3.string(),
1153
+ prompt: z3.array(z3.unknown())
1154
+ });
1155
+ var UpdatePromptResult = z3.object({
1156
+ updated: z3.boolean(),
1157
+ reason: z3.enum(["ok", "not_found", "already_running"])
1158
+ });
1050
1159
  var ProxyInitializeParams = z3.object({
1051
1160
  protocolVersion: z3.number().optional(),
1052
1161
  proxyInfo: z3.object({
@@ -1435,7 +1544,7 @@ stderr: ${tail}` : reason;
1435
1544
  };
1436
1545
 
1437
1546
  // src/core/session-manager.ts
1438
- import * as fs8 from "fs/promises";
1547
+ import * as fs10 from "fs/promises";
1439
1548
  import * as os2 from "os";
1440
1549
  import { customAlphabet as customAlphabet3 } from "nanoid";
1441
1550
 
@@ -1469,6 +1578,47 @@ function hydraCommandsAsAdvertised() {
1469
1578
  }));
1470
1579
  }
1471
1580
 
1581
+ // src/core/queue-store.ts
1582
+ import * as fs5 from "fs/promises";
1583
+ async function rewriteQueue(sessionId, entries) {
1584
+ const file = paths.queueFile(sessionId);
1585
+ if (entries.length === 0) {
1586
+ await fs5.unlink(file).catch(() => void 0);
1587
+ return;
1588
+ }
1589
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
1590
+ const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1591
+ await fs5.writeFile(file, body, "utf8");
1592
+ }
1593
+ async function loadQueue(sessionId) {
1594
+ const file = paths.queueFile(sessionId);
1595
+ let text;
1596
+ try {
1597
+ text = await fs5.readFile(file, "utf8");
1598
+ } catch (err) {
1599
+ if (err.code === "ENOENT") {
1600
+ return [];
1601
+ }
1602
+ throw err;
1603
+ }
1604
+ const out = [];
1605
+ for (const line of text.split("\n")) {
1606
+ if (!line.trim()) continue;
1607
+ try {
1608
+ const parsed = JSON.parse(line);
1609
+ if (parsed && typeof parsed.messageId === "string" && Array.isArray(parsed.prompt) && typeof parsed.enqueuedAt === "number") {
1610
+ out.push(parsed);
1611
+ }
1612
+ } catch {
1613
+ }
1614
+ }
1615
+ return out;
1616
+ }
1617
+ async function deleteQueue(sessionId) {
1618
+ const file = paths.queueFile(sessionId);
1619
+ await fs5.unlink(file).catch(() => void 0);
1620
+ }
1621
+
1472
1622
  // src/core/session.ts
1473
1623
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1474
1624
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
@@ -1504,7 +1654,18 @@ var Session = class {
1504
1654
  clients = /* @__PURE__ */ new Map();
1505
1655
  historyStore;
1506
1656
  promptQueue = [];
1657
+ // The entry that drainQueue is currently awaiting. Distinct from
1658
+ // promptQueue[0] (which is the *next* one to dequeue): once shifted
1659
+ // off, the entry lives here for the duration of its task() so
1660
+ // cancelQueuedPrompt can distinguish "still in line" from "running"
1661
+ // and return already_running for the latter.
1662
+ currentEntry;
1507
1663
  promptInFlight = false;
1664
+ // Serialize disk writes to the persisted queue file. Without this
1665
+ // chain, fire-and-forget appends/rewrites can interleave (e.g.
1666
+ // drainQueue's rewrite-to-empty races a sibling's append-on-
1667
+ // enqueue) and leave the file out of sync with in-memory state.
1668
+ queueWriteChain = Promise.resolve();
1508
1669
  closed = false;
1509
1670
  closeHandlers = [];
1510
1671
  titleHandlers = [];
@@ -1557,10 +1718,14 @@ var Session = class {
1557
1718
  // can deliver the merged list via _meta without depending on history
1558
1719
  // replay.
1559
1720
  agentAdvertisedCommands = [];
1721
+ // Last available_modes_update we observed from the agent. Same
1722
+ // pattern as commands: cache, persist, broadcast on change.
1723
+ agentAdvertisedModes = [];
1560
1724
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1561
1725
  // to mirror changes into meta.json so cold-resurrect attaches can
1562
1726
  // surface the latest snapshot via the attach response _meta.
1563
1727
  agentCommandsHandlers = [];
1728
+ agentModesHandlers = [];
1564
1729
  modelHandlers = [];
1565
1730
  modeHandlers = [];
1566
1731
  usageHandlers = [];
@@ -1579,6 +1744,9 @@ var Session = class {
1579
1744
  if (init.agentCommands && init.agentCommands.length > 0) {
1580
1745
  this.agentAdvertisedCommands = [...init.agentCommands];
1581
1746
  }
1747
+ if (init.agentModes && init.agentModes.length > 0) {
1748
+ this.agentAdvertisedModes = [...init.agentModes];
1749
+ }
1582
1750
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1583
1751
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1584
1752
  this.logger = init.logger;
@@ -1607,6 +1775,15 @@ var Session = class {
1607
1775
  }
1608
1776
  });
1609
1777
  }
1778
+ broadcastAvailableModes() {
1779
+ this.recordAndBroadcast("session/update", {
1780
+ sessionId: this.upstreamSessionId,
1781
+ update: {
1782
+ sessionUpdate: "available_modes_update",
1783
+ availableModes: this.agentAdvertisedModes
1784
+ }
1785
+ });
1786
+ }
1610
1787
  // Register session/update, session/request_permission, and onExit
1611
1788
  // handlers on an agent connection. Re-run on every /hydra agent so
1612
1789
  // the new agent is plumbed identically. The exit handler's identity
@@ -1624,6 +1801,11 @@ var Session = class {
1624
1801
  this.setAgentAdvertisedCommands(agentCmds);
1625
1802
  return;
1626
1803
  }
1804
+ const agentModes = extractAdvertisedModes(params);
1805
+ if (agentModes !== null) {
1806
+ this.setAgentAdvertisedModes(agentModes);
1807
+ return;
1808
+ }
1627
1809
  if (this.maybeApplyAgentModel(params)) {
1628
1810
  this.recordAndBroadcast("session/update", params);
1629
1811
  return;
@@ -1800,7 +1982,7 @@ var Session = class {
1800
1982
  sessionId,
1801
1983
  update: {
1802
1984
  sessionUpdate: "current_mode_update",
1803
- currentMode: this.currentMode
1985
+ currentModeId: this.currentMode
1804
1986
  }
1805
1987
  },
1806
1988
  recordedAt
@@ -1820,6 +2002,19 @@ var Session = class {
1820
2002
  recordedAt
1821
2003
  });
1822
2004
  }
2005
+ if (this.agentAdvertisedModes.length > 0) {
2006
+ out.push({
2007
+ method: "session/update",
2008
+ params: {
2009
+ sessionId,
2010
+ update: {
2011
+ sessionUpdate: "available_modes_update",
2012
+ availableModes: [...this.agentAdvertisedModes]
2013
+ }
2014
+ },
2015
+ recordedAt
2016
+ });
2017
+ }
1823
2018
  if (this.currentUsage !== void 0) {
1824
2019
  const u = this.currentUsage;
1825
2020
  const update = {
@@ -1910,34 +2105,28 @@ var Session = class {
1910
2105
  if (promptText.startsWith("/hydra")) {
1911
2106
  return this.handleSlashCommand(promptText);
1912
2107
  }
1913
- this.broadcastPromptReceived(client, params);
2108
+ const messageId = generateMessageId();
1914
2109
  this.maybeSeedTitleFromPrompt(params);
1915
- return this.enqueuePrompt(async () => {
1916
- let response;
1917
- try {
1918
- response = await this.agent.connection.request(
1919
- "session/prompt",
1920
- {
1921
- ...params,
1922
- sessionId: this.upstreamSessionId
1923
- }
1924
- );
1925
- } catch (err) {
1926
- this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
1927
- throw err;
1928
- }
1929
- this.broadcastTurnComplete(client.clientId, response);
1930
- return response;
1931
- });
1932
- }
1933
- broadcastPromptReceived(client, params) {
1934
- const promptParams = params ?? {};
1935
- const sentBy = { clientId: client.clientId };
1936
- if (client.clientInfo?.name) {
1937
- sentBy.name = client.clientInfo.name;
1938
- }
1939
- if (client.clientInfo?.version) {
1940
- sentBy.version = client.clientInfo.version;
2110
+ return this.enqueueUserPrompt(client, params, messageId);
2111
+ }
2112
+ // DEVIATION FROM RFD #533: this broadcast is deliberately deferred
2113
+ // until the prompt actually becomes the active turn (i.e. drainQueue
2114
+ // is about to forward it to the agent), NOT when hydra first accepts
2115
+ // the request. The literal RFD doesn't pin the timing — it just says
2116
+ // peers should learn about the turn — but it was authored before
2117
+ // prompt queueing existed, so accept-time and start-time were the
2118
+ // same moment. With hydra's per-session FIFO, deferring gives
2119
+ // prompt_received a single, useful meaning ("the agent is now taking
2120
+ // a turn on this prompt"), which is how attached clients (notably
2121
+ // agent-shell) consume it. The accept-time signal that peers can use
2122
+ // for queue chip rendering is hydra-acp/prompt_queue_added instead.
2123
+ broadcastPromptReceived(entry) {
2124
+ const sentBy = { clientId: entry.originator.clientId };
2125
+ if (entry.originator.name) {
2126
+ sentBy.name = entry.originator.name;
2127
+ }
2128
+ if (entry.originator.version) {
2129
+ sentBy.version = entry.originator.version;
1941
2130
  }
1942
2131
  this.promptStartedAt = Date.now();
1943
2132
  this.recordAndBroadcast(
@@ -1946,14 +2135,14 @@ var Session = class {
1946
2135
  sessionId: this.sessionId,
1947
2136
  update: {
1948
2137
  sessionUpdate: "prompt_received",
1949
- messageId: generateMessageId(),
1950
- prompt: promptParams.prompt,
2138
+ messageId: entry.messageId,
2139
+ prompt: entry.prompt,
1951
2140
  sentBy
1952
2141
  }
1953
2142
  },
1954
- client.clientId
2143
+ entry.clientId
1955
2144
  );
1956
- const text = extractPromptText(promptParams.prompt);
2145
+ const text = extractPromptText(entry.prompt);
1957
2146
  if (text.length > 0) {
1958
2147
  this.recordAndBroadcast(
1959
2148
  "session/update",
@@ -1965,7 +2154,7 @@ var Session = class {
1965
2154
  _meta: { "hydra-acp": { compatFor: "prompt_received" } }
1966
2155
  }
1967
2156
  },
1968
- client.clientId
2157
+ entry.clientId
1969
2158
  );
1970
2159
  }
1971
2160
  }
@@ -1988,6 +2177,172 @@ var Session = class {
1988
2177
  originatorClientId
1989
2178
  );
1990
2179
  }
2180
+ // Total visible-or-running entries: the in-flight head (if any) plus
2181
+ // the queue's user-visible waiting entries. Internal entries don't
2182
+ // count — they're an implementation detail and the wire never
2183
+ // surfaces them.
2184
+ visibleQueueDepth() {
2185
+ let count = this.currentEntry?.kind === "user" && !this.currentEntry.cancelled ? 1 : 0;
2186
+ for (const entry of this.promptQueue) {
2187
+ if (entry.kind === "user" && !entry.cancelled) count += 1;
2188
+ }
2189
+ return count;
2190
+ }
2191
+ broadcastQueueAdded(entry) {
2192
+ const depth = this.visibleQueueDepth();
2193
+ const position = Math.max(0, depth - 1);
2194
+ const params = {
2195
+ sessionId: this.sessionId,
2196
+ messageId: entry.messageId,
2197
+ originator: entry.originator,
2198
+ prompt: entry.prompt,
2199
+ position,
2200
+ queueDepth: depth,
2201
+ enqueuedAt: entry.enqueuedAt
2202
+ };
2203
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
2204
+ }
2205
+ broadcastQueueUpdated(messageId, prompt) {
2206
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
2207
+ sessionId: this.sessionId,
2208
+ messageId,
2209
+ prompt
2210
+ });
2211
+ }
2212
+ broadcastQueueRemoved(messageId, reason) {
2213
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
2214
+ sessionId: this.sessionId,
2215
+ messageId,
2216
+ reason
2217
+ });
2218
+ }
2219
+ // Fan-out for queue lifecycle notifications. Ephemeral by design —
2220
+ // these signals describe transient daemon state, not conversation
2221
+ // content, so we deliberately bypass recordAndBroadcast (no history,
2222
+ // no idle-timer arm, no rewrite-for-client since we already emit the
2223
+ // hydra sessionId).
2224
+ broadcastQueueNotification(method, params) {
2225
+ for (const client of this.clients.values()) {
2226
+ void client.connection.notify(method, params).catch(() => void 0);
2227
+ }
2228
+ }
2229
+ // Snapshot of user-visible queue state at this moment. Surfaced to
2230
+ // late-attaching clients via the session/attach response _meta so
2231
+ // they boot with the same chip list as their peers without waiting
2232
+ // for new prompt_queue_added notifications. Internal entries are
2233
+ // omitted (they're not surfaced on the wire at all).
2234
+ queueSnapshot() {
2235
+ const out = [];
2236
+ let position = 0;
2237
+ if (this.currentEntry?.kind === "user" && !this.currentEntry.cancelled) {
2238
+ out.push({
2239
+ messageId: this.currentEntry.messageId,
2240
+ originator: this.currentEntry.originator,
2241
+ prompt: this.currentEntry.prompt,
2242
+ position: position++,
2243
+ enqueuedAt: this.currentEntry.enqueuedAt
2244
+ });
2245
+ }
2246
+ for (const entry of this.promptQueue) {
2247
+ if (entry.kind !== "user" || entry.cancelled) continue;
2248
+ out.push({
2249
+ messageId: entry.messageId,
2250
+ originator: entry.originator,
2251
+ prompt: entry.prompt,
2252
+ position: position++,
2253
+ enqueuedAt: entry.enqueuedAt
2254
+ });
2255
+ }
2256
+ return out;
2257
+ }
2258
+ // Wait for any pending queue-file writes to settle. Test hook so
2259
+ // assertions about on-disk state don't race with fire-and-forget
2260
+ // rewrites. Production code doesn't need this — the chain
2261
+ // self-serializes.
2262
+ async flushPersistWrites() {
2263
+ await this.queueWriteChain.catch(() => void 0);
2264
+ }
2265
+ // Push pre-existing queue entries back through the daemon-side
2266
+ // pipeline on startup. Called by SessionManager after resurrecting
2267
+ // a session that had a non-empty queue.ndjson on disk. Each entry
2268
+ // gets a synthetic UserPromptQueueEntry with no real caller
2269
+ // (resolve/reject are no-ops since the original WS is long gone),
2270
+ // then drainQueue picks it up like any other entry. Late-attaching
2271
+ // clients see the entries via prompt_queue_added broadcasts and the
2272
+ // attach-response snapshot.
2273
+ replayPersistedQueue(entries) {
2274
+ for (const persisted of entries) {
2275
+ const originator = {
2276
+ clientId: `hydra-resurrected_${persisted.messageId}`
2277
+ };
2278
+ if (persisted.originator.clientInfo.name !== void 0) {
2279
+ originator.name = persisted.originator.clientInfo.name;
2280
+ }
2281
+ if (persisted.originator.clientInfo.version !== void 0) {
2282
+ originator.version = persisted.originator.clientInfo.version;
2283
+ }
2284
+ const entry = {
2285
+ kind: "user",
2286
+ messageId: persisted.messageId,
2287
+ originator,
2288
+ // Synthetic clientId. broadcastTurnComplete uses this as
2289
+ // excludeClientId for the peer-only broadcast; with a synthetic
2290
+ // id no real attached client matches the exclude, so everyone
2291
+ // sees turn_complete — which is what we want, since none of
2292
+ // them originated this restart-replayed prompt.
2293
+ clientId: originator.clientId,
2294
+ prompt: persisted.prompt,
2295
+ enqueuedAt: persisted.enqueuedAt,
2296
+ cancelled: false,
2297
+ resolve: () => void 0,
2298
+ reject: () => void 0
2299
+ };
2300
+ this.promptQueue.push(entry);
2301
+ this.broadcastQueueAdded(entry);
2302
+ }
2303
+ void this.drainQueue();
2304
+ }
2305
+ // Drop a queued prompt by messageId. Returns already_running when
2306
+ // the messageId names the in-flight entry — callers should fall back
2307
+ // to session/cancel for that case. Originator-agnostic: any attached
2308
+ // client may cancel any queued prompt (matches the existing slack
2309
+ // :stop_sign: reaction UX and the TUI's queue-edit dispatcher).
2310
+ cancelQueuedPrompt(messageId) {
2311
+ if (this.currentEntry?.messageId === messageId) {
2312
+ return { cancelled: false, reason: "already_running" };
2313
+ }
2314
+ const idx = this.promptQueue.findIndex((e) => e.messageId === messageId);
2315
+ if (idx < 0) {
2316
+ return { cancelled: false, reason: "not_found" };
2317
+ }
2318
+ const entry = this.promptQueue[idx];
2319
+ entry.cancelled = true;
2320
+ this.promptQueue.splice(idx, 1);
2321
+ if (entry.kind === "user") {
2322
+ this.broadcastQueueRemoved(messageId, "cancelled");
2323
+ this.persistRewrite();
2324
+ }
2325
+ entry.resolve({ stopReason: "cancelled" });
2326
+ return { cancelled: true, reason: "ok" };
2327
+ }
2328
+ // Replace the prompt payload of a queued (not-yet-running) entry.
2329
+ // Returns already_running for the in-flight head; not_found for
2330
+ // unknown messageIds or for internal queue entries (internal tasks
2331
+ // don't expose a mutable prompt). Broadcasts prompt_queue_updated on
2332
+ // success so every attached client refreshes its chip.
2333
+ updateQueuedPrompt(messageId, prompt) {
2334
+ if (this.currentEntry?.messageId === messageId) {
2335
+ return { updated: false, reason: "already_running" };
2336
+ }
2337
+ const entry = this.promptQueue.find((e) => e.messageId === messageId);
2338
+ if (!entry || entry.kind !== "user") {
2339
+ return { updated: false, reason: "not_found" };
2340
+ }
2341
+ entry.prompt = prompt;
2342
+ this.broadcastQueueUpdated(messageId, prompt);
2343
+ this.persistRewrite();
2344
+ return { updated: true, reason: "ok" };
2345
+ }
1991
2346
  async cancel(clientId) {
1992
2347
  const client = this.clients.get(clientId);
1993
2348
  if (!client) {
@@ -2114,7 +2469,7 @@ var Session = class {
2114
2469
  if (update.sessionUpdate !== "current_mode_update") {
2115
2470
  return false;
2116
2471
  }
2117
- const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
2472
+ const raw = typeof update.currentModeId === "string" ? update.currentModeId : typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
2118
2473
  if (raw === void 0) {
2119
2474
  return true;
2120
2475
  }
@@ -2192,12 +2547,29 @@ var Session = class {
2192
2547
  }
2193
2548
  this.broadcastMergedCommands();
2194
2549
  }
2550
+ setAgentAdvertisedModes(modes) {
2551
+ if (sameAdvertisedModes(this.agentAdvertisedModes, modes)) {
2552
+ this.broadcastAvailableModes();
2553
+ return;
2554
+ }
2555
+ this.agentAdvertisedModes = modes;
2556
+ for (const handler of this.agentModesHandlers) {
2557
+ try {
2558
+ handler(modes);
2559
+ } catch {
2560
+ }
2561
+ }
2562
+ this.broadcastAvailableModes();
2563
+ }
2195
2564
  // Subscribe to snapshot-state updates. SessionManager wires these to
2196
2565
  // persist the new value into meta.json so cold resurrect can restore
2197
2566
  // them via the attach response _meta.
2198
2567
  onAgentCommandsChange(handler) {
2199
2568
  this.agentCommandsHandlers.push(handler);
2200
2569
  }
2570
+ onAgentModesChange(handler) {
2571
+ this.agentModesHandlers.push(handler);
2572
+ }
2201
2573
  onModelChange(handler) {
2202
2574
  this.modelHandlers.push(handler);
2203
2575
  }
@@ -2219,6 +2591,10 @@ var Session = class {
2219
2591
  agentOnlyAdvertisedCommands() {
2220
2592
  return [...this.agentAdvertisedCommands];
2221
2593
  }
2594
+ // The agent's advertised modes list, for callers that need a snapshot.
2595
+ availableModes() {
2596
+ return [...this.agentAdvertisedModes];
2597
+ }
2222
2598
  // Pick up an agent-emitted session_info_update and store its title
2223
2599
  // as our canonical record. The notification is also forwarded to
2224
2600
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2531,6 +2907,20 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2531
2907
  }
2532
2908
  this.closed = true;
2533
2909
  this.cancelIdleTimer();
2910
+ const stranded = this.promptQueue;
2911
+ this.promptQueue = [];
2912
+ for (const entry of stranded) {
2913
+ entry.cancelled = true;
2914
+ if (entry.kind === "user") {
2915
+ this.broadcastQueueRemoved(entry.messageId, "abandoned");
2916
+ }
2917
+ try {
2918
+ entry.resolve({ stopReason: "cancelled" });
2919
+ } catch {
2920
+ }
2921
+ }
2922
+ const sessionId = this.sessionId;
2923
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
2534
2924
  for (const client of this.clients.values()) {
2535
2925
  void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
2536
2926
  }
@@ -2576,7 +2966,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2576
2966
  if (this.closed || this.idleTimeoutMs <= 0) {
2577
2967
  return;
2578
2968
  }
2579
- if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
2969
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0 || this.promptQueue.length > 0) {
2580
2970
  this.armIdleTimer(this.idleTimeoutMs);
2581
2971
  return;
2582
2972
  }
@@ -2705,20 +3095,88 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2705
3095
  }
2706
3096
  });
2707
3097
  }
3098
+ // Schedule an internal task (title regen, agent swap transcript
3099
+ // injection, import seed). Serializes behind any user prompts already
3100
+ // in flight, but doesn't emit prompt_queue_* broadcasts — clients
3101
+ // shouldn't see hydra's housekeeping in their chip list.
2708
3102
  async enqueuePrompt(task) {
2709
3103
  return new Promise((resolve3, reject) => {
2710
- const run2 = async () => {
2711
- try {
2712
- const result = await task();
2713
- resolve3(result);
2714
- } catch (err) {
2715
- reject(err);
2716
- }
3104
+ const entry = {
3105
+ kind: "internal",
3106
+ messageId: generateMessageId(),
3107
+ enqueuedAt: Date.now(),
3108
+ cancelled: false,
3109
+ task,
3110
+ resolve: resolve3,
3111
+ reject
3112
+ };
3113
+ this.promptQueue.push(entry);
3114
+ void this.drainQueue();
3115
+ });
3116
+ }
3117
+ // Schedule a user-originated session/prompt. Emits prompt_queue_added
3118
+ // immediately on enqueue (so peer clients can render the queued chip)
3119
+ // and prompt_queue_removed when the entry leaves the queue. The
3120
+ // returned promise resolves with the upstream agent's session/prompt
3121
+ // result, or { stopReason: "cancelled" } if the entry is dropped via
3122
+ // cancelQueuedPrompt before reaching the head.
3123
+ async enqueueUserPrompt(client, params, messageId) {
3124
+ const promptArray = (params ?? {}).prompt ?? [];
3125
+ const originator = { clientId: client.clientId };
3126
+ if (client.clientInfo?.name) originator.name = client.clientInfo.name;
3127
+ if (client.clientInfo?.version)
3128
+ originator.version = client.clientInfo.version;
3129
+ return new Promise((resolve3, reject) => {
3130
+ const entry = {
3131
+ kind: "user",
3132
+ messageId,
3133
+ originator,
3134
+ clientId: client.clientId,
3135
+ prompt: promptArray,
3136
+ enqueuedAt: Date.now(),
3137
+ cancelled: false,
3138
+ resolve: resolve3,
3139
+ reject
2717
3140
  };
2718
- this.promptQueue.push(run2);
3141
+ this.promptQueue.push(entry);
3142
+ this.persistRewrite();
3143
+ this.broadcastQueueAdded(entry);
2719
3144
  void this.drainQueue();
2720
3145
  });
2721
3146
  }
3147
+ // Rewrite the on-disk queue to reflect the current set of WAITING
3148
+ // entries (excluding currentEntry, the in-flight head). Excluding
3149
+ // the head is the key idempotency choice: once drainQueue shifts an
3150
+ // entry off and calls persistRewrite, a daemon crash mid-generation
3151
+ // will NOT re-run it on restart. Partial output (if any streamed
3152
+ // before the crash) stays in history; the prompt itself is lost
3153
+ // and the user can re-submit if they care.
3154
+ //
3155
+ // Snapshots in-memory state synchronously (so subsequent mutations
3156
+ // can't perturb what we're about to write) and chains the write
3157
+ // onto queueWriteChain so all persists are serialized.
3158
+ persistRewrite() {
3159
+ const entries = [];
3160
+ for (const entry of this.promptQueue) {
3161
+ if (entry.kind !== "user" || entry.cancelled) continue;
3162
+ entries.push(this.persistedFromEntry(entry));
3163
+ }
3164
+ const sessionId = this.sessionId;
3165
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => rewriteQueue(sessionId, entries).catch(() => void 0));
3166
+ }
3167
+ persistedFromEntry(entry) {
3168
+ return {
3169
+ messageId: entry.messageId,
3170
+ originator: {
3171
+ clientInfo: {
3172
+ ...entry.originator.name !== void 0 ? { name: entry.originator.name } : {},
3173
+ ...entry.originator.version !== void 0 ? { version: entry.originator.version } : {}
3174
+ }
3175
+ },
3176
+ prompt: entry.prompt,
3177
+ enqueuedAt: entry.enqueuedAt
3178
+ };
3179
+ }
2722
3180
  async drainQueue() {
2723
3181
  if (this.promptInFlight) {
2724
3182
  return;
@@ -2727,14 +3185,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2727
3185
  try {
2728
3186
  while (this.promptQueue.length > 0) {
2729
3187
  const next = this.promptQueue.shift();
2730
- if (next) {
2731
- await next();
3188
+ if (!next) {
3189
+ break;
3190
+ }
3191
+ if (next.cancelled) {
3192
+ continue;
3193
+ }
3194
+ this.currentEntry = next;
3195
+ if (next.kind === "user") {
3196
+ this.persistRewrite();
3197
+ }
3198
+ if (next.kind === "user") {
3199
+ this.broadcastQueueRemoved(next.messageId, "started");
3200
+ }
3201
+ try {
3202
+ const result = await this.runQueueEntry(next);
3203
+ next.resolve(result);
3204
+ } catch (err) {
3205
+ next.reject(err);
3206
+ } finally {
3207
+ this.currentEntry = void 0;
2732
3208
  }
2733
3209
  }
2734
3210
  } finally {
2735
3211
  this.promptInFlight = false;
2736
3212
  }
2737
3213
  }
3214
+ // Execute a queue entry. User-prompt entries forward to the upstream
3215
+ // agent and pair with broadcastTurnComplete; internal entries run
3216
+ // their captured task closure. Reads entry.prompt at dispatch time
3217
+ // so updateQueuedPrompt's mutations are honoured.
3218
+ //
3219
+ // For user entries, broadcastPromptReceived fires HERE — not in
3220
+ // Session.prompt — so peer clients see prompt_received only when the
3221
+ // turn actually starts (a deliberate deviation from a naive reading
3222
+ // of RFD #533; see the comment on broadcastPromptReceived). Order on
3223
+ // the wire: prompt_queue_removed{started} (already emitted by
3224
+ // drainQueue) → prompt_received → upstream session/prompt.
3225
+ async runQueueEntry(entry) {
3226
+ if (entry.kind === "internal") {
3227
+ return entry.task();
3228
+ }
3229
+ this.broadcastPromptReceived(entry);
3230
+ let response;
3231
+ try {
3232
+ response = await this.agent.connection.request(
3233
+ "session/prompt",
3234
+ {
3235
+ sessionId: this.upstreamSessionId,
3236
+ prompt: entry.prompt
3237
+ }
3238
+ );
3239
+ } catch (err) {
3240
+ this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
3241
+ throw err;
3242
+ }
3243
+ this.broadcastTurnComplete(entry.clientId, response);
3244
+ return response;
3245
+ }
2738
3246
  };
2739
3247
  function withCode(err, code) {
2740
3248
  err.code = code;
@@ -2745,6 +3253,7 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
2745
3253
  "current_model_update",
2746
3254
  "current_mode_update",
2747
3255
  "available_commands_update",
3256
+ "available_modes_update",
2748
3257
  "usage_update"
2749
3258
  ]);
2750
3259
  function isStateUpdate(method, params) {
@@ -2766,10 +3275,51 @@ function sameAdvertisedCommands(a, b) {
2766
3275
  }
2767
3276
  return true;
2768
3277
  }
2769
- function captureInternalChunk(capture, params) {
2770
- const obj = params ?? {};
2771
- const update = obj.update ?? {};
2772
- if (update.sessionUpdate !== "agent_message_chunk") {
3278
+ function sameAdvertisedModes(a, b) {
3279
+ if (a.length !== b.length) {
3280
+ return false;
3281
+ }
3282
+ for (let i = 0; i < a.length; i++) {
3283
+ if (a[i]?.id !== b[i]?.id || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
3284
+ return false;
3285
+ }
3286
+ }
3287
+ return true;
3288
+ }
3289
+ function extractAdvertisedModes(params) {
3290
+ const obj = params ?? {};
3291
+ const update = obj.update ?? {};
3292
+ if (update.sessionUpdate !== "available_modes_update") {
3293
+ return null;
3294
+ }
3295
+ const list = update.availableModes;
3296
+ if (!Array.isArray(list)) {
3297
+ return [];
3298
+ }
3299
+ const out = [];
3300
+ for (const raw of list) {
3301
+ if (!raw || typeof raw !== "object") {
3302
+ continue;
3303
+ }
3304
+ const m = raw;
3305
+ if (typeof m.id !== "string" || m.id.length === 0) {
3306
+ continue;
3307
+ }
3308
+ const mode = { id: m.id };
3309
+ if (typeof m.name === "string") {
3310
+ mode.name = m.name;
3311
+ }
3312
+ if (typeof m.description === "string") {
3313
+ mode.description = m.description;
3314
+ }
3315
+ out.push(mode);
3316
+ }
3317
+ return out;
3318
+ }
3319
+ function captureInternalChunk(capture, params) {
3320
+ const obj = params ?? {};
3321
+ const update = obj.update ?? {};
3322
+ if (update.sessionUpdate !== "agent_message_chunk") {
2773
3323
  return;
2774
3324
  }
2775
3325
  const content = update.content ?? {};
@@ -2921,7 +3471,7 @@ function firstLine(text, max) {
2921
3471
  }
2922
3472
 
2923
3473
  // src/core/session-store.ts
2924
- import * as fs4 from "fs/promises";
3474
+ import * as fs6 from "fs/promises";
2925
3475
  import * as path4 from "path";
2926
3476
  import { customAlphabet as customAlphabet2 } from "nanoid";
2927
3477
  import { z as z4 } from "zod";
@@ -2935,6 +3485,11 @@ var PersistedAgentCommand = z4.object({
2935
3485
  name: z4.string(),
2936
3486
  description: z4.string().optional()
2937
3487
  });
3488
+ var PersistedAgentMode = z4.object({
3489
+ id: z4.string(),
3490
+ name: z4.string().optional(),
3491
+ description: z4.string().optional()
3492
+ });
2938
3493
  var PersistedUsage = z4.object({
2939
3494
  used: z4.number().optional(),
2940
3495
  size: z4.number().optional(),
@@ -2980,6 +3535,7 @@ var SessionRecord = z4.object({
2980
3535
  currentMode: z4.string().optional(),
2981
3536
  currentUsage: PersistedUsage.optional(),
2982
3537
  agentCommands: z4.array(PersistedAgentCommand).optional(),
3538
+ agentModes: z4.array(PersistedAgentMode).optional(),
2983
3539
  createdAt: z4.string(),
2984
3540
  updatedAt: z4.string()
2985
3541
  });
@@ -2992,9 +3548,9 @@ function assertSafeId(id) {
2992
3548
  var SessionStore = class {
2993
3549
  async write(record) {
2994
3550
  assertSafeId(record.sessionId);
2995
- await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
3551
+ await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2996
3552
  const full = { version: 1, ...record };
2997
- await fs4.writeFile(
3553
+ await fs6.writeFile(
2998
3554
  paths.sessionFile(record.sessionId),
2999
3555
  JSON.stringify(full, null, 2) + "\n",
3000
3556
  { encoding: "utf8", mode: 384 }
@@ -3006,7 +3562,7 @@ var SessionStore = class {
3006
3562
  }
3007
3563
  let raw;
3008
3564
  try {
3009
- raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
3565
+ raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
3010
3566
  } catch (err) {
3011
3567
  const e = err;
3012
3568
  if (e.code === "ENOENT") {
@@ -3025,7 +3581,7 @@ var SessionStore = class {
3025
3581
  return;
3026
3582
  }
3027
3583
  try {
3028
- await fs4.unlink(paths.sessionFile(sessionId));
3584
+ await fs6.unlink(paths.sessionFile(sessionId));
3029
3585
  } catch (err) {
3030
3586
  const e = err;
3031
3587
  if (e.code !== "ENOENT") {
@@ -3033,7 +3589,7 @@ var SessionStore = class {
3033
3589
  }
3034
3590
  }
3035
3591
  try {
3036
- await fs4.rmdir(paths.sessionDir(sessionId));
3592
+ await fs6.rmdir(paths.sessionDir(sessionId));
3037
3593
  } catch (err) {
3038
3594
  const e = err;
3039
3595
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -3063,7 +3619,7 @@ var SessionStore = class {
3063
3619
  async list() {
3064
3620
  let entries;
3065
3621
  try {
3066
- entries = await fs4.readdir(paths.sessionsDir());
3622
+ entries = await fs6.readdir(paths.sessionsDir());
3067
3623
  } catch (err) {
3068
3624
  const e = err;
3069
3625
  if (e.code === "ENOENT") {
@@ -3098,13 +3654,14 @@ function recordFromMemorySession(args) {
3098
3654
  currentMode: args.currentMode,
3099
3655
  currentUsage: args.currentUsage,
3100
3656
  agentCommands: args.agentCommands,
3657
+ agentModes: args.agentModes,
3101
3658
  createdAt: args.createdAt ?? now,
3102
3659
  updatedAt: args.updatedAt ?? now
3103
3660
  };
3104
3661
  }
3105
3662
 
3106
3663
  // src/core/history-store.ts
3107
- import * as fs5 from "fs/promises";
3664
+ import * as fs7 from "fs/promises";
3108
3665
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
3109
3666
  var DEFAULT_MAX_ENTRIES = 1e3;
3110
3667
  var HistoryStore = class {
@@ -3121,9 +3678,9 @@ var HistoryStore = class {
3121
3678
  return;
3122
3679
  }
3123
3680
  return this.enqueue(sessionId, async () => {
3124
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
3681
+ await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
3125
3682
  const line = JSON.stringify(entry) + "\n";
3126
- await fs5.appendFile(paths.historyFile(sessionId), line, {
3683
+ await fs7.appendFile(paths.historyFile(sessionId), line, {
3127
3684
  encoding: "utf8",
3128
3685
  mode: 384
3129
3686
  });
@@ -3134,9 +3691,9 @@ var HistoryStore = class {
3134
3691
  return;
3135
3692
  }
3136
3693
  return this.enqueue(sessionId, async () => {
3137
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
3694
+ await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
3138
3695
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
3139
- await fs5.writeFile(paths.historyFile(sessionId), body, {
3696
+ await fs7.writeFile(paths.historyFile(sessionId), body, {
3140
3697
  encoding: "utf8",
3141
3698
  mode: 384
3142
3699
  });
@@ -3153,7 +3710,7 @@ var HistoryStore = class {
3153
3710
  return this.enqueue(sessionId, async () => {
3154
3711
  let raw;
3155
3712
  try {
3156
- raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
3713
+ raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
3157
3714
  } catch (err) {
3158
3715
  const e = err;
3159
3716
  if (e.code === "ENOENT") {
@@ -3166,7 +3723,7 @@ var HistoryStore = class {
3166
3723
  return;
3167
3724
  }
3168
3725
  const trimmed = lines.slice(-maxEntries);
3169
- await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
3726
+ await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
3170
3727
  encoding: "utf8",
3171
3728
  mode: 384
3172
3729
  });
@@ -3182,7 +3739,7 @@ var HistoryStore = class {
3182
3739
  }
3183
3740
  let raw;
3184
3741
  try {
3185
- raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
3742
+ raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
3186
3743
  } catch (err) {
3187
3744
  const e = err;
3188
3745
  if (e.code === "ENOENT") {
@@ -3228,7 +3785,7 @@ var HistoryStore = class {
3228
3785
  }
3229
3786
  return this.enqueue(sessionId, async () => {
3230
3787
  try {
3231
- await fs5.unlink(paths.historyFile(sessionId));
3788
+ await fs7.unlink(paths.historyFile(sessionId));
3232
3789
  } catch (err) {
3233
3790
  const e = err;
3234
3791
  if (e.code !== "ENOENT") {
@@ -3236,7 +3793,7 @@ var HistoryStore = class {
3236
3793
  }
3237
3794
  }
3238
3795
  try {
3239
- await fs5.rmdir(paths.sessionDir(sessionId));
3796
+ await fs7.rmdir(paths.sessionDir(sessionId));
3240
3797
  } catch (err) {
3241
3798
  const e = err;
3242
3799
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -3260,25 +3817,25 @@ var HistoryStore = class {
3260
3817
  };
3261
3818
 
3262
3819
  // src/tui/history.ts
3263
- import { promises as fs6 } from "fs";
3820
+ import { promises as fs8 } from "fs";
3264
3821
  import * as path5 from "path";
3265
3822
  async function saveHistory(file, history) {
3266
- await fs6.mkdir(path5.dirname(file), { recursive: true });
3823
+ await fs8.mkdir(path5.dirname(file), { recursive: true });
3267
3824
  const lines = history.map((entry) => JSON.stringify(entry));
3268
- await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3825
+ await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3269
3826
  }
3270
3827
 
3271
3828
  // src/core/hydra-version.ts
3272
3829
  import { fileURLToPath } from "url";
3273
3830
  import * as path6 from "path";
3274
- import * as fs7 from "fs";
3831
+ import * as fs9 from "fs";
3275
3832
  function resolveVersion() {
3276
3833
  try {
3277
3834
  let dir = path6.dirname(fileURLToPath(import.meta.url));
3278
3835
  for (let i = 0; i < 8; i += 1) {
3279
3836
  const candidate = path6.join(dir, "package.json");
3280
- if (fs7.existsSync(candidate)) {
3281
- const pkg = JSON.parse(fs7.readFileSync(candidate, "utf8"));
3837
+ if (fs9.existsSync(candidate)) {
3838
+ const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
3282
3839
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
3283
3840
  return pkg.version;
3284
3841
  }
@@ -3296,6 +3853,7 @@ function resolveVersion() {
3296
3853
  var HYDRA_VERSION = resolveVersion();
3297
3854
 
3298
3855
  // src/core/session-manager.ts
3856
+ var QUEUE_REPLAY_TTL_MS = 15 * 60 * 1e3;
3299
3857
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3300
3858
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
3301
3859
  var SessionManager = class {
@@ -3308,6 +3866,7 @@ var SessionManager = class {
3308
3866
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
3309
3867
  this.defaultModels = options.defaultModels ?? {};
3310
3868
  this.logger = options.logger;
3869
+ this.npmRegistry = options.npmRegistry;
3311
3870
  }
3312
3871
  registry;
3313
3872
  sessions = /* @__PURE__ */ new Map();
@@ -3323,6 +3882,7 @@ var SessionManager = class {
3323
3882
  // back-to-back) don't lose writes via interleaved reads.
3324
3883
  metaWriteQueues = /* @__PURE__ */ new Map();
3325
3884
  logger;
3885
+ npmRegistry;
3326
3886
  async create(params) {
3327
3887
  const fresh = await this.bootstrapAgent({
3328
3888
  agentId: params.agentId,
@@ -3344,7 +3904,9 @@ var SessionManager = class {
3344
3904
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
3345
3905
  historyStore: this.histories,
3346
3906
  historyMaxEntries: this.sessionHistoryMaxEntries,
3347
- currentModel: fresh.initialModel
3907
+ currentModel: fresh.initialModel,
3908
+ currentMode: fresh.initialMode,
3909
+ agentModes: fresh.initialModes
3348
3910
  });
3349
3911
  await this.attachManagerHooks(session);
3350
3912
  return session;
@@ -3389,7 +3951,7 @@ var SessionManager = class {
3389
3951
  if (params.upstreamSessionId === "") {
3390
3952
  return this.doResurrectFromImport(params);
3391
3953
  }
3392
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
3954
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
3393
3955
  const agent = this.spawner({
3394
3956
  agentId: params.agentId,
3395
3957
  cwd: params.cwd,
@@ -3443,9 +4005,10 @@ var SessionManager = class {
3443
4005
  // this fix), fall back to the model the agent ships in its
3444
4006
  // session/load response body.
3445
4007
  currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
3446
- currentMode: params.currentMode,
4008
+ currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
3447
4009
  currentUsage: params.currentUsage,
3448
4010
  agentCommands: params.agentCommands,
4011
+ agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
3449
4012
  // Only gate the first-prompt title heuristic when we actually have
3450
4013
  // a title to preserve. A title-less session (lost to a write race
3451
4014
  // or never seeded) should re-derive from the next prompt rather
@@ -3488,9 +4051,10 @@ var SessionManager = class {
3488
4051
  // Prefer the stored value (set by a previous current_model_update);
3489
4052
  // fall back to whatever the agent ships in its session/new response.
3490
4053
  currentModel: params.currentModel ?? fresh.initialModel,
3491
- currentMode: params.currentMode,
4054
+ currentMode: params.currentMode ?? fresh.initialMode,
3492
4055
  currentUsage: params.currentUsage,
3493
4056
  agentCommands: params.agentCommands,
4057
+ agentModes: params.agentModes ?? fresh.initialModes,
3494
4058
  firstPromptSeeded: !!params.title,
3495
4059
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
3496
4060
  });
@@ -3500,7 +4064,7 @@ var SessionManager = class {
3500
4064
  }
3501
4065
  async resolveImportCwd(cwd) {
3502
4066
  try {
3503
- const stat2 = await fs8.stat(cwd);
4067
+ const stat2 = await fs10.stat(cwd);
3504
4068
  if (stat2.isDirectory()) {
3505
4069
  return cwd;
3506
4070
  }
@@ -3520,7 +4084,7 @@ var SessionManager = class {
3520
4084
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
3521
4085
  throw err;
3522
4086
  }
3523
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
4087
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
3524
4088
  const agent = this.spawner({
3525
4089
  agentId: params.agentId,
3526
4090
  cwd: params.cwd,
@@ -3557,11 +4121,15 @@ var SessionManager = class {
3557
4121
  } catch {
3558
4122
  }
3559
4123
  }
4124
+ const initialModes = extractInitialModes(newResult);
4125
+ const initialMode = extractInitialCurrentMode(newResult);
3560
4126
  return {
3561
4127
  agent,
3562
4128
  upstreamSessionId: sessionIdRaw,
3563
4129
  agentMeta: newResult._meta,
3564
- initialModel
4130
+ initialModel,
4131
+ initialModes: initialModes.length > 0 ? initialModes : void 0,
4132
+ initialMode
3565
4133
  };
3566
4134
  } catch (err) {
3567
4135
  await agent.kill().catch(() => void 0);
@@ -3613,6 +4181,15 @@ var SessionManager = class {
3613
4181
  }))
3614
4182
  }).catch(() => void 0);
3615
4183
  });
4184
+ session.onAgentModesChange((modes) => {
4185
+ void this.persistSnapshot(session.sessionId, {
4186
+ agentModes: modes.map((m) => ({
4187
+ id: m.id,
4188
+ ...m.name !== void 0 ? { name: m.name } : {},
4189
+ ...m.description !== void 0 ? { description: m.description } : {}
4190
+ }))
4191
+ }).catch(() => void 0);
4192
+ });
3616
4193
  this.sessions.set(session.sessionId, session);
3617
4194
  await this.enqueueMetaWrite(session.sessionId, async () => {
3618
4195
  const existing = await this.store.read(session.sessionId);
@@ -3655,6 +4232,7 @@ var SessionManager = class {
3655
4232
  currentMode: record.currentMode,
3656
4233
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
3657
4234
  agentCommands: record.agentCommands,
4235
+ agentModes: record.agentModes,
3658
4236
  createdAt: record.createdAt
3659
4237
  };
3660
4238
  }
@@ -3932,6 +4510,7 @@ var SessionManager = class {
3932
4510
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
3933
4511
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
3934
4512
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
4513
+ ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
3935
4514
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3936
4515
  });
3937
4516
  });
@@ -3965,6 +4544,53 @@ var SessionManager = class {
3965
4544
  }
3966
4545
  await Promise.allSettled(pending);
3967
4546
  }
4547
+ // Startup hook: scan persisted sessions for non-empty queue files,
4548
+ // apply the TTL, resurrect anything with surviving entries, and
4549
+ // replay them through the normal queue path. Called from the daemon
4550
+ // boot sequence; failures per session are logged and don't block
4551
+ // the boot.
4552
+ //
4553
+ // Concurrency is deliberately sequential — resurrect each session
4554
+ // one at a time so a runaway daemon with 100 queued sessions
4555
+ // doesn't burst-spawn 100 agents on startup. Inside a single
4556
+ // session, the queue still drains in parallel-friendly fashion via
4557
+ // drainQueue once resurrect() completes.
4558
+ async resurrectPendingQueues() {
4559
+ const records = await this.store.list().catch(() => []);
4560
+ for (const rec of records) {
4561
+ const queue = await loadQueue(rec.sessionId).catch(() => []);
4562
+ if (queue.length === 0) continue;
4563
+ const now = Date.now();
4564
+ const fresh = queue.filter((e) => now - e.enqueuedAt < QUEUE_REPLAY_TTL_MS);
4565
+ const dropped = queue.length - fresh.length;
4566
+ if (dropped > 0) {
4567
+ this.logger?.info(
4568
+ `queue replay: dropping ${dropped} stale prompt(s) for ${rec.sessionId} (TTL ${QUEUE_REPLAY_TTL_MS / 1e3}s)`
4569
+ );
4570
+ await rewriteQueue(rec.sessionId, fresh).catch(() => void 0);
4571
+ }
4572
+ if (fresh.length === 0) continue;
4573
+ const fromDisk = await this.loadFromDisk(rec.sessionId).catch(() => void 0);
4574
+ if (!fromDisk) {
4575
+ this.logger?.warn(
4576
+ `queue replay: no meta for ${rec.sessionId}; discarding ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"}`
4577
+ );
4578
+ await rewriteQueue(rec.sessionId, []).catch(() => void 0);
4579
+ continue;
4580
+ }
4581
+ try {
4582
+ const session = await this.resurrect(fromDisk);
4583
+ this.logger?.info(
4584
+ `queue replay: resurrected ${rec.sessionId} and replaying ${fresh.length} prompt(s)`
4585
+ );
4586
+ session.replayPersistedQueue(fresh);
4587
+ } catch (err) {
4588
+ this.logger?.warn(
4589
+ `queue replay: failed to resurrect ${rec.sessionId}: ${err.message}`
4590
+ );
4591
+ }
4592
+ }
4593
+ }
3968
4594
  };
3969
4595
  function mergeForPersistence(session, existing) {
3970
4596
  const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
@@ -3974,6 +4600,18 @@ function mergeForPersistence(session, existing) {
3974
4600
  return { name: c.name };
3975
4601
  }) : void 0;
3976
4602
  const agentCommands = persistedCommands ?? existing?.agentCommands;
4603
+ const sessionModes = session.availableModes();
4604
+ const persistedModes = sessionModes.length > 0 ? sessionModes.map((m) => {
4605
+ const out = { id: m.id };
4606
+ if (m.name !== void 0) {
4607
+ out.name = m.name;
4608
+ }
4609
+ if (m.description !== void 0) {
4610
+ out.description = m.description;
4611
+ }
4612
+ return out;
4613
+ }) : void 0;
4614
+ const agentModes = persistedModes ?? existing?.agentModes;
3977
4615
  return recordFromMemorySession({
3978
4616
  sessionId: session.sessionId,
3979
4617
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -3989,6 +4627,7 @@ function mergeForPersistence(session, existing) {
3989
4627
  currentMode: session.currentMode ?? existing?.currentMode,
3990
4628
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
3991
4629
  agentCommands,
4630
+ agentModes,
3992
4631
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
3993
4632
  });
3994
4633
  }
@@ -4051,9 +4690,103 @@ function asString(value) {
4051
4690
  const trimmed = value.trim();
4052
4691
  return trimmed.length > 0 ? trimmed : void 0;
4053
4692
  }
4693
+ function nonEmptyOrUndefined(arr) {
4694
+ return arr.length > 0 ? arr : void 0;
4695
+ }
4696
+ function extractInitialModes(result) {
4697
+ const direct = parseModesList(result.availableModes);
4698
+ if (direct.length > 0) {
4699
+ return direct;
4700
+ }
4701
+ const modes = result.modes;
4702
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
4703
+ const fromModesObj = parseModesList(
4704
+ modes.availableModes
4705
+ );
4706
+ if (fromModesObj.length > 0) {
4707
+ return fromModesObj;
4708
+ }
4709
+ }
4710
+ const meta = result._meta;
4711
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4712
+ for (const [key, value] of Object.entries(
4713
+ meta
4714
+ )) {
4715
+ if (key === "hydra-acp") {
4716
+ continue;
4717
+ }
4718
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4719
+ const fromMeta = parseModesList(
4720
+ value.availableModes
4721
+ );
4722
+ if (fromMeta.length > 0) {
4723
+ return fromMeta;
4724
+ }
4725
+ }
4726
+ }
4727
+ }
4728
+ return [];
4729
+ }
4730
+ function extractInitialCurrentMode(result) {
4731
+ const direct = asString(result.currentModeId) ?? asString(result.currentMode) ?? asString(result.modeId) ?? asString(result.mode);
4732
+ if (direct) {
4733
+ return direct;
4734
+ }
4735
+ const modes = result.modes;
4736
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
4737
+ const m = asString(modes.currentModeId) ?? asString(modes.currentMode);
4738
+ if (m) {
4739
+ return m;
4740
+ }
4741
+ }
4742
+ const meta = result._meta;
4743
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4744
+ for (const [key, value] of Object.entries(
4745
+ meta
4746
+ )) {
4747
+ if (key === "hydra-acp") {
4748
+ continue;
4749
+ }
4750
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4751
+ const m = asString(value.currentModeId) ?? asString(value.currentMode) ?? asString(value.modeId);
4752
+ if (m) {
4753
+ return m;
4754
+ }
4755
+ }
4756
+ }
4757
+ }
4758
+ return void 0;
4759
+ }
4760
+ function parseModesList(list) {
4761
+ if (!Array.isArray(list)) {
4762
+ return [];
4763
+ }
4764
+ const out = [];
4765
+ for (const raw of list) {
4766
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
4767
+ continue;
4768
+ }
4769
+ const r = raw;
4770
+ const id = asString(r.id) ?? asString(r.modeId);
4771
+ if (!id) {
4772
+ continue;
4773
+ }
4774
+ const mode = { id };
4775
+ const name = asString(r.name);
4776
+ if (name) {
4777
+ mode.name = name;
4778
+ }
4779
+ const description = asString(r.description);
4780
+ if (description) {
4781
+ mode.description = description;
4782
+ }
4783
+ out.push(mode);
4784
+ }
4785
+ return out;
4786
+ }
4054
4787
  async function loadPromptHistorySafely(sessionId) {
4055
4788
  try {
4056
- const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
4789
+ const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
4057
4790
  const out = [];
4058
4791
  for (const line of raw.split("\n")) {
4059
4792
  if (line.length === 0) {
@@ -4074,7 +4807,7 @@ async function loadPromptHistorySafely(sessionId) {
4074
4807
  }
4075
4808
  async function historyMtimeIso(sessionId) {
4076
4809
  try {
4077
- const st = await fs8.stat(paths.historyFile(sessionId));
4810
+ const st = await fs10.stat(paths.historyFile(sessionId));
4078
4811
  return new Date(st.mtimeMs).toISOString();
4079
4812
  } catch {
4080
4813
  return void 0;
@@ -4083,7 +4816,7 @@ async function historyMtimeIso(sessionId) {
4083
4816
 
4084
4817
  // src/core/extensions.ts
4085
4818
  import { spawn as spawn4 } from "child_process";
4086
- import * as fs9 from "fs";
4819
+ import * as fs11 from "fs";
4087
4820
  import * as fsp3 from "fs/promises";
4088
4821
  import * as path7 from "path";
4089
4822
  var RESTART_BASE_MS = 1e3;
@@ -4366,7 +5099,7 @@ var ExtensionManager = class {
4366
5099
  }
4367
5100
  const ext = entry.config;
4368
5101
  const command = ext.command.length > 0 ? ext.command : [ext.name];
4369
- const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
5102
+ const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
4370
5103
  flags: "a"
4371
5104
  });
4372
5105
  logStream.write(
@@ -4378,7 +5111,7 @@ var ExtensionManager = class {
4378
5111
  HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
4379
5112
  HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
4380
5113
  HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
4381
- HYDRA_ACP_TOKEN: ctx.daemonToken,
5114
+ HYDRA_ACP_TOKEN: ctx.serviceToken,
4382
5115
  HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
4383
5116
  HYDRA_ACP_HOME: ctx.hydraHome,
4384
5117
  HYDRA_ACP_EXTENSION_NAME: ext.name,
@@ -4416,7 +5149,7 @@ var ExtensionManager = class {
4416
5149
  }
4417
5150
  if (typeof child.pid === "number") {
4418
5151
  try {
4419
- fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
5152
+ fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
4420
5153
  `, {
4421
5154
  encoding: "utf8",
4422
5155
  mode: 384
@@ -4441,7 +5174,7 @@ var ExtensionManager = class {
4441
5174
  });
4442
5175
  child.on("exit", (code, signal) => {
4443
5176
  try {
4444
- fs9.unlinkSync(paths.extensionPidFile(ext.name));
5177
+ fs11.unlinkSync(paths.extensionPidFile(ext.name));
4445
5178
  } catch {
4446
5179
  }
4447
5180
  logStream.write(
@@ -4497,8 +5230,227 @@ function withCode2(err, code) {
4497
5230
  return err;
4498
5231
  }
4499
5232
 
5233
+ // src/core/session-tokens.ts
5234
+ import * as fs12 from "fs/promises";
5235
+ import * as path8 from "path";
5236
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
5237
+ var TOKEN_PREFIX = "hydra_session_";
5238
+ var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
5239
+ var ID_LENGTH = 12;
5240
+ var TOKEN_BYTES = 32;
5241
+ var WRITE_DEBOUNCE_MS = 50;
5242
+ function tokensFilePath() {
5243
+ return path8.join(paths.home(), "session-tokens.json");
5244
+ }
5245
+ function sha256Hex(input) {
5246
+ return createHash("sha256").update(input).digest("hex");
5247
+ }
5248
+ function randomHex(bytes) {
5249
+ return randomBytes(bytes).toString("hex");
5250
+ }
5251
+ function generateId() {
5252
+ return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
5253
+ }
5254
+ function generateToken() {
5255
+ return `${TOKEN_PREFIX}${randomHex(TOKEN_BYTES)}`;
5256
+ }
5257
+ var SessionTokenStore = class _SessionTokenStore {
5258
+ records = /* @__PURE__ */ new Map();
5259
+ // keyed by hash
5260
+ writeTimer = null;
5261
+ writeInflight = null;
5262
+ constructor(records) {
5263
+ for (const r of records) {
5264
+ this.records.set(r.hash, r);
5265
+ }
5266
+ }
5267
+ static async load() {
5268
+ let records = [];
5269
+ try {
5270
+ const raw = await fs12.readFile(tokensFilePath(), "utf8");
5271
+ const parsed = JSON.parse(raw);
5272
+ if (parsed && Array.isArray(parsed.records)) {
5273
+ records = parsed.records.filter(isRecord);
5274
+ }
5275
+ } catch (err) {
5276
+ const e = err;
5277
+ if (e.code !== "ENOENT") {
5278
+ throw err;
5279
+ }
5280
+ }
5281
+ const store = new _SessionTokenStore(records);
5282
+ const removed = store.sweepExpired(/* @__PURE__ */ new Date());
5283
+ if (removed > 0) {
5284
+ await store.flush();
5285
+ }
5286
+ return store;
5287
+ }
5288
+ async issue(opts = {}) {
5289
+ const token = generateToken();
5290
+ const hash = sha256Hex(token);
5291
+ const id = generateId();
5292
+ const now = /* @__PURE__ */ new Date();
5293
+ const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TTL_SEC;
5294
+ const expiresAt = new Date(now.getTime() + ttlSec * 1e3);
5295
+ const record = {
5296
+ id,
5297
+ hash,
5298
+ label: opts.label,
5299
+ createdAt: now.toISOString(),
5300
+ expiresAt: expiresAt.toISOString(),
5301
+ lastUsedAt: now.toISOString()
5302
+ };
5303
+ this.records.set(hash, record);
5304
+ this.scheduleWrite();
5305
+ return { id, token, expiresAt: record.expiresAt };
5306
+ }
5307
+ // Verifies a presented token. Returns the matching record id (so the
5308
+ // caller can revoke it on logout) and bumps lastUsedAt; returns
5309
+ // undefined when no record matches or when the matched record has
5310
+ // expired.
5311
+ async verify(token) {
5312
+ if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
5313
+ return void 0;
5314
+ }
5315
+ const hash = sha256Hex(token);
5316
+ const record = this.records.get(hash);
5317
+ if (!record) {
5318
+ return void 0;
5319
+ }
5320
+ const expected = Buffer.from(record.hash, "hex");
5321
+ const actual = Buffer.from(hash, "hex");
5322
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
5323
+ return void 0;
5324
+ }
5325
+ const now = /* @__PURE__ */ new Date();
5326
+ if (new Date(record.expiresAt).getTime() <= now.getTime()) {
5327
+ this.records.delete(hash);
5328
+ this.scheduleWrite();
5329
+ return void 0;
5330
+ }
5331
+ record.lastUsedAt = now.toISOString();
5332
+ this.scheduleWrite();
5333
+ return record.id;
5334
+ }
5335
+ async revoke(id) {
5336
+ for (const [hash, r] of this.records) {
5337
+ if (r.id === id) {
5338
+ this.records.delete(hash);
5339
+ this.scheduleWrite();
5340
+ return true;
5341
+ }
5342
+ }
5343
+ return false;
5344
+ }
5345
+ async revokeAll() {
5346
+ const n = this.records.size;
5347
+ this.records.clear();
5348
+ this.scheduleWrite();
5349
+ return n;
5350
+ }
5351
+ list() {
5352
+ return Array.from(this.records.values()).map(({ id, label, createdAt, expiresAt, lastUsedAt }) => ({
5353
+ id,
5354
+ label,
5355
+ createdAt,
5356
+ expiresAt,
5357
+ lastUsedAt
5358
+ })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
5359
+ }
5360
+ sweepExpired(now = /* @__PURE__ */ new Date()) {
5361
+ let removed = 0;
5362
+ for (const [hash, r] of this.records) {
5363
+ if (new Date(r.expiresAt).getTime() <= now.getTime()) {
5364
+ this.records.delete(hash);
5365
+ removed += 1;
5366
+ }
5367
+ }
5368
+ if (removed > 0) {
5369
+ this.scheduleWrite();
5370
+ }
5371
+ return removed;
5372
+ }
5373
+ // Force any pending write to complete. Useful in tests and at shutdown.
5374
+ async flush() {
5375
+ if (this.writeTimer) {
5376
+ clearTimeout(this.writeTimer);
5377
+ this.writeTimer = null;
5378
+ }
5379
+ await this.persist();
5380
+ }
5381
+ scheduleWrite() {
5382
+ if (this.writeTimer) {
5383
+ return;
5384
+ }
5385
+ this.writeTimer = setTimeout(() => {
5386
+ this.writeTimer = null;
5387
+ this.persist().catch(() => {
5388
+ });
5389
+ }, WRITE_DEBOUNCE_MS);
5390
+ }
5391
+ async persist() {
5392
+ if (this.writeInflight) {
5393
+ await this.writeInflight;
5394
+ }
5395
+ const records = Array.from(this.records.values());
5396
+ const payload = JSON.stringify({ records }, null, 2) + "\n";
5397
+ this.writeInflight = (async () => {
5398
+ await fs12.mkdir(paths.home(), { recursive: true });
5399
+ await fs12.writeFile(tokensFilePath(), payload, {
5400
+ encoding: "utf8",
5401
+ mode: 384
5402
+ });
5403
+ })();
5404
+ try {
5405
+ await this.writeInflight;
5406
+ } finally {
5407
+ this.writeInflight = null;
5408
+ }
5409
+ }
5410
+ };
5411
+ function isRecord(value) {
5412
+ if (!value || typeof value !== "object") {
5413
+ return false;
5414
+ }
5415
+ const v = value;
5416
+ return typeof v.id === "string" && typeof v.hash === "string" && typeof v.createdAt === "string" && typeof v.expiresAt === "string" && typeof v.lastUsedAt === "string" && (v.label === void 0 || typeof v.label === "string");
5417
+ }
5418
+
4500
5419
  // src/daemon/auth.ts
4501
5420
  var BEARER_PREFIX = "Bearer ";
5421
+ var StaticTokenValidator = class {
5422
+ constructor(token) {
5423
+ this.token = token;
5424
+ }
5425
+ token;
5426
+ async validate(token) {
5427
+ return constantTimeEqual(token, this.token) ? "service" : void 0;
5428
+ }
5429
+ };
5430
+ var SessionTokenValidator = class {
5431
+ constructor(store) {
5432
+ this.store = store;
5433
+ }
5434
+ store;
5435
+ async validate(token) {
5436
+ return this.store.verify(token);
5437
+ }
5438
+ };
5439
+ var CompositeTokenValidator = class {
5440
+ constructor(validators) {
5441
+ this.validators = validators;
5442
+ }
5443
+ validators;
5444
+ async validate(token) {
5445
+ for (const v of this.validators) {
5446
+ const id = await v.validate(token);
5447
+ if (id !== void 0) {
5448
+ return id;
5449
+ }
5450
+ }
5451
+ return void 0;
5452
+ }
5453
+ };
4502
5454
  function bearerAuth(opts) {
4503
5455
  return async function authMiddleware(request, reply) {
4504
5456
  const header = request.headers.authorization;
@@ -4507,10 +5459,12 @@ function bearerAuth(opts) {
4507
5459
  return;
4508
5460
  }
4509
5461
  const token = header.slice(BEARER_PREFIX.length).trim();
4510
- if (!constantTimeEqual(token, opts.config.daemon.authToken)) {
5462
+ const identity = await opts.validator.validate(token);
5463
+ if (!identity) {
4511
5464
  reply.code(403).send({ error: "Invalid token" });
4512
5465
  return;
4513
5466
  }
5467
+ request.authIdentity = identity;
4514
5468
  };
4515
5469
  }
4516
5470
  function tokenFromUpgradeRequest(req) {
@@ -4549,6 +5503,40 @@ function constantTimeEqual(a, b) {
4549
5503
  return mismatch === 0;
4550
5504
  }
4551
5505
 
5506
+ // src/daemon/rate-limit.ts
5507
+ var AuthRateLimiter = class {
5508
+ entries = /* @__PURE__ */ new Map();
5509
+ maxFails;
5510
+ windowMs;
5511
+ constructor(maxFails = 10, windowMs = 15 * 60 * 1e3) {
5512
+ this.maxFails = maxFails;
5513
+ this.windowMs = windowMs;
5514
+ }
5515
+ isBlocked(ip) {
5516
+ const e = this.entries.get(ip);
5517
+ if (!e) {
5518
+ return false;
5519
+ }
5520
+ if (Date.now() - e.windowStart > this.windowMs) {
5521
+ this.entries.delete(ip);
5522
+ return false;
5523
+ }
5524
+ return e.fails >= this.maxFails;
5525
+ }
5526
+ recordFailure(ip) {
5527
+ const now = Date.now();
5528
+ const e = this.entries.get(ip);
5529
+ if (!e || now - e.windowStart > this.windowMs) {
5530
+ this.entries.set(ip, { fails: 1, windowStart: now });
5531
+ return;
5532
+ }
5533
+ e.fails += 1;
5534
+ }
5535
+ recordSuccess(ip) {
5536
+ this.entries.delete(ip);
5537
+ }
5538
+ };
5539
+
4552
5540
  // src/daemon/routes/sessions.ts
4553
5541
  import * as os3 from "os";
4554
5542
 
@@ -4579,6 +5567,7 @@ var BundleSession = z5.object({
4579
5567
  currentMode: z5.string().optional(),
4580
5568
  currentUsage: PersistedUsage.optional(),
4581
5569
  agentCommands: z5.array(PersistedAgentCommand).optional(),
5570
+ agentModes: z5.array(PersistedAgentMode).optional(),
4582
5571
  createdAt: z5.string(),
4583
5572
  updatedAt: z5.string()
4584
5573
  });
@@ -4612,6 +5601,7 @@ function encodeBundle(params) {
4612
5601
  ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
4613
5602
  ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
4614
5603
  ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
5604
+ ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
4615
5605
  createdAt: params.record.createdAt,
4616
5606
  updatedAt: params.record.updatedAt
4617
5607
  },
@@ -4670,6 +5660,8 @@ function mapUpdate(update) {
4670
5660
  return mapUsage(u);
4671
5661
  case "available_commands_update":
4672
5662
  return mapAvailableCommands(u);
5663
+ case "available_modes_update":
5664
+ return mapAvailableModes(u);
4673
5665
  case "session_info_update":
4674
5666
  return mapSessionInfo(u);
4675
5667
  default:
@@ -4731,6 +5723,31 @@ function mapAvailableCommands(u) {
4731
5723
  }
4732
5724
  return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
4733
5725
  }
5726
+ function mapAvailableModes(u) {
5727
+ const list = u.availableModes;
5728
+ if (!Array.isArray(list)) {
5729
+ return null;
5730
+ }
5731
+ const modes = [];
5732
+ for (const raw of list) {
5733
+ if (!raw || typeof raw !== "object") {
5734
+ continue;
5735
+ }
5736
+ const m = raw;
5737
+ if (typeof m.id !== "string" || m.id.length === 0) {
5738
+ continue;
5739
+ }
5740
+ const mode = { id: sanitizeSingleLine(m.id) };
5741
+ if (typeof m.name === "string") {
5742
+ mode.name = sanitizeSingleLine(m.name);
5743
+ }
5744
+ if (typeof m.description === "string") {
5745
+ mode.description = sanitizeSingleLine(m.description);
5746
+ }
5747
+ modes.push(mode);
5748
+ }
5749
+ return { kind: "available-modes", modes };
5750
+ }
4734
5751
  function mapUsage(u) {
4735
5752
  const event = { kind: "usage-update" };
4736
5753
  if (typeof u.used === "number") {
@@ -4851,7 +5868,7 @@ function mapPlan(u) {
4851
5868
  return { kind: "plan", entries: normalized };
4852
5869
  }
4853
5870
  function mapMode(u) {
4854
- const mode = readString(u, "currentMode") ?? readString(u, "mode");
5871
+ const mode = readString(u, "currentModeId") ?? readString(u, "currentMode") ?? readString(u, "mode");
4855
5872
  if (!mode) {
4856
5873
  return null;
4857
5874
  }
@@ -5514,6 +6531,157 @@ function registerConfigRoutes(app, defaults) {
5514
6531
  });
5515
6532
  }
5516
6533
 
6534
+ // src/daemon/routes/auth.ts
6535
+ import { z as z6 } from "zod";
6536
+
6537
+ // src/core/password.ts
6538
+ import * as fs13 from "fs/promises";
6539
+ import * as path9 from "path";
6540
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
6541
+ import { promisify } from "util";
6542
+ var scryptAsync = promisify(scrypt);
6543
+ function passwordHashPath() {
6544
+ return path9.join(paths.home(), "password-hash");
6545
+ }
6546
+ var DEFAULT_N = 1 << 15;
6547
+ var MAX_MEM = 128 * 1024 * 1024;
6548
+ async function hasPassword() {
6549
+ try {
6550
+ const text = await fs13.readFile(passwordHashPath(), "utf8");
6551
+ return text.trim().length > 0;
6552
+ } catch (err) {
6553
+ const e = err;
6554
+ if (e.code === "ENOENT") {
6555
+ return false;
6556
+ }
6557
+ throw err;
6558
+ }
6559
+ }
6560
+ async function verifyPassword(plaintext) {
6561
+ if (typeof plaintext !== "string" || plaintext.length === 0) {
6562
+ return false;
6563
+ }
6564
+ let line;
6565
+ try {
6566
+ line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
6567
+ } catch (err) {
6568
+ const e = err;
6569
+ if (e.code === "ENOENT") {
6570
+ return false;
6571
+ }
6572
+ throw err;
6573
+ }
6574
+ const parts = line.split("$");
6575
+ if (parts.length !== 6 || parts[0] !== "scrypt") {
6576
+ return false;
6577
+ }
6578
+ const N = parseInt(parts[1], 10);
6579
+ const r = parseInt(parts[2], 10);
6580
+ const p = parseInt(parts[3], 10);
6581
+ if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
6582
+ return false;
6583
+ }
6584
+ const salt = Buffer.from(parts[4], "hex");
6585
+ const expected = Buffer.from(parts[5], "hex");
6586
+ if (salt.length === 0 || expected.length === 0) {
6587
+ return false;
6588
+ }
6589
+ const actual = await scryptAsync(plaintext, salt, expected.length, {
6590
+ N,
6591
+ r,
6592
+ p,
6593
+ maxmem: MAX_MEM
6594
+ });
6595
+ if (actual.length !== expected.length) {
6596
+ return false;
6597
+ }
6598
+ return timingSafeEqual2(actual, expected);
6599
+ }
6600
+
6601
+ // src/daemon/routes/auth.ts
6602
+ var LoginBody = z6.object({
6603
+ password: z6.string().min(1),
6604
+ label: z6.string().min(1).max(256).optional(),
6605
+ ttlSec: z6.number().int().positive().optional()
6606
+ });
6607
+ var LogoutBody = z6.object({
6608
+ id: z6.string().optional()
6609
+ }).optional();
6610
+ function registerAuthRoutes(app, deps) {
6611
+ app.post(
6612
+ "/v1/auth/login",
6613
+ { config: { skipAuth: true } },
6614
+ async (request, reply) => {
6615
+ const ip = remoteIp(request);
6616
+ if (deps.rateLimiter.isBlocked(ip)) {
6617
+ return reply.code(429).send({
6618
+ error: "Too many failed attempts; try again later."
6619
+ });
6620
+ }
6621
+ let body;
6622
+ try {
6623
+ body = LoginBody.parse(request.body);
6624
+ } catch {
6625
+ return reply.code(400).send({ error: "Invalid request body" });
6626
+ }
6627
+ if (!await hasPassword()) {
6628
+ return reply.code(403).send({
6629
+ error: "No password configured. Run `hydra-acp auth password` on the daemon host."
6630
+ });
6631
+ }
6632
+ const ok = await verifyPassword(body.password);
6633
+ if (!ok) {
6634
+ deps.rateLimiter.recordFailure(ip);
6635
+ return reply.code(401).send({ error: "Invalid password" });
6636
+ }
6637
+ deps.rateLimiter.recordSuccess(ip);
6638
+ const issued = await deps.store.issue({
6639
+ label: body.label,
6640
+ ttlSec: body.ttlSec
6641
+ });
6642
+ return reply.code(200).send({
6643
+ session_token: issued.token,
6644
+ id: issued.id,
6645
+ expires_at: issued.expiresAt
6646
+ });
6647
+ }
6648
+ );
6649
+ app.post("/v1/auth/logout", async (request, reply) => {
6650
+ let body = void 0;
6651
+ try {
6652
+ body = LogoutBody.parse(request.body ?? void 0);
6653
+ } catch {
6654
+ return reply.code(400).send({ error: "Invalid request body" });
6655
+ }
6656
+ const id = body?.id ?? request.authIdentity;
6657
+ if (!id || id === "service") {
6658
+ return reply.code(200).send({ revoked: false });
6659
+ }
6660
+ const revoked = await deps.store.revoke(id);
6661
+ return reply.code(200).send({ revoked });
6662
+ });
6663
+ app.get("/v1/auth/verify", async (_request, reply) => {
6664
+ return reply.code(200).send({ ok: true });
6665
+ });
6666
+ app.get("/v1/auth/sessions", async (_request, reply) => {
6667
+ return reply.code(200).send({ sessions: deps.store.list() });
6668
+ });
6669
+ app.delete(
6670
+ "/v1/auth/sessions/:id",
6671
+ async (request, reply) => {
6672
+ const id = request.params.id;
6673
+ const revoked = await deps.store.revoke(id);
6674
+ if (!revoked) {
6675
+ return reply.code(404).send({ error: "Not found" });
6676
+ }
6677
+ return reply.code(204).send();
6678
+ }
6679
+ );
6680
+ }
6681
+ function remoteIp(request) {
6682
+ return request.ip || "unknown";
6683
+ }
6684
+
5517
6685
  // src/daemon/acp-ws.ts
5518
6686
  import { nanoid as nanoid2 } from "nanoid";
5519
6687
 
@@ -5590,12 +6758,12 @@ function wsToMessageStream(ws) {
5590
6758
 
5591
6759
  // src/daemon/acp-ws.ts
5592
6760
  function registerAcpWsEndpoint(app, deps) {
5593
- app.get("/acp", { websocket: true }, (socket, request) => {
6761
+ app.get("/acp", { websocket: true }, async (socket, request) => {
5594
6762
  const token = tokenFromUpgradeRequest({
5595
6763
  headers: request.headers,
5596
6764
  url: request.url
5597
6765
  });
5598
- if (!token || !constantTimeEqual(token, deps.config.daemon.authToken)) {
6766
+ if (!token || !await deps.validator.validate(token)) {
5599
6767
  socket.close(4401, "Unauthorized");
5600
6768
  return;
5601
6769
  }
@@ -5646,8 +6814,15 @@ function registerAcpWsEndpoint(app, deps) {
5646
6814
  }
5647
6815
  })();
5648
6816
  });
6817
+ const modesPayload = buildModesPayload(session);
5649
6818
  return {
5650
6819
  sessionId: session.sessionId,
6820
+ // session/new is implicitly an attach; mirror session/attach's
6821
+ // shape by including the clientId so deferred-echo clients
6822
+ // (TUI's queue work) can recognize their own prompt_queue_added
6823
+ // events without an extra round-trip.
6824
+ clientId: client.clientId,
6825
+ ...modesPayload ? { modes: modesPayload } : {},
5651
6826
  _meta: buildResponseMeta(session)
5652
6827
  };
5653
6828
  });
@@ -5708,6 +6883,7 @@ function registerAcpWsEndpoint(app, deps) {
5708
6883
  await connection.notify(note.method, note.params);
5709
6884
  }
5710
6885
  session.replayPendingPermissions(client);
6886
+ const modesPayload = buildModesPayload(session);
5711
6887
  return {
5712
6888
  sessionId: session.sessionId,
5713
6889
  clientId: client.clientId,
@@ -5718,6 +6894,7 @@ function registerAcpWsEndpoint(app, deps) {
5718
6894
  // ran, not what was asked for.
5719
6895
  historyPolicy: appliedPolicy,
5720
6896
  replayed: replay.length,
6897
+ ...modesPayload ? { modes: modesPayload } : {},
5721
6898
  _meta: buildResponseMeta(session)
5722
6899
  };
5723
6900
  });
@@ -5751,7 +6928,29 @@ function registerAcpWsEndpoint(app, deps) {
5751
6928
  err.code = JsonRpcErrorCodes.SessionNotFound;
5752
6929
  throw err;
5753
6930
  }
5754
- const session = deps.manager.require(params.sessionId);
6931
+ let session = deps.manager.get(params.sessionId);
6932
+ if (!session) {
6933
+ const fromDisk = await deps.manager.loadFromDisk(params.sessionId);
6934
+ if (!fromDisk) {
6935
+ const err = new Error(
6936
+ `session ${params.sessionId} not found`
6937
+ );
6938
+ err.code = JsonRpcErrorCodes.SessionNotFound;
6939
+ throw err;
6940
+ }
6941
+ app.log.info(
6942
+ `session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
6943
+ );
6944
+ session = await deps.manager.resurrect(fromDisk);
6945
+ const client = bindClientToSession(
6946
+ connection,
6947
+ session,
6948
+ state,
6949
+ void 0,
6950
+ att.clientId
6951
+ );
6952
+ await session.attach(client, "none");
6953
+ }
5755
6954
  return session.prompt(att.clientId, params);
5756
6955
  });
5757
6956
  const handleCancelParams = (raw) => {
@@ -5783,6 +6982,26 @@ function registerAcpWsEndpoint(app, deps) {
5783
6982
  handleCancelParams(raw);
5784
6983
  return null;
5785
6984
  });
6985
+ connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
6986
+ const params = CancelPromptParams.parse(raw);
6987
+ const session = deps.manager.get(params.sessionId);
6988
+ if (!session) {
6989
+ const err = new Error(`session ${params.sessionId} not found`);
6990
+ err.code = JsonRpcErrorCodes.SessionNotFound;
6991
+ throw err;
6992
+ }
6993
+ return session.cancelQueuedPrompt(params.messageId);
6994
+ });
6995
+ connection.onRequest("hydra-acp/update_prompt", async (raw) => {
6996
+ const params = UpdatePromptParams.parse(raw);
6997
+ const session = deps.manager.get(params.sessionId);
6998
+ if (!session) {
6999
+ const err = new Error(`session ${params.sessionId} not found`);
7000
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7001
+ throw err;
7002
+ }
7003
+ return session.updateQueuedPrompt(params.messageId, params.prompt);
7004
+ });
5786
7005
  connection.onRequest("session/load", async (raw) => {
5787
7006
  const rawObj = raw ?? {};
5788
7007
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -5814,8 +7033,13 @@ function registerAcpWsEndpoint(app, deps) {
5814
7033
  await connection.notify(note.method, note.params);
5815
7034
  }
5816
7035
  session.replayPendingPermissions(client);
7036
+ const modesPayload = buildModesPayload(session);
5817
7037
  return {
5818
7038
  sessionId: session.sessionId,
7039
+ // Same as session/new: include clientId so the deferred-echo
7040
+ // path in queue-aware clients can recognize own broadcasts.
7041
+ clientId: client.clientId,
7042
+ ...modesPayload ? { modes: modesPayload } : {},
5819
7043
  _meta: buildResponseMeta(session)
5820
7044
  };
5821
7045
  });
@@ -5841,6 +7065,26 @@ function registerAcpWsEndpoint(app, deps) {
5841
7065
  });
5842
7066
  });
5843
7067
  }
7068
+ function buildModesPayload(session) {
7069
+ const modes = session.availableModes();
7070
+ if (modes.length === 0) {
7071
+ return void 0;
7072
+ }
7073
+ const availableModes = modes.map((m) => {
7074
+ const out = {
7075
+ id: m.id,
7076
+ // ACP spec requires `name` — fall back to id when the agent didn't
7077
+ // supply one so we never emit an invalid SessionMode.
7078
+ name: m.name ?? m.id
7079
+ };
7080
+ if (m.description !== void 0) {
7081
+ out.description = m.description;
7082
+ }
7083
+ return out;
7084
+ });
7085
+ const currentModeId = session.currentMode ?? modes[0].id;
7086
+ return { currentModeId, availableModes };
7087
+ }
5844
7088
  function buildResponseMeta(session) {
5845
7089
  const ours = {
5846
7090
  upstreamSessionId: session.upstreamSessionId,
@@ -5866,9 +7110,17 @@ function buildResponseMeta(session) {
5866
7110
  if (commands.length > 0) {
5867
7111
  ours.availableCommands = commands;
5868
7112
  }
7113
+ const modes = session.availableModes();
7114
+ if (modes.length > 0) {
7115
+ ours.availableModes = modes;
7116
+ }
5869
7117
  if (session.turnStartedAt !== void 0) {
5870
7118
  ours.turnStartedAt = session.turnStartedAt;
5871
7119
  }
7120
+ const queue = session.queueSnapshot();
7121
+ if (queue.length > 0) {
7122
+ ours.queue = queue;
7123
+ }
5872
7124
  return mergeMeta(session.agentMeta, ours);
5873
7125
  }
5874
7126
  function buildInitializeResult() {
@@ -5899,7 +7151,13 @@ function buildInitializeResult() {
5899
7151
  id: "bearer-token",
5900
7152
  description: "Bearer token presented at WS upgrade"
5901
7153
  }
5902
- ]
7154
+ ],
7155
+ // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
7156
+ // ACP clients ignore the field; capability-aware clients learn here
7157
+ // that hydra accepts concurrent session/prompt requests and emits
7158
+ // prompt_queue_* notifications so they can stop running their own
7159
+ // local queue.
7160
+ _meta: mergeMeta(void 0, { promptQueueing: true })
5903
7161
  };
5904
7162
  }
5905
7163
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
@@ -5913,7 +7171,7 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
5913
7171
  }
5914
7172
 
5915
7173
  // src/daemon/server.ts
5916
- async function startDaemon(config) {
7174
+ async function startDaemon(config, serviceToken) {
5917
7175
  ensureLoopbackOrTls(config);
5918
7176
  const httpsOptions = config.daemon.tls ? {
5919
7177
  key: await fsp4.readFile(config.daemon.tls.key),
@@ -5940,7 +7198,13 @@ async function startDaemon(config) {
5940
7198
  setNpmInstallLogger((msg) => {
5941
7199
  app.log.info(msg);
5942
7200
  });
5943
- const auth = bearerAuth({ config });
7201
+ const sessionTokenStore = await SessionTokenStore.load();
7202
+ const authRateLimiter = new AuthRateLimiter();
7203
+ const validator = new CompositeTokenValidator([
7204
+ new StaticTokenValidator(serviceToken),
7205
+ new SessionTokenValidator(sessionTokenStore)
7206
+ ]);
7207
+ const auth = bearerAuth({ validator });
5944
7208
  app.addHook("onRequest", async (request, reply) => {
5945
7209
  if (request.routeOptions.config?.skipAuth) {
5946
7210
  return;
@@ -5950,6 +7214,13 @@ async function startDaemon(config) {
5950
7214
  }
5951
7215
  await auth(request, reply);
5952
7216
  });
7217
+ const sweepInterval = setInterval(
7218
+ () => {
7219
+ sessionTokenStore.sweepExpired();
7220
+ },
7221
+ 5 * 60 * 1e3
7222
+ );
7223
+ sweepInterval.unref();
5953
7224
  const registry = new Registry(config);
5954
7225
  const agentLogger = {
5955
7226
  info: (msg) => app.log.info(msg),
@@ -5964,7 +7235,8 @@ async function startDaemon(config) {
5964
7235
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
5965
7236
  defaultModels: config.defaultModels,
5966
7237
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
5967
- logger: agentLogger
7238
+ logger: agentLogger,
7239
+ npmRegistry: config.npmRegistry
5968
7240
  });
5969
7241
  const extensions = new ExtensionManager(extensionList(config));
5970
7242
  registerHealthRoutes(app, HYDRA_VERSION);
@@ -5978,8 +7250,12 @@ async function startDaemon(config) {
5978
7250
  defaultAgent: config.defaultAgent,
5979
7251
  defaultCwd: config.defaultCwd
5980
7252
  });
7253
+ registerAuthRoutes(app, {
7254
+ store: sessionTokenStore,
7255
+ rateLimiter: authRateLimiter
7256
+ });
5981
7257
  registerAcpWsEndpoint(app, {
5982
- config,
7258
+ validator,
5983
7259
  manager,
5984
7260
  defaultAgent: config.defaultAgent
5985
7261
  });
@@ -6003,12 +7279,19 @@ async function startDaemon(config) {
6003
7279
  daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
6004
7280
  daemonHost: config.daemon.host,
6005
7281
  daemonPort: boundPort,
6006
- daemonToken: config.daemon.authToken,
7282
+ serviceToken,
6007
7283
  daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
6008
7284
  hydraHome: paths.home()
6009
7285
  });
6010
7286
  await extensions.start();
7287
+ void manager.resurrectPendingQueues().catch((err) => {
7288
+ app.log.warn(
7289
+ `queue replay scan failed: ${err.message}`
7290
+ );
7291
+ });
6011
7292
  const shutdown = async () => {
7293
+ clearInterval(sweepInterval);
7294
+ await sessionTokenStore.flush();
6012
7295
  await extensions.stop();
6013
7296
  await manager.closeAll();
6014
7297
  await manager.flushMetaWrites();
@@ -6016,7 +7299,7 @@ async function startDaemon(config) {
6016
7299
  setNpmInstallLogger(null);
6017
7300
  await app.close();
6018
7301
  try {
6019
- fs10.unlinkSync(paths.pidFile());
7302
+ fs14.unlinkSync(paths.pidFile());
6020
7303
  } catch {
6021
7304
  }
6022
7305
  try {
@@ -6057,9 +7340,10 @@ export {
6057
7340
  Session,
6058
7341
  SessionManager,
6059
7342
  defaultConfig,
6060
- ensureConfig,
6061
- generateAuthToken,
7343
+ ensureServiceToken,
7344
+ generateServiceToken,
6062
7345
  loadConfig,
7346
+ loadServiceToken,
6063
7347
  ndjsonStreamFromStdio,
6064
7348
  paths,
6065
7349
  planSpawn,