@hydra-acp/cli 0.1.22 → 0.1.24

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,13 +1,13 @@
1
1
  // src/daemon/server.ts
2
- import * as fs10 from "fs";
3
- import * as fsp4 from "fs/promises";
2
+ import * as fs14 from "fs";
3
+ import * as fsp5 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
6
6
  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"]
@@ -680,10 +693,12 @@ var RegistryDocument = z2.object({
680
693
  extensions: z2.array(z2.unknown()).optional()
681
694
  });
682
695
  var Registry = class {
683
- constructor(config) {
696
+ constructor(config, options = {}) {
684
697
  this.config = config;
698
+ this.options = options;
685
699
  }
686
700
  config;
701
+ options;
687
702
  cache;
688
703
  async load() {
689
704
  if (this.cache && this.isFresh(this.cache.fetchedAt)) {
@@ -733,12 +748,17 @@ var Registry = class {
733
748
  }
734
749
  const raw = await response.json();
735
750
  const data = RegistryDocument.parse(raw);
736
- return { fetchedAt: Date.now(), raw, data };
751
+ const cached = { fetchedAt: Date.now(), raw, data };
752
+ const hook = this.options.onFetched;
753
+ if (hook) {
754
+ void Promise.resolve().then(() => hook(data)).catch(() => void 0);
755
+ }
756
+ return cached;
737
757
  }
738
758
  async readDiskCache() {
739
759
  let text;
740
760
  try {
741
- text = await fs3.readFile(paths.registryCache(), "utf8");
761
+ text = await fs4.readFile(paths.registryCache(), "utf8");
742
762
  } catch (err) {
743
763
  const e = err;
744
764
  if (e.code === "ENOENT") {
@@ -764,7 +784,7 @@ var Registry = class {
764
784
  // without a lock file: the loser of the rename race just gets its
765
785
  // version replaced by the winner's.
766
786
  async writeDiskCache(cache) {
767
- await fs3.mkdir(paths.home(), { recursive: true });
787
+ await fs4.mkdir(paths.home(), { recursive: true });
768
788
  const final = paths.registryCache();
769
789
  const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
770
790
  const body = JSON.stringify(
@@ -773,10 +793,10 @@ var Registry = class {
773
793
  2
774
794
  ) + "\n";
775
795
  try {
776
- await fs3.writeFile(tmp, body, "utf8");
777
- await fs3.rename(tmp, final);
796
+ await fs4.writeFile(tmp, body, "utf8");
797
+ await fs4.rename(tmp, final);
778
798
  } catch (err) {
779
- await fs3.unlink(tmp).catch(() => void 0);
799
+ await fs4.unlink(tmp).catch(() => void 0);
780
800
  throw err;
781
801
  }
782
802
  }
@@ -794,7 +814,8 @@ function npxPackageBasename(agent) {
794
814
  const atIdx = afterSlash.lastIndexOf("@");
795
815
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
796
816
  }
797
- async function planSpawn(agent, callerArgs = []) {
817
+ async function planSpawn(agent, callerArgs = [], options = {}) {
818
+ const version = agent.version ?? "current";
798
819
  if (agent.distribution.npx) {
799
820
  const npx = agent.distribution.npx;
800
821
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
@@ -802,20 +823,23 @@ async function planSpawn(agent, callerArgs = []) {
802
823
  return {
803
824
  command: "npx",
804
825
  args: ["-y", npx.package, ...tail],
805
- env: npx.env ?? {}
826
+ env: npx.env ?? {},
827
+ version
806
828
  };
807
829
  }
808
830
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
809
831
  const binPath = await ensureNpmPackage({
810
832
  agentId: agent.id,
811
- version: agent.version ?? "current",
833
+ version,
812
834
  packageSpec: npx.package,
813
- bin
835
+ bin,
836
+ registry: options.npmRegistry
814
837
  });
815
838
  return {
816
839
  command: binPath,
817
840
  args: tail,
818
- env: npx.env ?? {}
841
+ env: npx.env ?? {},
842
+ version
819
843
  };
820
844
  }
821
845
  if (agent.distribution.binary) {
@@ -827,14 +851,15 @@ async function planSpawn(agent, callerArgs = []) {
827
851
  }
828
852
  const cmdPath = await ensureBinary({
829
853
  agentId: agent.id,
830
- version: agent.version ?? "current",
854
+ version,
831
855
  target
832
856
  });
833
857
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
834
858
  return {
835
859
  command: cmdPath,
836
860
  args: tail,
837
- env: target.env ?? {}
861
+ env: target.env ?? {},
862
+ version
838
863
  };
839
864
  }
840
865
  if (agent.distribution.uvx) {
@@ -843,7 +868,8 @@ async function planSpawn(agent, callerArgs = []) {
843
868
  return {
844
869
  command: "uvx",
845
870
  args: [uvx.package, ...tail],
846
- env: uvx.env ?? {}
871
+ env: uvx.env ?? {},
872
+ version
847
873
  };
848
874
  }
849
875
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
@@ -995,6 +1021,58 @@ function extractHydraMeta(meta) {
995
1021
  out.availableCommands = cmds;
996
1022
  }
997
1023
  }
1024
+ if (typeof obj.promptQueueing === "boolean") {
1025
+ out.promptQueueing = obj.promptQueueing;
1026
+ }
1027
+ if (Array.isArray(obj.queue)) {
1028
+ const entries = [];
1029
+ for (const raw of obj.queue) {
1030
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1031
+ continue;
1032
+ }
1033
+ const r = raw;
1034
+ const orig = r.originator;
1035
+ if (typeof r.messageId !== "string" || !orig || typeof orig.clientId !== "string" || !Array.isArray(r.prompt) || typeof r.position !== "number" || typeof r.enqueuedAt !== "number") {
1036
+ continue;
1037
+ }
1038
+ const originator = { clientId: orig.clientId };
1039
+ if (typeof orig.name === "string") originator.name = orig.name;
1040
+ if (typeof orig.version === "string") originator.version = orig.version;
1041
+ entries.push({
1042
+ messageId: r.messageId,
1043
+ originator,
1044
+ prompt: r.prompt,
1045
+ position: r.position,
1046
+ enqueuedAt: r.enqueuedAt
1047
+ });
1048
+ }
1049
+ if (entries.length > 0) {
1050
+ out.queue = entries;
1051
+ }
1052
+ }
1053
+ if (Array.isArray(obj.availableModes)) {
1054
+ const modes = [];
1055
+ for (const raw of obj.availableModes) {
1056
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1057
+ continue;
1058
+ }
1059
+ const m = raw;
1060
+ if (typeof m.id !== "string") {
1061
+ continue;
1062
+ }
1063
+ const mode = { id: m.id };
1064
+ if (typeof m.name === "string") {
1065
+ mode.name = m.name;
1066
+ }
1067
+ if (typeof m.description === "string") {
1068
+ mode.description = m.description;
1069
+ }
1070
+ modes.push(mode);
1071
+ }
1072
+ if (modes.length > 0) {
1073
+ out.availableModes = modes;
1074
+ }
1075
+ }
998
1076
  return out;
999
1077
  }
1000
1078
  function mergeMeta(passthrough, ours) {
@@ -1047,6 +1125,49 @@ var SessionPromptParams = z3.object({
1047
1125
  var SessionCancelParams = z3.object({
1048
1126
  sessionId: z3.string()
1049
1127
  });
1128
+ var PromptOriginatorSchema = z3.object({
1129
+ clientId: z3.string(),
1130
+ name: z3.string().optional(),
1131
+ version: z3.string().optional()
1132
+ });
1133
+ var PromptQueueAddedParams = z3.object({
1134
+ sessionId: z3.string(),
1135
+ messageId: z3.string(),
1136
+ originator: PromptOriginatorSchema,
1137
+ prompt: z3.array(z3.unknown()),
1138
+ // 0 = head (currently in-flight). At enqueue time the new entry's
1139
+ // position equals the count of entries already ahead of it.
1140
+ position: z3.number().int().nonnegative(),
1141
+ queueDepth: z3.number().int().positive(),
1142
+ enqueuedAt: z3.number()
1143
+ });
1144
+ var PromptQueueUpdatedParams = z3.object({
1145
+ sessionId: z3.string(),
1146
+ messageId: z3.string(),
1147
+ prompt: z3.array(z3.unknown())
1148
+ });
1149
+ var PromptQueueRemovedParams = z3.object({
1150
+ sessionId: z3.string(),
1151
+ messageId: z3.string(),
1152
+ reason: z3.enum(["started", "cancelled", "abandoned"])
1153
+ });
1154
+ var CancelPromptParams = z3.object({
1155
+ sessionId: z3.string(),
1156
+ messageId: z3.string()
1157
+ });
1158
+ var CancelPromptResult = z3.object({
1159
+ cancelled: z3.boolean(),
1160
+ reason: z3.enum(["ok", "not_found", "already_running"])
1161
+ });
1162
+ var UpdatePromptParams = z3.object({
1163
+ sessionId: z3.string(),
1164
+ messageId: z3.string(),
1165
+ prompt: z3.array(z3.unknown())
1166
+ });
1167
+ var UpdatePromptResult = z3.object({
1168
+ updated: z3.boolean(),
1169
+ reason: z3.enum(["ok", "not_found", "already_running"])
1170
+ });
1050
1171
  var ProxyInitializeParams = z3.object({
1051
1172
  protocolVersion: z3.number().optional(),
1052
1173
  proxyInfo: z3.object({
@@ -1334,6 +1455,9 @@ var JsonRpcConnection = class _JsonRpcConnection {
1334
1455
  var DEFAULT_STDERR_TAIL_BYTES = 4096;
1335
1456
  var AgentInstance = class _AgentInstance {
1336
1457
  agentId;
1458
+ // Version this process was spawned from — used by the registry-fetch
1459
+ // prune sweep to skip install dirs belonging to a live agent.
1460
+ version;
1337
1461
  cwd;
1338
1462
  connection;
1339
1463
  child;
@@ -1345,6 +1469,7 @@ var AgentInstance = class _AgentInstance {
1345
1469
  exitHandlers = [];
1346
1470
  constructor(opts, child) {
1347
1471
  this.agentId = opts.agentId;
1472
+ this.version = opts.plan.version;
1348
1473
  this.cwd = opts.cwd;
1349
1474
  this.child = child;
1350
1475
  this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
@@ -1435,7 +1560,7 @@ stderr: ${tail}` : reason;
1435
1560
  };
1436
1561
 
1437
1562
  // src/core/session-manager.ts
1438
- import * as fs8 from "fs/promises";
1563
+ import * as fs10 from "fs/promises";
1439
1564
  import * as os2 from "os";
1440
1565
  import { customAlphabet as customAlphabet3 } from "nanoid";
1441
1566
 
@@ -1469,6 +1594,47 @@ function hydraCommandsAsAdvertised() {
1469
1594
  }));
1470
1595
  }
1471
1596
 
1597
+ // src/core/queue-store.ts
1598
+ import * as fs5 from "fs/promises";
1599
+ async function rewriteQueue(sessionId, entries) {
1600
+ const file = paths.queueFile(sessionId);
1601
+ if (entries.length === 0) {
1602
+ await fs5.unlink(file).catch(() => void 0);
1603
+ return;
1604
+ }
1605
+ await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
1606
+ const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1607
+ await fs5.writeFile(file, body, "utf8");
1608
+ }
1609
+ async function loadQueue(sessionId) {
1610
+ const file = paths.queueFile(sessionId);
1611
+ let text;
1612
+ try {
1613
+ text = await fs5.readFile(file, "utf8");
1614
+ } catch (err) {
1615
+ if (err.code === "ENOENT") {
1616
+ return [];
1617
+ }
1618
+ throw err;
1619
+ }
1620
+ const out = [];
1621
+ for (const line of text.split("\n")) {
1622
+ if (!line.trim()) continue;
1623
+ try {
1624
+ const parsed = JSON.parse(line);
1625
+ if (parsed && typeof parsed.messageId === "string" && Array.isArray(parsed.prompt) && typeof parsed.enqueuedAt === "number") {
1626
+ out.push(parsed);
1627
+ }
1628
+ } catch {
1629
+ }
1630
+ }
1631
+ return out;
1632
+ }
1633
+ async function deleteQueue(sessionId) {
1634
+ const file = paths.queueFile(sessionId);
1635
+ await fs5.unlink(file).catch(() => void 0);
1636
+ }
1637
+
1472
1638
  // src/core/session.ts
1473
1639
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1474
1640
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
@@ -1504,7 +1670,18 @@ var Session = class {
1504
1670
  clients = /* @__PURE__ */ new Map();
1505
1671
  historyStore;
1506
1672
  promptQueue = [];
1673
+ // The entry that drainQueue is currently awaiting. Distinct from
1674
+ // promptQueue[0] (which is the *next* one to dequeue): once shifted
1675
+ // off, the entry lives here for the duration of its task() so
1676
+ // cancelQueuedPrompt can distinguish "still in line" from "running"
1677
+ // and return already_running for the latter.
1678
+ currentEntry;
1507
1679
  promptInFlight = false;
1680
+ // Serialize disk writes to the persisted queue file. Without this
1681
+ // chain, fire-and-forget appends/rewrites can interleave (e.g.
1682
+ // drainQueue's rewrite-to-empty races a sibling's append-on-
1683
+ // enqueue) and leave the file out of sync with in-memory state.
1684
+ queueWriteChain = Promise.resolve();
1508
1685
  closed = false;
1509
1686
  closeHandlers = [];
1510
1687
  titleHandlers = [];
@@ -1557,10 +1734,14 @@ var Session = class {
1557
1734
  // can deliver the merged list via _meta without depending on history
1558
1735
  // replay.
1559
1736
  agentAdvertisedCommands = [];
1737
+ // Last available_modes_update we observed from the agent. Same
1738
+ // pattern as commands: cache, persist, broadcast on change.
1739
+ agentAdvertisedModes = [];
1560
1740
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1561
1741
  // to mirror changes into meta.json so cold-resurrect attaches can
1562
1742
  // surface the latest snapshot via the attach response _meta.
1563
1743
  agentCommandsHandlers = [];
1744
+ agentModesHandlers = [];
1564
1745
  modelHandlers = [];
1565
1746
  modeHandlers = [];
1566
1747
  usageHandlers = [];
@@ -1579,6 +1760,9 @@ var Session = class {
1579
1760
  if (init.agentCommands && init.agentCommands.length > 0) {
1580
1761
  this.agentAdvertisedCommands = [...init.agentCommands];
1581
1762
  }
1763
+ if (init.agentModes && init.agentModes.length > 0) {
1764
+ this.agentAdvertisedModes = [...init.agentModes];
1765
+ }
1582
1766
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1583
1767
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1584
1768
  this.logger = init.logger;
@@ -1607,6 +1791,15 @@ var Session = class {
1607
1791
  }
1608
1792
  });
1609
1793
  }
1794
+ broadcastAvailableModes() {
1795
+ this.recordAndBroadcast("session/update", {
1796
+ sessionId: this.upstreamSessionId,
1797
+ update: {
1798
+ sessionUpdate: "available_modes_update",
1799
+ availableModes: this.agentAdvertisedModes
1800
+ }
1801
+ });
1802
+ }
1610
1803
  // Register session/update, session/request_permission, and onExit
1611
1804
  // handlers on an agent connection. Re-run on every /hydra agent so
1612
1805
  // the new agent is plumbed identically. The exit handler's identity
@@ -1624,6 +1817,11 @@ var Session = class {
1624
1817
  this.setAgentAdvertisedCommands(agentCmds);
1625
1818
  return;
1626
1819
  }
1820
+ const agentModes = extractAdvertisedModes(params);
1821
+ if (agentModes !== null) {
1822
+ this.setAgentAdvertisedModes(agentModes);
1823
+ return;
1824
+ }
1627
1825
  if (this.maybeApplyAgentModel(params)) {
1628
1826
  this.recordAndBroadcast("session/update", params);
1629
1827
  return;
@@ -1800,7 +1998,7 @@ var Session = class {
1800
1998
  sessionId,
1801
1999
  update: {
1802
2000
  sessionUpdate: "current_mode_update",
1803
- currentMode: this.currentMode
2001
+ currentModeId: this.currentMode
1804
2002
  }
1805
2003
  },
1806
2004
  recordedAt
@@ -1820,6 +2018,19 @@ var Session = class {
1820
2018
  recordedAt
1821
2019
  });
1822
2020
  }
2021
+ if (this.agentAdvertisedModes.length > 0) {
2022
+ out.push({
2023
+ method: "session/update",
2024
+ params: {
2025
+ sessionId,
2026
+ update: {
2027
+ sessionUpdate: "available_modes_update",
2028
+ availableModes: [...this.agentAdvertisedModes]
2029
+ }
2030
+ },
2031
+ recordedAt
2032
+ });
2033
+ }
1823
2034
  if (this.currentUsage !== void 0) {
1824
2035
  const u = this.currentUsage;
1825
2036
  const update = {
@@ -1910,34 +2121,28 @@ var Session = class {
1910
2121
  if (promptText.startsWith("/hydra")) {
1911
2122
  return this.handleSlashCommand(promptText);
1912
2123
  }
1913
- this.broadcastPromptReceived(client, params);
2124
+ const messageId = generateMessageId();
1914
2125
  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;
2126
+ return this.enqueueUserPrompt(client, params, messageId);
2127
+ }
2128
+ // DEVIATION FROM RFD #533: this broadcast is deliberately deferred
2129
+ // until the prompt actually becomes the active turn (i.e. drainQueue
2130
+ // is about to forward it to the agent), NOT when hydra first accepts
2131
+ // the request. The literal RFD doesn't pin the timing — it just says
2132
+ // peers should learn about the turn — but it was authored before
2133
+ // prompt queueing existed, so accept-time and start-time were the
2134
+ // same moment. With hydra's per-session FIFO, deferring gives
2135
+ // prompt_received a single, useful meaning ("the agent is now taking
2136
+ // a turn on this prompt"), which is how attached clients (notably
2137
+ // agent-shell) consume it. The accept-time signal that peers can use
2138
+ // for queue chip rendering is hydra-acp/prompt_queue_added instead.
2139
+ broadcastPromptReceived(entry) {
2140
+ const sentBy = { clientId: entry.originator.clientId };
2141
+ if (entry.originator.name) {
2142
+ sentBy.name = entry.originator.name;
2143
+ }
2144
+ if (entry.originator.version) {
2145
+ sentBy.version = entry.originator.version;
1941
2146
  }
1942
2147
  this.promptStartedAt = Date.now();
1943
2148
  this.recordAndBroadcast(
@@ -1946,14 +2151,14 @@ var Session = class {
1946
2151
  sessionId: this.sessionId,
1947
2152
  update: {
1948
2153
  sessionUpdate: "prompt_received",
1949
- messageId: generateMessageId(),
1950
- prompt: promptParams.prompt,
2154
+ messageId: entry.messageId,
2155
+ prompt: entry.prompt,
1951
2156
  sentBy
1952
2157
  }
1953
2158
  },
1954
- client.clientId
2159
+ entry.clientId
1955
2160
  );
1956
- const text = extractPromptText(promptParams.prompt);
2161
+ const text = extractPromptText(entry.prompt);
1957
2162
  if (text.length > 0) {
1958
2163
  this.recordAndBroadcast(
1959
2164
  "session/update",
@@ -1965,7 +2170,7 @@ var Session = class {
1965
2170
  _meta: { "hydra-acp": { compatFor: "prompt_received" } }
1966
2171
  }
1967
2172
  },
1968
- client.clientId
2173
+ entry.clientId
1969
2174
  );
1970
2175
  }
1971
2176
  }
@@ -1988,6 +2193,172 @@ var Session = class {
1988
2193
  originatorClientId
1989
2194
  );
1990
2195
  }
2196
+ // Total visible-or-running entries: the in-flight head (if any) plus
2197
+ // the queue's user-visible waiting entries. Internal entries don't
2198
+ // count — they're an implementation detail and the wire never
2199
+ // surfaces them.
2200
+ visibleQueueDepth() {
2201
+ let count = this.currentEntry?.kind === "user" && !this.currentEntry.cancelled ? 1 : 0;
2202
+ for (const entry of this.promptQueue) {
2203
+ if (entry.kind === "user" && !entry.cancelled) count += 1;
2204
+ }
2205
+ return count;
2206
+ }
2207
+ broadcastQueueAdded(entry) {
2208
+ const depth = this.visibleQueueDepth();
2209
+ const position = Math.max(0, depth - 1);
2210
+ const params = {
2211
+ sessionId: this.sessionId,
2212
+ messageId: entry.messageId,
2213
+ originator: entry.originator,
2214
+ prompt: entry.prompt,
2215
+ position,
2216
+ queueDepth: depth,
2217
+ enqueuedAt: entry.enqueuedAt
2218
+ };
2219
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
2220
+ }
2221
+ broadcastQueueUpdated(messageId, prompt) {
2222
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
2223
+ sessionId: this.sessionId,
2224
+ messageId,
2225
+ prompt
2226
+ });
2227
+ }
2228
+ broadcastQueueRemoved(messageId, reason) {
2229
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
2230
+ sessionId: this.sessionId,
2231
+ messageId,
2232
+ reason
2233
+ });
2234
+ }
2235
+ // Fan-out for queue lifecycle notifications. Ephemeral by design —
2236
+ // these signals describe transient daemon state, not conversation
2237
+ // content, so we deliberately bypass recordAndBroadcast (no history,
2238
+ // no idle-timer arm, no rewrite-for-client since we already emit the
2239
+ // hydra sessionId).
2240
+ broadcastQueueNotification(method, params) {
2241
+ for (const client of this.clients.values()) {
2242
+ void client.connection.notify(method, params).catch(() => void 0);
2243
+ }
2244
+ }
2245
+ // Snapshot of user-visible queue state at this moment. Surfaced to
2246
+ // late-attaching clients via the session/attach response _meta so
2247
+ // they boot with the same chip list as their peers without waiting
2248
+ // for new prompt_queue_added notifications. Internal entries are
2249
+ // omitted (they're not surfaced on the wire at all).
2250
+ queueSnapshot() {
2251
+ const out = [];
2252
+ let position = 0;
2253
+ if (this.currentEntry?.kind === "user" && !this.currentEntry.cancelled) {
2254
+ out.push({
2255
+ messageId: this.currentEntry.messageId,
2256
+ originator: this.currentEntry.originator,
2257
+ prompt: this.currentEntry.prompt,
2258
+ position: position++,
2259
+ enqueuedAt: this.currentEntry.enqueuedAt
2260
+ });
2261
+ }
2262
+ for (const entry of this.promptQueue) {
2263
+ if (entry.kind !== "user" || entry.cancelled) continue;
2264
+ out.push({
2265
+ messageId: entry.messageId,
2266
+ originator: entry.originator,
2267
+ prompt: entry.prompt,
2268
+ position: position++,
2269
+ enqueuedAt: entry.enqueuedAt
2270
+ });
2271
+ }
2272
+ return out;
2273
+ }
2274
+ // Wait for any pending queue-file writes to settle. Test hook so
2275
+ // assertions about on-disk state don't race with fire-and-forget
2276
+ // rewrites. Production code doesn't need this — the chain
2277
+ // self-serializes.
2278
+ async flushPersistWrites() {
2279
+ await this.queueWriteChain.catch(() => void 0);
2280
+ }
2281
+ // Push pre-existing queue entries back through the daemon-side
2282
+ // pipeline on startup. Called by SessionManager after resurrecting
2283
+ // a session that had a non-empty queue.ndjson on disk. Each entry
2284
+ // gets a synthetic UserPromptQueueEntry with no real caller
2285
+ // (resolve/reject are no-ops since the original WS is long gone),
2286
+ // then drainQueue picks it up like any other entry. Late-attaching
2287
+ // clients see the entries via prompt_queue_added broadcasts and the
2288
+ // attach-response snapshot.
2289
+ replayPersistedQueue(entries) {
2290
+ for (const persisted of entries) {
2291
+ const originator = {
2292
+ clientId: `hydra-resurrected_${persisted.messageId}`
2293
+ };
2294
+ if (persisted.originator.clientInfo.name !== void 0) {
2295
+ originator.name = persisted.originator.clientInfo.name;
2296
+ }
2297
+ if (persisted.originator.clientInfo.version !== void 0) {
2298
+ originator.version = persisted.originator.clientInfo.version;
2299
+ }
2300
+ const entry = {
2301
+ kind: "user",
2302
+ messageId: persisted.messageId,
2303
+ originator,
2304
+ // Synthetic clientId. broadcastTurnComplete uses this as
2305
+ // excludeClientId for the peer-only broadcast; with a synthetic
2306
+ // id no real attached client matches the exclude, so everyone
2307
+ // sees turn_complete — which is what we want, since none of
2308
+ // them originated this restart-replayed prompt.
2309
+ clientId: originator.clientId,
2310
+ prompt: persisted.prompt,
2311
+ enqueuedAt: persisted.enqueuedAt,
2312
+ cancelled: false,
2313
+ resolve: () => void 0,
2314
+ reject: () => void 0
2315
+ };
2316
+ this.promptQueue.push(entry);
2317
+ this.broadcastQueueAdded(entry);
2318
+ }
2319
+ void this.drainQueue();
2320
+ }
2321
+ // Drop a queued prompt by messageId. Returns already_running when
2322
+ // the messageId names the in-flight entry — callers should fall back
2323
+ // to session/cancel for that case. Originator-agnostic: any attached
2324
+ // client may cancel any queued prompt (matches the existing slack
2325
+ // :stop_sign: reaction UX and the TUI's queue-edit dispatcher).
2326
+ cancelQueuedPrompt(messageId) {
2327
+ if (this.currentEntry?.messageId === messageId) {
2328
+ return { cancelled: false, reason: "already_running" };
2329
+ }
2330
+ const idx = this.promptQueue.findIndex((e) => e.messageId === messageId);
2331
+ if (idx < 0) {
2332
+ return { cancelled: false, reason: "not_found" };
2333
+ }
2334
+ const entry = this.promptQueue[idx];
2335
+ entry.cancelled = true;
2336
+ this.promptQueue.splice(idx, 1);
2337
+ if (entry.kind === "user") {
2338
+ this.broadcastQueueRemoved(messageId, "cancelled");
2339
+ this.persistRewrite();
2340
+ }
2341
+ entry.resolve({ stopReason: "cancelled" });
2342
+ return { cancelled: true, reason: "ok" };
2343
+ }
2344
+ // Replace the prompt payload of a queued (not-yet-running) entry.
2345
+ // Returns already_running for the in-flight head; not_found for
2346
+ // unknown messageIds or for internal queue entries (internal tasks
2347
+ // don't expose a mutable prompt). Broadcasts prompt_queue_updated on
2348
+ // success so every attached client refreshes its chip.
2349
+ updateQueuedPrompt(messageId, prompt) {
2350
+ if (this.currentEntry?.messageId === messageId) {
2351
+ return { updated: false, reason: "already_running" };
2352
+ }
2353
+ const entry = this.promptQueue.find((e) => e.messageId === messageId);
2354
+ if (!entry || entry.kind !== "user") {
2355
+ return { updated: false, reason: "not_found" };
2356
+ }
2357
+ entry.prompt = prompt;
2358
+ this.broadcastQueueUpdated(messageId, prompt);
2359
+ this.persistRewrite();
2360
+ return { updated: true, reason: "ok" };
2361
+ }
1991
2362
  async cancel(clientId) {
1992
2363
  const client = this.clients.get(clientId);
1993
2364
  if (!client) {
@@ -2114,7 +2485,7 @@ var Session = class {
2114
2485
  if (update.sessionUpdate !== "current_mode_update") {
2115
2486
  return false;
2116
2487
  }
2117
- const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
2488
+ const raw = typeof update.currentModeId === "string" ? update.currentModeId : typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
2118
2489
  if (raw === void 0) {
2119
2490
  return true;
2120
2491
  }
@@ -2192,12 +2563,29 @@ var Session = class {
2192
2563
  }
2193
2564
  this.broadcastMergedCommands();
2194
2565
  }
2566
+ setAgentAdvertisedModes(modes) {
2567
+ if (sameAdvertisedModes(this.agentAdvertisedModes, modes)) {
2568
+ this.broadcastAvailableModes();
2569
+ return;
2570
+ }
2571
+ this.agentAdvertisedModes = modes;
2572
+ for (const handler of this.agentModesHandlers) {
2573
+ try {
2574
+ handler(modes);
2575
+ } catch {
2576
+ }
2577
+ }
2578
+ this.broadcastAvailableModes();
2579
+ }
2195
2580
  // Subscribe to snapshot-state updates. SessionManager wires these to
2196
2581
  // persist the new value into meta.json so cold resurrect can restore
2197
2582
  // them via the attach response _meta.
2198
2583
  onAgentCommandsChange(handler) {
2199
2584
  this.agentCommandsHandlers.push(handler);
2200
2585
  }
2586
+ onAgentModesChange(handler) {
2587
+ this.agentModesHandlers.push(handler);
2588
+ }
2201
2589
  onModelChange(handler) {
2202
2590
  this.modelHandlers.push(handler);
2203
2591
  }
@@ -2219,6 +2607,10 @@ var Session = class {
2219
2607
  agentOnlyAdvertisedCommands() {
2220
2608
  return [...this.agentAdvertisedCommands];
2221
2609
  }
2610
+ // The agent's advertised modes list, for callers that need a snapshot.
2611
+ availableModes() {
2612
+ return [...this.agentAdvertisedModes];
2613
+ }
2222
2614
  // Pick up an agent-emitted session_info_update and store its title
2223
2615
  // as our canonical record. The notification is also forwarded to
2224
2616
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2531,6 +2923,20 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2531
2923
  }
2532
2924
  this.closed = true;
2533
2925
  this.cancelIdleTimer();
2926
+ const stranded = this.promptQueue;
2927
+ this.promptQueue = [];
2928
+ for (const entry of stranded) {
2929
+ entry.cancelled = true;
2930
+ if (entry.kind === "user") {
2931
+ this.broadcastQueueRemoved(entry.messageId, "abandoned");
2932
+ }
2933
+ try {
2934
+ entry.resolve({ stopReason: "cancelled" });
2935
+ } catch {
2936
+ }
2937
+ }
2938
+ const sessionId = this.sessionId;
2939
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
2534
2940
  for (const client of this.clients.values()) {
2535
2941
  void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
2536
2942
  }
@@ -2576,7 +2982,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2576
2982
  if (this.closed || this.idleTimeoutMs <= 0) {
2577
2983
  return;
2578
2984
  }
2579
- if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
2985
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0 || this.promptQueue.length > 0) {
2580
2986
  this.armIdleTimer(this.idleTimeoutMs);
2581
2987
  return;
2582
2988
  }
@@ -2705,20 +3111,88 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2705
3111
  }
2706
3112
  });
2707
3113
  }
3114
+ // Schedule an internal task (title regen, agent swap transcript
3115
+ // injection, import seed). Serializes behind any user prompts already
3116
+ // in flight, but doesn't emit prompt_queue_* broadcasts — clients
3117
+ // shouldn't see hydra's housekeeping in their chip list.
2708
3118
  async enqueuePrompt(task) {
2709
3119
  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
- }
3120
+ const entry = {
3121
+ kind: "internal",
3122
+ messageId: generateMessageId(),
3123
+ enqueuedAt: Date.now(),
3124
+ cancelled: false,
3125
+ task,
3126
+ resolve: resolve3,
3127
+ reject
3128
+ };
3129
+ this.promptQueue.push(entry);
3130
+ void this.drainQueue();
3131
+ });
3132
+ }
3133
+ // Schedule a user-originated session/prompt. Emits prompt_queue_added
3134
+ // immediately on enqueue (so peer clients can render the queued chip)
3135
+ // and prompt_queue_removed when the entry leaves the queue. The
3136
+ // returned promise resolves with the upstream agent's session/prompt
3137
+ // result, or { stopReason: "cancelled" } if the entry is dropped via
3138
+ // cancelQueuedPrompt before reaching the head.
3139
+ async enqueueUserPrompt(client, params, messageId) {
3140
+ const promptArray = (params ?? {}).prompt ?? [];
3141
+ const originator = { clientId: client.clientId };
3142
+ if (client.clientInfo?.name) originator.name = client.clientInfo.name;
3143
+ if (client.clientInfo?.version)
3144
+ originator.version = client.clientInfo.version;
3145
+ return new Promise((resolve3, reject) => {
3146
+ const entry = {
3147
+ kind: "user",
3148
+ messageId,
3149
+ originator,
3150
+ clientId: client.clientId,
3151
+ prompt: promptArray,
3152
+ enqueuedAt: Date.now(),
3153
+ cancelled: false,
3154
+ resolve: resolve3,
3155
+ reject
2717
3156
  };
2718
- this.promptQueue.push(run2);
3157
+ this.promptQueue.push(entry);
3158
+ this.persistRewrite();
3159
+ this.broadcastQueueAdded(entry);
2719
3160
  void this.drainQueue();
2720
3161
  });
2721
3162
  }
3163
+ // Rewrite the on-disk queue to reflect the current set of WAITING
3164
+ // entries (excluding currentEntry, the in-flight head). Excluding
3165
+ // the head is the key idempotency choice: once drainQueue shifts an
3166
+ // entry off and calls persistRewrite, a daemon crash mid-generation
3167
+ // will NOT re-run it on restart. Partial output (if any streamed
3168
+ // before the crash) stays in history; the prompt itself is lost
3169
+ // and the user can re-submit if they care.
3170
+ //
3171
+ // Snapshots in-memory state synchronously (so subsequent mutations
3172
+ // can't perturb what we're about to write) and chains the write
3173
+ // onto queueWriteChain so all persists are serialized.
3174
+ persistRewrite() {
3175
+ const entries = [];
3176
+ for (const entry of this.promptQueue) {
3177
+ if (entry.kind !== "user" || entry.cancelled) continue;
3178
+ entries.push(this.persistedFromEntry(entry));
3179
+ }
3180
+ const sessionId = this.sessionId;
3181
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => rewriteQueue(sessionId, entries).catch(() => void 0));
3182
+ }
3183
+ persistedFromEntry(entry) {
3184
+ return {
3185
+ messageId: entry.messageId,
3186
+ originator: {
3187
+ clientInfo: {
3188
+ ...entry.originator.name !== void 0 ? { name: entry.originator.name } : {},
3189
+ ...entry.originator.version !== void 0 ? { version: entry.originator.version } : {}
3190
+ }
3191
+ },
3192
+ prompt: entry.prompt,
3193
+ enqueuedAt: entry.enqueuedAt
3194
+ };
3195
+ }
2722
3196
  async drainQueue() {
2723
3197
  if (this.promptInFlight) {
2724
3198
  return;
@@ -2727,14 +3201,64 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2727
3201
  try {
2728
3202
  while (this.promptQueue.length > 0) {
2729
3203
  const next = this.promptQueue.shift();
2730
- if (next) {
2731
- await next();
3204
+ if (!next) {
3205
+ break;
3206
+ }
3207
+ if (next.cancelled) {
3208
+ continue;
3209
+ }
3210
+ this.currentEntry = next;
3211
+ if (next.kind === "user") {
3212
+ this.persistRewrite();
3213
+ }
3214
+ if (next.kind === "user") {
3215
+ this.broadcastQueueRemoved(next.messageId, "started");
3216
+ }
3217
+ try {
3218
+ const result = await this.runQueueEntry(next);
3219
+ next.resolve(result);
3220
+ } catch (err) {
3221
+ next.reject(err);
3222
+ } finally {
3223
+ this.currentEntry = void 0;
2732
3224
  }
2733
3225
  }
2734
3226
  } finally {
2735
3227
  this.promptInFlight = false;
2736
3228
  }
2737
3229
  }
3230
+ // Execute a queue entry. User-prompt entries forward to the upstream
3231
+ // agent and pair with broadcastTurnComplete; internal entries run
3232
+ // their captured task closure. Reads entry.prompt at dispatch time
3233
+ // so updateQueuedPrompt's mutations are honoured.
3234
+ //
3235
+ // For user entries, broadcastPromptReceived fires HERE — not in
3236
+ // Session.prompt — so peer clients see prompt_received only when the
3237
+ // turn actually starts (a deliberate deviation from a naive reading
3238
+ // of RFD #533; see the comment on broadcastPromptReceived). Order on
3239
+ // the wire: prompt_queue_removed{started} (already emitted by
3240
+ // drainQueue) → prompt_received → upstream session/prompt.
3241
+ async runQueueEntry(entry) {
3242
+ if (entry.kind === "internal") {
3243
+ return entry.task();
3244
+ }
3245
+ this.broadcastPromptReceived(entry);
3246
+ let response;
3247
+ try {
3248
+ response = await this.agent.connection.request(
3249
+ "session/prompt",
3250
+ {
3251
+ sessionId: this.upstreamSessionId,
3252
+ prompt: entry.prompt
3253
+ }
3254
+ );
3255
+ } catch (err) {
3256
+ this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
3257
+ throw err;
3258
+ }
3259
+ this.broadcastTurnComplete(entry.clientId, response);
3260
+ return response;
3261
+ }
2738
3262
  };
2739
3263
  function withCode(err, code) {
2740
3264
  err.code = code;
@@ -2745,6 +3269,7 @@ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
2745
3269
  "current_model_update",
2746
3270
  "current_mode_update",
2747
3271
  "available_commands_update",
3272
+ "available_modes_update",
2748
3273
  "usage_update"
2749
3274
  ]);
2750
3275
  function isStateUpdate(method, params) {
@@ -2766,8 +3291,49 @@ function sameAdvertisedCommands(a, b) {
2766
3291
  }
2767
3292
  return true;
2768
3293
  }
2769
- function captureInternalChunk(capture, params) {
2770
- const obj = params ?? {};
3294
+ function sameAdvertisedModes(a, b) {
3295
+ if (a.length !== b.length) {
3296
+ return false;
3297
+ }
3298
+ for (let i = 0; i < a.length; i++) {
3299
+ if (a[i]?.id !== b[i]?.id || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
3300
+ return false;
3301
+ }
3302
+ }
3303
+ return true;
3304
+ }
3305
+ function extractAdvertisedModes(params) {
3306
+ const obj = params ?? {};
3307
+ const update = obj.update ?? {};
3308
+ if (update.sessionUpdate !== "available_modes_update") {
3309
+ return null;
3310
+ }
3311
+ const list = update.availableModes;
3312
+ if (!Array.isArray(list)) {
3313
+ return [];
3314
+ }
3315
+ const out = [];
3316
+ for (const raw of list) {
3317
+ if (!raw || typeof raw !== "object") {
3318
+ continue;
3319
+ }
3320
+ const m = raw;
3321
+ if (typeof m.id !== "string" || m.id.length === 0) {
3322
+ continue;
3323
+ }
3324
+ const mode = { id: m.id };
3325
+ if (typeof m.name === "string") {
3326
+ mode.name = m.name;
3327
+ }
3328
+ if (typeof m.description === "string") {
3329
+ mode.description = m.description;
3330
+ }
3331
+ out.push(mode);
3332
+ }
3333
+ return out;
3334
+ }
3335
+ function captureInternalChunk(capture, params) {
3336
+ const obj = params ?? {};
2771
3337
  const update = obj.update ?? {};
2772
3338
  if (update.sessionUpdate !== "agent_message_chunk") {
2773
3339
  return;
@@ -2921,7 +3487,7 @@ function firstLine(text, max) {
2921
3487
  }
2922
3488
 
2923
3489
  // src/core/session-store.ts
2924
- import * as fs4 from "fs/promises";
3490
+ import * as fs6 from "fs/promises";
2925
3491
  import * as path4 from "path";
2926
3492
  import { customAlphabet as customAlphabet2 } from "nanoid";
2927
3493
  import { z as z4 } from "zod";
@@ -2935,6 +3501,11 @@ var PersistedAgentCommand = z4.object({
2935
3501
  name: z4.string(),
2936
3502
  description: z4.string().optional()
2937
3503
  });
3504
+ var PersistedAgentMode = z4.object({
3505
+ id: z4.string(),
3506
+ name: z4.string().optional(),
3507
+ description: z4.string().optional()
3508
+ });
2938
3509
  var PersistedUsage = z4.object({
2939
3510
  used: z4.number().optional(),
2940
3511
  size: z4.number().optional(),
@@ -2980,6 +3551,7 @@ var SessionRecord = z4.object({
2980
3551
  currentMode: z4.string().optional(),
2981
3552
  currentUsage: PersistedUsage.optional(),
2982
3553
  agentCommands: z4.array(PersistedAgentCommand).optional(),
3554
+ agentModes: z4.array(PersistedAgentMode).optional(),
2983
3555
  createdAt: z4.string(),
2984
3556
  updatedAt: z4.string()
2985
3557
  });
@@ -2992,9 +3564,9 @@ function assertSafeId(id) {
2992
3564
  var SessionStore = class {
2993
3565
  async write(record) {
2994
3566
  assertSafeId(record.sessionId);
2995
- await fs4.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
3567
+ await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2996
3568
  const full = { version: 1, ...record };
2997
- await fs4.writeFile(
3569
+ await fs6.writeFile(
2998
3570
  paths.sessionFile(record.sessionId),
2999
3571
  JSON.stringify(full, null, 2) + "\n",
3000
3572
  { encoding: "utf8", mode: 384 }
@@ -3006,7 +3578,7 @@ var SessionStore = class {
3006
3578
  }
3007
3579
  let raw;
3008
3580
  try {
3009
- raw = await fs4.readFile(paths.sessionFile(sessionId), "utf8");
3581
+ raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
3010
3582
  } catch (err) {
3011
3583
  const e = err;
3012
3584
  if (e.code === "ENOENT") {
@@ -3025,7 +3597,7 @@ var SessionStore = class {
3025
3597
  return;
3026
3598
  }
3027
3599
  try {
3028
- await fs4.unlink(paths.sessionFile(sessionId));
3600
+ await fs6.unlink(paths.sessionFile(sessionId));
3029
3601
  } catch (err) {
3030
3602
  const e = err;
3031
3603
  if (e.code !== "ENOENT") {
@@ -3033,7 +3605,7 @@ var SessionStore = class {
3033
3605
  }
3034
3606
  }
3035
3607
  try {
3036
- await fs4.rmdir(paths.sessionDir(sessionId));
3608
+ await fs6.rmdir(paths.sessionDir(sessionId));
3037
3609
  } catch (err) {
3038
3610
  const e = err;
3039
3611
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -3063,7 +3635,7 @@ var SessionStore = class {
3063
3635
  async list() {
3064
3636
  let entries;
3065
3637
  try {
3066
- entries = await fs4.readdir(paths.sessionsDir());
3638
+ entries = await fs6.readdir(paths.sessionsDir());
3067
3639
  } catch (err) {
3068
3640
  const e = err;
3069
3641
  if (e.code === "ENOENT") {
@@ -3098,13 +3670,14 @@ function recordFromMemorySession(args) {
3098
3670
  currentMode: args.currentMode,
3099
3671
  currentUsage: args.currentUsage,
3100
3672
  agentCommands: args.agentCommands,
3673
+ agentModes: args.agentModes,
3101
3674
  createdAt: args.createdAt ?? now,
3102
3675
  updatedAt: args.updatedAt ?? now
3103
3676
  };
3104
3677
  }
3105
3678
 
3106
3679
  // src/core/history-store.ts
3107
- import * as fs5 from "fs/promises";
3680
+ import * as fs7 from "fs/promises";
3108
3681
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
3109
3682
  var DEFAULT_MAX_ENTRIES = 1e3;
3110
3683
  var HistoryStore = class {
@@ -3121,9 +3694,9 @@ var HistoryStore = class {
3121
3694
  return;
3122
3695
  }
3123
3696
  return this.enqueue(sessionId, async () => {
3124
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
3697
+ await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
3125
3698
  const line = JSON.stringify(entry) + "\n";
3126
- await fs5.appendFile(paths.historyFile(sessionId), line, {
3699
+ await fs7.appendFile(paths.historyFile(sessionId), line, {
3127
3700
  encoding: "utf8",
3128
3701
  mode: 384
3129
3702
  });
@@ -3134,9 +3707,9 @@ var HistoryStore = class {
3134
3707
  return;
3135
3708
  }
3136
3709
  return this.enqueue(sessionId, async () => {
3137
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
3710
+ await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
3138
3711
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
3139
- await fs5.writeFile(paths.historyFile(sessionId), body, {
3712
+ await fs7.writeFile(paths.historyFile(sessionId), body, {
3140
3713
  encoding: "utf8",
3141
3714
  mode: 384
3142
3715
  });
@@ -3153,7 +3726,7 @@ var HistoryStore = class {
3153
3726
  return this.enqueue(sessionId, async () => {
3154
3727
  let raw;
3155
3728
  try {
3156
- raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
3729
+ raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
3157
3730
  } catch (err) {
3158
3731
  const e = err;
3159
3732
  if (e.code === "ENOENT") {
@@ -3166,7 +3739,7 @@ var HistoryStore = class {
3166
3739
  return;
3167
3740
  }
3168
3741
  const trimmed = lines.slice(-maxEntries);
3169
- await fs5.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
3742
+ await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
3170
3743
  encoding: "utf8",
3171
3744
  mode: 384
3172
3745
  });
@@ -3182,7 +3755,7 @@ var HistoryStore = class {
3182
3755
  }
3183
3756
  let raw;
3184
3757
  try {
3185
- raw = await fs5.readFile(paths.historyFile(sessionId), "utf8");
3758
+ raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
3186
3759
  } catch (err) {
3187
3760
  const e = err;
3188
3761
  if (e.code === "ENOENT") {
@@ -3228,7 +3801,7 @@ var HistoryStore = class {
3228
3801
  }
3229
3802
  return this.enqueue(sessionId, async () => {
3230
3803
  try {
3231
- await fs5.unlink(paths.historyFile(sessionId));
3804
+ await fs7.unlink(paths.historyFile(sessionId));
3232
3805
  } catch (err) {
3233
3806
  const e = err;
3234
3807
  if (e.code !== "ENOENT") {
@@ -3236,7 +3809,7 @@ var HistoryStore = class {
3236
3809
  }
3237
3810
  }
3238
3811
  try {
3239
- await fs5.rmdir(paths.sessionDir(sessionId));
3812
+ await fs7.rmdir(paths.sessionDir(sessionId));
3240
3813
  } catch (err) {
3241
3814
  const e = err;
3242
3815
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -3260,25 +3833,25 @@ var HistoryStore = class {
3260
3833
  };
3261
3834
 
3262
3835
  // src/tui/history.ts
3263
- import { promises as fs6 } from "fs";
3836
+ import { promises as fs8 } from "fs";
3264
3837
  import * as path5 from "path";
3265
3838
  async function saveHistory(file, history) {
3266
- await fs6.mkdir(path5.dirname(file), { recursive: true });
3839
+ await fs8.mkdir(path5.dirname(file), { recursive: true });
3267
3840
  const lines = history.map((entry) => JSON.stringify(entry));
3268
- await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3841
+ await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3269
3842
  }
3270
3843
 
3271
3844
  // src/core/hydra-version.ts
3272
3845
  import { fileURLToPath } from "url";
3273
3846
  import * as path6 from "path";
3274
- import * as fs7 from "fs";
3847
+ import * as fs9 from "fs";
3275
3848
  function resolveVersion() {
3276
3849
  try {
3277
3850
  let dir = path6.dirname(fileURLToPath(import.meta.url));
3278
3851
  for (let i = 0; i < 8; i += 1) {
3279
3852
  const candidate = path6.join(dir, "package.json");
3280
- if (fs7.existsSync(candidate)) {
3281
- const pkg = JSON.parse(fs7.readFileSync(candidate, "utf8"));
3853
+ if (fs9.existsSync(candidate)) {
3854
+ const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
3282
3855
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
3283
3856
  return pkg.version;
3284
3857
  }
@@ -3296,6 +3869,7 @@ function resolveVersion() {
3296
3869
  var HYDRA_VERSION = resolveVersion();
3297
3870
 
3298
3871
  // src/core/session-manager.ts
3872
+ var QUEUE_REPLAY_TTL_MS = 15 * 60 * 1e3;
3299
3873
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
3300
3874
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
3301
3875
  var SessionManager = class {
@@ -3308,6 +3882,7 @@ var SessionManager = class {
3308
3882
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
3309
3883
  this.defaultModels = options.defaultModels ?? {};
3310
3884
  this.logger = options.logger;
3885
+ this.npmRegistry = options.npmRegistry;
3311
3886
  }
3312
3887
  registry;
3313
3888
  sessions = /* @__PURE__ */ new Map();
@@ -3323,6 +3898,7 @@ var SessionManager = class {
3323
3898
  // back-to-back) don't lose writes via interleaved reads.
3324
3899
  metaWriteQueues = /* @__PURE__ */ new Map();
3325
3900
  logger;
3901
+ npmRegistry;
3326
3902
  async create(params) {
3327
3903
  const fresh = await this.bootstrapAgent({
3328
3904
  agentId: params.agentId,
@@ -3344,7 +3920,9 @@ var SessionManager = class {
3344
3920
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
3345
3921
  historyStore: this.histories,
3346
3922
  historyMaxEntries: this.sessionHistoryMaxEntries,
3347
- currentModel: fresh.initialModel
3923
+ currentModel: fresh.initialModel,
3924
+ currentMode: fresh.initialMode,
3925
+ agentModes: fresh.initialModes
3348
3926
  });
3349
3927
  await this.attachManagerHooks(session);
3350
3928
  return session;
@@ -3389,7 +3967,7 @@ var SessionManager = class {
3389
3967
  if (params.upstreamSessionId === "") {
3390
3968
  return this.doResurrectFromImport(params);
3391
3969
  }
3392
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
3970
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
3393
3971
  const agent = this.spawner({
3394
3972
  agentId: params.agentId,
3395
3973
  cwd: params.cwd,
@@ -3443,9 +4021,10 @@ var SessionManager = class {
3443
4021
  // this fix), fall back to the model the agent ships in its
3444
4022
  // session/load response body.
3445
4023
  currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
3446
- currentMode: params.currentMode,
4024
+ currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
3447
4025
  currentUsage: params.currentUsage,
3448
4026
  agentCommands: params.agentCommands,
4027
+ agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
3449
4028
  // Only gate the first-prompt title heuristic when we actually have
3450
4029
  // a title to preserve. A title-less session (lost to a write race
3451
4030
  // or never seeded) should re-derive from the next prompt rather
@@ -3488,9 +4067,10 @@ var SessionManager = class {
3488
4067
  // Prefer the stored value (set by a previous current_model_update);
3489
4068
  // fall back to whatever the agent ships in its session/new response.
3490
4069
  currentModel: params.currentModel ?? fresh.initialModel,
3491
- currentMode: params.currentMode,
4070
+ currentMode: params.currentMode ?? fresh.initialMode,
3492
4071
  currentUsage: params.currentUsage,
3493
4072
  agentCommands: params.agentCommands,
4073
+ agentModes: params.agentModes ?? fresh.initialModes,
3494
4074
  firstPromptSeeded: !!params.title,
3495
4075
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
3496
4076
  });
@@ -3500,7 +4080,7 @@ var SessionManager = class {
3500
4080
  }
3501
4081
  async resolveImportCwd(cwd) {
3502
4082
  try {
3503
- const stat2 = await fs8.stat(cwd);
4083
+ const stat2 = await fs10.stat(cwd);
3504
4084
  if (stat2.isDirectory()) {
3505
4085
  return cwd;
3506
4086
  }
@@ -3520,7 +4100,7 @@ var SessionManager = class {
3520
4100
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
3521
4101
  throw err;
3522
4102
  }
3523
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
4103
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
3524
4104
  const agent = this.spawner({
3525
4105
  agentId: params.agentId,
3526
4106
  cwd: params.cwd,
@@ -3557,11 +4137,15 @@ var SessionManager = class {
3557
4137
  } catch {
3558
4138
  }
3559
4139
  }
4140
+ const initialModes = extractInitialModes(newResult);
4141
+ const initialMode = extractInitialCurrentMode(newResult);
3560
4142
  return {
3561
4143
  agent,
3562
4144
  upstreamSessionId: sessionIdRaw,
3563
4145
  agentMeta: newResult._meta,
3564
- initialModel
4146
+ initialModel,
4147
+ initialModes: initialModes.length > 0 ? initialModes : void 0,
4148
+ initialMode
3565
4149
  };
3566
4150
  } catch (err) {
3567
4151
  await agent.kill().catch(() => void 0);
@@ -3613,6 +4197,15 @@ var SessionManager = class {
3613
4197
  }))
3614
4198
  }).catch(() => void 0);
3615
4199
  });
4200
+ session.onAgentModesChange((modes) => {
4201
+ void this.persistSnapshot(session.sessionId, {
4202
+ agentModes: modes.map((m) => ({
4203
+ id: m.id,
4204
+ ...m.name !== void 0 ? { name: m.name } : {},
4205
+ ...m.description !== void 0 ? { description: m.description } : {}
4206
+ }))
4207
+ }).catch(() => void 0);
4208
+ });
3616
4209
  this.sessions.set(session.sessionId, session);
3617
4210
  await this.enqueueMetaWrite(session.sessionId, async () => {
3618
4211
  const existing = await this.store.read(session.sessionId);
@@ -3655,6 +4248,7 @@ var SessionManager = class {
3655
4248
  currentMode: record.currentMode,
3656
4249
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
3657
4250
  agentCommands: record.agentCommands,
4251
+ agentModes: record.agentModes,
3658
4252
  createdAt: record.createdAt
3659
4253
  };
3660
4254
  }
@@ -3679,6 +4273,23 @@ var SessionManager = class {
3679
4273
  get(sessionId) {
3680
4274
  return this.sessions.get(sessionId);
3681
4275
  }
4276
+ // Snapshot of which agent versions are currently in use by live
4277
+ // sessions, keyed by agentId. Read by the registry-fetch prune sweep
4278
+ // so it can skip install dirs that still back a running process.
4279
+ activeAgentVersions() {
4280
+ const out = /* @__PURE__ */ new Map();
4281
+ for (const session of this.sessions.values()) {
4282
+ const id = session.agent.agentId;
4283
+ const version = session.agent.version;
4284
+ let set = out.get(id);
4285
+ if (!set) {
4286
+ set = /* @__PURE__ */ new Set();
4287
+ out.set(id, set);
4288
+ }
4289
+ set.add(version);
4290
+ }
4291
+ return out;
4292
+ }
3682
4293
  // Resolve a user-typed session id (which may have the hydra_session_
3683
4294
  // prefix stripped — that's what `sessions list` and the picker show) to
3684
4295
  // the canonical form that actually exists. Tries the input as-given
@@ -3932,6 +4543,7 @@ var SessionManager = class {
3932
4543
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
3933
4544
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
3934
4545
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
4546
+ ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
3935
4547
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3936
4548
  });
3937
4549
  });
@@ -3965,6 +4577,53 @@ var SessionManager = class {
3965
4577
  }
3966
4578
  await Promise.allSettled(pending);
3967
4579
  }
4580
+ // Startup hook: scan persisted sessions for non-empty queue files,
4581
+ // apply the TTL, resurrect anything with surviving entries, and
4582
+ // replay them through the normal queue path. Called from the daemon
4583
+ // boot sequence; failures per session are logged and don't block
4584
+ // the boot.
4585
+ //
4586
+ // Concurrency is deliberately sequential — resurrect each session
4587
+ // one at a time so a runaway daemon with 100 queued sessions
4588
+ // doesn't burst-spawn 100 agents on startup. Inside a single
4589
+ // session, the queue still drains in parallel-friendly fashion via
4590
+ // drainQueue once resurrect() completes.
4591
+ async resurrectPendingQueues() {
4592
+ const records = await this.store.list().catch(() => []);
4593
+ for (const rec of records) {
4594
+ const queue = await loadQueue(rec.sessionId).catch(() => []);
4595
+ if (queue.length === 0) continue;
4596
+ const now = Date.now();
4597
+ const fresh = queue.filter((e) => now - e.enqueuedAt < QUEUE_REPLAY_TTL_MS);
4598
+ const dropped = queue.length - fresh.length;
4599
+ if (dropped > 0) {
4600
+ this.logger?.info(
4601
+ `queue replay: dropping ${dropped} stale prompt(s) for ${rec.sessionId} (TTL ${QUEUE_REPLAY_TTL_MS / 1e3}s)`
4602
+ );
4603
+ await rewriteQueue(rec.sessionId, fresh).catch(() => void 0);
4604
+ }
4605
+ if (fresh.length === 0) continue;
4606
+ const fromDisk = await this.loadFromDisk(rec.sessionId).catch(() => void 0);
4607
+ if (!fromDisk) {
4608
+ this.logger?.warn(
4609
+ `queue replay: no meta for ${rec.sessionId}; discarding ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"}`
4610
+ );
4611
+ await rewriteQueue(rec.sessionId, []).catch(() => void 0);
4612
+ continue;
4613
+ }
4614
+ try {
4615
+ const session = await this.resurrect(fromDisk);
4616
+ this.logger?.info(
4617
+ `queue replay: resurrected ${rec.sessionId} and replaying ${fresh.length} prompt(s)`
4618
+ );
4619
+ session.replayPersistedQueue(fresh);
4620
+ } catch (err) {
4621
+ this.logger?.warn(
4622
+ `queue replay: failed to resurrect ${rec.sessionId}: ${err.message}`
4623
+ );
4624
+ }
4625
+ }
4626
+ }
3968
4627
  };
3969
4628
  function mergeForPersistence(session, existing) {
3970
4629
  const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
@@ -3974,6 +4633,18 @@ function mergeForPersistence(session, existing) {
3974
4633
  return { name: c.name };
3975
4634
  }) : void 0;
3976
4635
  const agentCommands = persistedCommands ?? existing?.agentCommands;
4636
+ const sessionModes = session.availableModes();
4637
+ const persistedModes = sessionModes.length > 0 ? sessionModes.map((m) => {
4638
+ const out = { id: m.id };
4639
+ if (m.name !== void 0) {
4640
+ out.name = m.name;
4641
+ }
4642
+ if (m.description !== void 0) {
4643
+ out.description = m.description;
4644
+ }
4645
+ return out;
4646
+ }) : void 0;
4647
+ const agentModes = persistedModes ?? existing?.agentModes;
3977
4648
  return recordFromMemorySession({
3978
4649
  sessionId: session.sessionId,
3979
4650
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -3989,6 +4660,7 @@ function mergeForPersistence(session, existing) {
3989
4660
  currentMode: session.currentMode ?? existing?.currentMode,
3990
4661
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
3991
4662
  agentCommands,
4663
+ agentModes,
3992
4664
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
3993
4665
  });
3994
4666
  }
@@ -4051,9 +4723,103 @@ function asString(value) {
4051
4723
  const trimmed = value.trim();
4052
4724
  return trimmed.length > 0 ? trimmed : void 0;
4053
4725
  }
4726
+ function nonEmptyOrUndefined(arr) {
4727
+ return arr.length > 0 ? arr : void 0;
4728
+ }
4729
+ function extractInitialModes(result) {
4730
+ const direct = parseModesList(result.availableModes);
4731
+ if (direct.length > 0) {
4732
+ return direct;
4733
+ }
4734
+ const modes = result.modes;
4735
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
4736
+ const fromModesObj = parseModesList(
4737
+ modes.availableModes
4738
+ );
4739
+ if (fromModesObj.length > 0) {
4740
+ return fromModesObj;
4741
+ }
4742
+ }
4743
+ const meta = result._meta;
4744
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4745
+ for (const [key, value] of Object.entries(
4746
+ meta
4747
+ )) {
4748
+ if (key === "hydra-acp") {
4749
+ continue;
4750
+ }
4751
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4752
+ const fromMeta = parseModesList(
4753
+ value.availableModes
4754
+ );
4755
+ if (fromMeta.length > 0) {
4756
+ return fromMeta;
4757
+ }
4758
+ }
4759
+ }
4760
+ }
4761
+ return [];
4762
+ }
4763
+ function extractInitialCurrentMode(result) {
4764
+ const direct = asString(result.currentModeId) ?? asString(result.currentMode) ?? asString(result.modeId) ?? asString(result.mode);
4765
+ if (direct) {
4766
+ return direct;
4767
+ }
4768
+ const modes = result.modes;
4769
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
4770
+ const m = asString(modes.currentModeId) ?? asString(modes.currentMode);
4771
+ if (m) {
4772
+ return m;
4773
+ }
4774
+ }
4775
+ const meta = result._meta;
4776
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
4777
+ for (const [key, value] of Object.entries(
4778
+ meta
4779
+ )) {
4780
+ if (key === "hydra-acp") {
4781
+ continue;
4782
+ }
4783
+ if (value && typeof value === "object" && !Array.isArray(value)) {
4784
+ const m = asString(value.currentModeId) ?? asString(value.currentMode) ?? asString(value.modeId);
4785
+ if (m) {
4786
+ return m;
4787
+ }
4788
+ }
4789
+ }
4790
+ }
4791
+ return void 0;
4792
+ }
4793
+ function parseModesList(list) {
4794
+ if (!Array.isArray(list)) {
4795
+ return [];
4796
+ }
4797
+ const out = [];
4798
+ for (const raw of list) {
4799
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
4800
+ continue;
4801
+ }
4802
+ const r = raw;
4803
+ const id = asString(r.id) ?? asString(r.modeId);
4804
+ if (!id) {
4805
+ continue;
4806
+ }
4807
+ const mode = { id };
4808
+ const name = asString(r.name);
4809
+ if (name) {
4810
+ mode.name = name;
4811
+ }
4812
+ const description = asString(r.description);
4813
+ if (description) {
4814
+ mode.description = description;
4815
+ }
4816
+ out.push(mode);
4817
+ }
4818
+ return out;
4819
+ }
4054
4820
  async function loadPromptHistorySafely(sessionId) {
4055
4821
  try {
4056
- const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
4822
+ const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
4057
4823
  const out = [];
4058
4824
  for (const line of raw.split("\n")) {
4059
4825
  if (line.length === 0) {
@@ -4074,7 +4840,7 @@ async function loadPromptHistorySafely(sessionId) {
4074
4840
  }
4075
4841
  async function historyMtimeIso(sessionId) {
4076
4842
  try {
4077
- const st = await fs8.stat(paths.historyFile(sessionId));
4843
+ const st = await fs10.stat(paths.historyFile(sessionId));
4078
4844
  return new Date(st.mtimeMs).toISOString();
4079
4845
  } catch {
4080
4846
  return void 0;
@@ -4083,7 +4849,7 @@ async function historyMtimeIso(sessionId) {
4083
4849
 
4084
4850
  // src/core/extensions.ts
4085
4851
  import { spawn as spawn4 } from "child_process";
4086
- import * as fs9 from "fs";
4852
+ import * as fs11 from "fs";
4087
4853
  import * as fsp3 from "fs/promises";
4088
4854
  import * as path7 from "path";
4089
4855
  var RESTART_BASE_MS = 1e3;
@@ -4366,7 +5132,7 @@ var ExtensionManager = class {
4366
5132
  }
4367
5133
  const ext = entry.config;
4368
5134
  const command = ext.command.length > 0 ? ext.command : [ext.name];
4369
- const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
5135
+ const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
4370
5136
  flags: "a"
4371
5137
  });
4372
5138
  logStream.write(
@@ -4378,7 +5144,7 @@ var ExtensionManager = class {
4378
5144
  HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
4379
5145
  HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
4380
5146
  HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
4381
- HYDRA_ACP_TOKEN: ctx.daemonToken,
5147
+ HYDRA_ACP_TOKEN: ctx.serviceToken,
4382
5148
  HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
4383
5149
  HYDRA_ACP_HOME: ctx.hydraHome,
4384
5150
  HYDRA_ACP_EXTENSION_NAME: ext.name,
@@ -4416,7 +5182,7 @@ var ExtensionManager = class {
4416
5182
  }
4417
5183
  if (typeof child.pid === "number") {
4418
5184
  try {
4419
- fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
5185
+ fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
4420
5186
  `, {
4421
5187
  encoding: "utf8",
4422
5188
  mode: 384
@@ -4441,7 +5207,7 @@ var ExtensionManager = class {
4441
5207
  });
4442
5208
  child.on("exit", (code, signal) => {
4443
5209
  try {
4444
- fs9.unlinkSync(paths.extensionPidFile(ext.name));
5210
+ fs11.unlinkSync(paths.extensionPidFile(ext.name));
4445
5211
  } catch {
4446
5212
  }
4447
5213
  logStream.write(
@@ -4497,8 +5263,303 @@ function withCode2(err, code) {
4497
5263
  return err;
4498
5264
  }
4499
5265
 
5266
+ // src/core/agent-prune.ts
5267
+ import * as fsp4 from "fs/promises";
5268
+ import * as path8 from "path";
5269
+ var logSink3 = (msg) => {
5270
+ process.stderr.write(msg + "\n");
5271
+ };
5272
+ function setAgentPruneLogger(log) {
5273
+ logSink3 = log ?? ((msg) => process.stderr.write(msg + "\n"));
5274
+ }
5275
+ async function pruneStaleAgentVersions(registry, sessionManager) {
5276
+ const platformKey = currentPlatformKey();
5277
+ if (!platformKey) {
5278
+ return;
5279
+ }
5280
+ const doc = await registry.load();
5281
+ const desiredByAgent = /* @__PURE__ */ new Map();
5282
+ for (const a of doc.agents) {
5283
+ desiredByAgent.set(a.id, a.version ?? "current");
5284
+ }
5285
+ const activeByAgent = sessionManager.activeAgentVersions();
5286
+ const platformDir = path8.join(paths.agentsDir(), platformKey);
5287
+ let agentEntries;
5288
+ try {
5289
+ agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
5290
+ } catch (err) {
5291
+ const e = err;
5292
+ if (e.code === "ENOENT") {
5293
+ return;
5294
+ }
5295
+ logSink3(`hydra-acp: prune: failed to read ${platformDir}: ${e.message}`);
5296
+ return;
5297
+ }
5298
+ for (const agentEntry of agentEntries) {
5299
+ if (!agentEntry.isDirectory()) {
5300
+ continue;
5301
+ }
5302
+ const agentId = agentEntry.name;
5303
+ const desired = desiredByAgent.get(agentId);
5304
+ if (desired === void 0) {
5305
+ continue;
5306
+ }
5307
+ const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
5308
+ const agentDir = path8.join(platformDir, agentId);
5309
+ let versionEntries;
5310
+ try {
5311
+ versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
5312
+ } catch (err) {
5313
+ logSink3(
5314
+ `hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
5315
+ );
5316
+ continue;
5317
+ }
5318
+ for (const versionEntry of versionEntries) {
5319
+ if (!versionEntry.isDirectory()) {
5320
+ continue;
5321
+ }
5322
+ const version = versionEntry.name;
5323
+ if (version === desired) {
5324
+ continue;
5325
+ }
5326
+ if (activeVersions.has(version)) {
5327
+ continue;
5328
+ }
5329
+ const versionDir = path8.join(agentDir, version);
5330
+ try {
5331
+ await fsp4.rm(versionDir, { recursive: true, force: true });
5332
+ logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
5333
+ } catch (err) {
5334
+ logSink3(
5335
+ `hydra-acp: prune: failed to remove ${versionDir}: ${err.message}`
5336
+ );
5337
+ }
5338
+ }
5339
+ }
5340
+ }
5341
+
5342
+ // src/core/session-tokens.ts
5343
+ import * as fs12 from "fs/promises";
5344
+ import * as path9 from "path";
5345
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
5346
+ var TOKEN_PREFIX = "hydra_session_";
5347
+ var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
5348
+ var ID_LENGTH = 12;
5349
+ var TOKEN_BYTES = 32;
5350
+ var WRITE_DEBOUNCE_MS = 50;
5351
+ function tokensFilePath() {
5352
+ return path9.join(paths.home(), "session-tokens.json");
5353
+ }
5354
+ function sha256Hex(input) {
5355
+ return createHash("sha256").update(input).digest("hex");
5356
+ }
5357
+ function randomHex(bytes) {
5358
+ return randomBytes(bytes).toString("hex");
5359
+ }
5360
+ function generateId() {
5361
+ return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
5362
+ }
5363
+ function generateToken() {
5364
+ return `${TOKEN_PREFIX}${randomHex(TOKEN_BYTES)}`;
5365
+ }
5366
+ var SessionTokenStore = class _SessionTokenStore {
5367
+ records = /* @__PURE__ */ new Map();
5368
+ // keyed by hash
5369
+ writeTimer = null;
5370
+ writeInflight = null;
5371
+ constructor(records) {
5372
+ for (const r of records) {
5373
+ this.records.set(r.hash, r);
5374
+ }
5375
+ }
5376
+ static async load() {
5377
+ let records = [];
5378
+ try {
5379
+ const raw = await fs12.readFile(tokensFilePath(), "utf8");
5380
+ const parsed = JSON.parse(raw);
5381
+ if (parsed && Array.isArray(parsed.records)) {
5382
+ records = parsed.records.filter(isRecord);
5383
+ }
5384
+ } catch (err) {
5385
+ const e = err;
5386
+ if (e.code !== "ENOENT") {
5387
+ throw err;
5388
+ }
5389
+ }
5390
+ const store = new _SessionTokenStore(records);
5391
+ const removed = store.sweepExpired(/* @__PURE__ */ new Date());
5392
+ if (removed > 0) {
5393
+ await store.flush();
5394
+ }
5395
+ return store;
5396
+ }
5397
+ async issue(opts = {}) {
5398
+ const token = generateToken();
5399
+ const hash = sha256Hex(token);
5400
+ const id = generateId();
5401
+ const now = /* @__PURE__ */ new Date();
5402
+ const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TTL_SEC;
5403
+ const expiresAt = new Date(now.getTime() + ttlSec * 1e3);
5404
+ const record = {
5405
+ id,
5406
+ hash,
5407
+ label: opts.label,
5408
+ createdAt: now.toISOString(),
5409
+ expiresAt: expiresAt.toISOString(),
5410
+ lastUsedAt: now.toISOString()
5411
+ };
5412
+ this.records.set(hash, record);
5413
+ this.scheduleWrite();
5414
+ return { id, token, expiresAt: record.expiresAt };
5415
+ }
5416
+ // Verifies a presented token. Returns the matching record id (so the
5417
+ // caller can revoke it on logout) and bumps lastUsedAt; returns
5418
+ // undefined when no record matches or when the matched record has
5419
+ // expired.
5420
+ async verify(token) {
5421
+ if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
5422
+ return void 0;
5423
+ }
5424
+ const hash = sha256Hex(token);
5425
+ const record = this.records.get(hash);
5426
+ if (!record) {
5427
+ return void 0;
5428
+ }
5429
+ const expected = Buffer.from(record.hash, "hex");
5430
+ const actual = Buffer.from(hash, "hex");
5431
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
5432
+ return void 0;
5433
+ }
5434
+ const now = /* @__PURE__ */ new Date();
5435
+ if (new Date(record.expiresAt).getTime() <= now.getTime()) {
5436
+ this.records.delete(hash);
5437
+ this.scheduleWrite();
5438
+ return void 0;
5439
+ }
5440
+ record.lastUsedAt = now.toISOString();
5441
+ this.scheduleWrite();
5442
+ return record.id;
5443
+ }
5444
+ async revoke(id) {
5445
+ for (const [hash, r] of this.records) {
5446
+ if (r.id === id) {
5447
+ this.records.delete(hash);
5448
+ this.scheduleWrite();
5449
+ return true;
5450
+ }
5451
+ }
5452
+ return false;
5453
+ }
5454
+ async revokeAll() {
5455
+ const n = this.records.size;
5456
+ this.records.clear();
5457
+ this.scheduleWrite();
5458
+ return n;
5459
+ }
5460
+ list() {
5461
+ return Array.from(this.records.values()).map(({ id, label, createdAt, expiresAt, lastUsedAt }) => ({
5462
+ id,
5463
+ label,
5464
+ createdAt,
5465
+ expiresAt,
5466
+ lastUsedAt
5467
+ })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
5468
+ }
5469
+ sweepExpired(now = /* @__PURE__ */ new Date()) {
5470
+ let removed = 0;
5471
+ for (const [hash, r] of this.records) {
5472
+ if (new Date(r.expiresAt).getTime() <= now.getTime()) {
5473
+ this.records.delete(hash);
5474
+ removed += 1;
5475
+ }
5476
+ }
5477
+ if (removed > 0) {
5478
+ this.scheduleWrite();
5479
+ }
5480
+ return removed;
5481
+ }
5482
+ // Force any pending write to complete. Useful in tests and at shutdown.
5483
+ async flush() {
5484
+ if (this.writeTimer) {
5485
+ clearTimeout(this.writeTimer);
5486
+ this.writeTimer = null;
5487
+ }
5488
+ await this.persist();
5489
+ }
5490
+ scheduleWrite() {
5491
+ if (this.writeTimer) {
5492
+ return;
5493
+ }
5494
+ this.writeTimer = setTimeout(() => {
5495
+ this.writeTimer = null;
5496
+ this.persist().catch(() => {
5497
+ });
5498
+ }, WRITE_DEBOUNCE_MS);
5499
+ }
5500
+ async persist() {
5501
+ if (this.writeInflight) {
5502
+ await this.writeInflight;
5503
+ }
5504
+ const records = Array.from(this.records.values());
5505
+ const payload = JSON.stringify({ records }, null, 2) + "\n";
5506
+ this.writeInflight = (async () => {
5507
+ await fs12.mkdir(paths.home(), { recursive: true });
5508
+ await fs12.writeFile(tokensFilePath(), payload, {
5509
+ encoding: "utf8",
5510
+ mode: 384
5511
+ });
5512
+ })();
5513
+ try {
5514
+ await this.writeInflight;
5515
+ } finally {
5516
+ this.writeInflight = null;
5517
+ }
5518
+ }
5519
+ };
5520
+ function isRecord(value) {
5521
+ if (!value || typeof value !== "object") {
5522
+ return false;
5523
+ }
5524
+ const v = value;
5525
+ 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");
5526
+ }
5527
+
4500
5528
  // src/daemon/auth.ts
4501
5529
  var BEARER_PREFIX = "Bearer ";
5530
+ var StaticTokenValidator = class {
5531
+ constructor(token) {
5532
+ this.token = token;
5533
+ }
5534
+ token;
5535
+ async validate(token) {
5536
+ return constantTimeEqual(token, this.token) ? "service" : void 0;
5537
+ }
5538
+ };
5539
+ var SessionTokenValidator = class {
5540
+ constructor(store) {
5541
+ this.store = store;
5542
+ }
5543
+ store;
5544
+ async validate(token) {
5545
+ return this.store.verify(token);
5546
+ }
5547
+ };
5548
+ var CompositeTokenValidator = class {
5549
+ constructor(validators) {
5550
+ this.validators = validators;
5551
+ }
5552
+ validators;
5553
+ async validate(token) {
5554
+ for (const v of this.validators) {
5555
+ const id = await v.validate(token);
5556
+ if (id !== void 0) {
5557
+ return id;
5558
+ }
5559
+ }
5560
+ return void 0;
5561
+ }
5562
+ };
4502
5563
  function bearerAuth(opts) {
4503
5564
  return async function authMiddleware(request, reply) {
4504
5565
  const header = request.headers.authorization;
@@ -4507,10 +5568,12 @@ function bearerAuth(opts) {
4507
5568
  return;
4508
5569
  }
4509
5570
  const token = header.slice(BEARER_PREFIX.length).trim();
4510
- if (!constantTimeEqual(token, opts.config.daemon.authToken)) {
5571
+ const identity = await opts.validator.validate(token);
5572
+ if (!identity) {
4511
5573
  reply.code(403).send({ error: "Invalid token" });
4512
5574
  return;
4513
5575
  }
5576
+ request.authIdentity = identity;
4514
5577
  };
4515
5578
  }
4516
5579
  function tokenFromUpgradeRequest(req) {
@@ -4549,6 +5612,40 @@ function constantTimeEqual(a, b) {
4549
5612
  return mismatch === 0;
4550
5613
  }
4551
5614
 
5615
+ // src/daemon/rate-limit.ts
5616
+ var AuthRateLimiter = class {
5617
+ entries = /* @__PURE__ */ new Map();
5618
+ maxFails;
5619
+ windowMs;
5620
+ constructor(maxFails = 10, windowMs = 15 * 60 * 1e3) {
5621
+ this.maxFails = maxFails;
5622
+ this.windowMs = windowMs;
5623
+ }
5624
+ isBlocked(ip) {
5625
+ const e = this.entries.get(ip);
5626
+ if (!e) {
5627
+ return false;
5628
+ }
5629
+ if (Date.now() - e.windowStart > this.windowMs) {
5630
+ this.entries.delete(ip);
5631
+ return false;
5632
+ }
5633
+ return e.fails >= this.maxFails;
5634
+ }
5635
+ recordFailure(ip) {
5636
+ const now = Date.now();
5637
+ const e = this.entries.get(ip);
5638
+ if (!e || now - e.windowStart > this.windowMs) {
5639
+ this.entries.set(ip, { fails: 1, windowStart: now });
5640
+ return;
5641
+ }
5642
+ e.fails += 1;
5643
+ }
5644
+ recordSuccess(ip) {
5645
+ this.entries.delete(ip);
5646
+ }
5647
+ };
5648
+
4552
5649
  // src/daemon/routes/sessions.ts
4553
5650
  import * as os3 from "os";
4554
5651
 
@@ -4579,6 +5676,7 @@ var BundleSession = z5.object({
4579
5676
  currentMode: z5.string().optional(),
4580
5677
  currentUsage: PersistedUsage.optional(),
4581
5678
  agentCommands: z5.array(PersistedAgentCommand).optional(),
5679
+ agentModes: z5.array(PersistedAgentMode).optional(),
4582
5680
  createdAt: z5.string(),
4583
5681
  updatedAt: z5.string()
4584
5682
  });
@@ -4612,6 +5710,7 @@ function encodeBundle(params) {
4612
5710
  ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
4613
5711
  ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
4614
5712
  ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
5713
+ ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
4615
5714
  createdAt: params.record.createdAt,
4616
5715
  updatedAt: params.record.updatedAt
4617
5716
  },
@@ -4670,6 +5769,8 @@ function mapUpdate(update) {
4670
5769
  return mapUsage(u);
4671
5770
  case "available_commands_update":
4672
5771
  return mapAvailableCommands(u);
5772
+ case "available_modes_update":
5773
+ return mapAvailableModes(u);
4673
5774
  case "session_info_update":
4674
5775
  return mapSessionInfo(u);
4675
5776
  default:
@@ -4731,6 +5832,31 @@ function mapAvailableCommands(u) {
4731
5832
  }
4732
5833
  return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
4733
5834
  }
5835
+ function mapAvailableModes(u) {
5836
+ const list = u.availableModes;
5837
+ if (!Array.isArray(list)) {
5838
+ return null;
5839
+ }
5840
+ const modes = [];
5841
+ for (const raw of list) {
5842
+ if (!raw || typeof raw !== "object") {
5843
+ continue;
5844
+ }
5845
+ const m = raw;
5846
+ if (typeof m.id !== "string" || m.id.length === 0) {
5847
+ continue;
5848
+ }
5849
+ const mode = { id: sanitizeSingleLine(m.id) };
5850
+ if (typeof m.name === "string") {
5851
+ mode.name = sanitizeSingleLine(m.name);
5852
+ }
5853
+ if (typeof m.description === "string") {
5854
+ mode.description = sanitizeSingleLine(m.description);
5855
+ }
5856
+ modes.push(mode);
5857
+ }
5858
+ return { kind: "available-modes", modes };
5859
+ }
4734
5860
  function mapUsage(u) {
4735
5861
  const event = { kind: "usage-update" };
4736
5862
  if (typeof u.used === "number") {
@@ -4851,7 +5977,7 @@ function mapPlan(u) {
4851
5977
  return { kind: "plan", entries: normalized };
4852
5978
  }
4853
5979
  function mapMode(u) {
4854
- const mode = readString(u, "currentMode") ?? readString(u, "mode");
5980
+ const mode = readString(u, "currentModeId") ?? readString(u, "currentMode") ?? readString(u, "mode");
4855
5981
  if (!mode) {
4856
5982
  return null;
4857
5983
  }
@@ -5514,6 +6640,157 @@ function registerConfigRoutes(app, defaults) {
5514
6640
  });
5515
6641
  }
5516
6642
 
6643
+ // src/daemon/routes/auth.ts
6644
+ import { z as z6 } from "zod";
6645
+
6646
+ // src/core/password.ts
6647
+ import * as fs13 from "fs/promises";
6648
+ import * as path10 from "path";
6649
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
6650
+ import { promisify } from "util";
6651
+ var scryptAsync = promisify(scrypt);
6652
+ function passwordHashPath() {
6653
+ return path10.join(paths.home(), "password-hash");
6654
+ }
6655
+ var DEFAULT_N = 1 << 15;
6656
+ var MAX_MEM = 128 * 1024 * 1024;
6657
+ async function hasPassword() {
6658
+ try {
6659
+ const text = await fs13.readFile(passwordHashPath(), "utf8");
6660
+ return text.trim().length > 0;
6661
+ } catch (err) {
6662
+ const e = err;
6663
+ if (e.code === "ENOENT") {
6664
+ return false;
6665
+ }
6666
+ throw err;
6667
+ }
6668
+ }
6669
+ async function verifyPassword(plaintext) {
6670
+ if (typeof plaintext !== "string" || plaintext.length === 0) {
6671
+ return false;
6672
+ }
6673
+ let line;
6674
+ try {
6675
+ line = (await fs13.readFile(passwordHashPath(), "utf8")).trim();
6676
+ } catch (err) {
6677
+ const e = err;
6678
+ if (e.code === "ENOENT") {
6679
+ return false;
6680
+ }
6681
+ throw err;
6682
+ }
6683
+ const parts = line.split("$");
6684
+ if (parts.length !== 6 || parts[0] !== "scrypt") {
6685
+ return false;
6686
+ }
6687
+ const N = parseInt(parts[1], 10);
6688
+ const r = parseInt(parts[2], 10);
6689
+ const p = parseInt(parts[3], 10);
6690
+ if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
6691
+ return false;
6692
+ }
6693
+ const salt = Buffer.from(parts[4], "hex");
6694
+ const expected = Buffer.from(parts[5], "hex");
6695
+ if (salt.length === 0 || expected.length === 0) {
6696
+ return false;
6697
+ }
6698
+ const actual = await scryptAsync(plaintext, salt, expected.length, {
6699
+ N,
6700
+ r,
6701
+ p,
6702
+ maxmem: MAX_MEM
6703
+ });
6704
+ if (actual.length !== expected.length) {
6705
+ return false;
6706
+ }
6707
+ return timingSafeEqual2(actual, expected);
6708
+ }
6709
+
6710
+ // src/daemon/routes/auth.ts
6711
+ var LoginBody = z6.object({
6712
+ password: z6.string().min(1),
6713
+ label: z6.string().min(1).max(256).optional(),
6714
+ ttlSec: z6.number().int().positive().optional()
6715
+ });
6716
+ var LogoutBody = z6.object({
6717
+ id: z6.string().optional()
6718
+ }).optional();
6719
+ function registerAuthRoutes(app, deps) {
6720
+ app.post(
6721
+ "/v1/auth/login",
6722
+ { config: { skipAuth: true } },
6723
+ async (request, reply) => {
6724
+ const ip = remoteIp(request);
6725
+ if (deps.rateLimiter.isBlocked(ip)) {
6726
+ return reply.code(429).send({
6727
+ error: "Too many failed attempts; try again later."
6728
+ });
6729
+ }
6730
+ let body;
6731
+ try {
6732
+ body = LoginBody.parse(request.body);
6733
+ } catch {
6734
+ return reply.code(400).send({ error: "Invalid request body" });
6735
+ }
6736
+ if (!await hasPassword()) {
6737
+ return reply.code(403).send({
6738
+ error: "No password configured. Run `hydra-acp auth password` on the daemon host."
6739
+ });
6740
+ }
6741
+ const ok = await verifyPassword(body.password);
6742
+ if (!ok) {
6743
+ deps.rateLimiter.recordFailure(ip);
6744
+ return reply.code(401).send({ error: "Invalid password" });
6745
+ }
6746
+ deps.rateLimiter.recordSuccess(ip);
6747
+ const issued = await deps.store.issue({
6748
+ label: body.label,
6749
+ ttlSec: body.ttlSec
6750
+ });
6751
+ return reply.code(200).send({
6752
+ session_token: issued.token,
6753
+ id: issued.id,
6754
+ expires_at: issued.expiresAt
6755
+ });
6756
+ }
6757
+ );
6758
+ app.post("/v1/auth/logout", async (request, reply) => {
6759
+ let body = void 0;
6760
+ try {
6761
+ body = LogoutBody.parse(request.body ?? void 0);
6762
+ } catch {
6763
+ return reply.code(400).send({ error: "Invalid request body" });
6764
+ }
6765
+ const id = body?.id ?? request.authIdentity;
6766
+ if (!id || id === "service") {
6767
+ return reply.code(200).send({ revoked: false });
6768
+ }
6769
+ const revoked = await deps.store.revoke(id);
6770
+ return reply.code(200).send({ revoked });
6771
+ });
6772
+ app.get("/v1/auth/verify", async (_request, reply) => {
6773
+ return reply.code(200).send({ ok: true });
6774
+ });
6775
+ app.get("/v1/auth/sessions", async (_request, reply) => {
6776
+ return reply.code(200).send({ sessions: deps.store.list() });
6777
+ });
6778
+ app.delete(
6779
+ "/v1/auth/sessions/:id",
6780
+ async (request, reply) => {
6781
+ const id = request.params.id;
6782
+ const revoked = await deps.store.revoke(id);
6783
+ if (!revoked) {
6784
+ return reply.code(404).send({ error: "Not found" });
6785
+ }
6786
+ return reply.code(204).send();
6787
+ }
6788
+ );
6789
+ }
6790
+ function remoteIp(request) {
6791
+ return request.ip || "unknown";
6792
+ }
6793
+
5517
6794
  // src/daemon/acp-ws.ts
5518
6795
  import { nanoid as nanoid2 } from "nanoid";
5519
6796
 
@@ -5590,12 +6867,12 @@ function wsToMessageStream(ws) {
5590
6867
 
5591
6868
  // src/daemon/acp-ws.ts
5592
6869
  function registerAcpWsEndpoint(app, deps) {
5593
- app.get("/acp", { websocket: true }, (socket, request) => {
6870
+ app.get("/acp", { websocket: true }, async (socket, request) => {
5594
6871
  const token = tokenFromUpgradeRequest({
5595
6872
  headers: request.headers,
5596
6873
  url: request.url
5597
6874
  });
5598
- if (!token || !constantTimeEqual(token, deps.config.daemon.authToken)) {
6875
+ if (!token || !await deps.validator.validate(token)) {
5599
6876
  socket.close(4401, "Unauthorized");
5600
6877
  return;
5601
6878
  }
@@ -5646,8 +6923,15 @@ function registerAcpWsEndpoint(app, deps) {
5646
6923
  }
5647
6924
  })();
5648
6925
  });
6926
+ const modesPayload = buildModesPayload(session);
5649
6927
  return {
5650
6928
  sessionId: session.sessionId,
6929
+ // session/new is implicitly an attach; mirror session/attach's
6930
+ // shape by including the clientId so deferred-echo clients
6931
+ // (TUI's queue work) can recognize their own prompt_queue_added
6932
+ // events without an extra round-trip.
6933
+ clientId: client.clientId,
6934
+ ...modesPayload ? { modes: modesPayload } : {},
5651
6935
  _meta: buildResponseMeta(session)
5652
6936
  };
5653
6937
  });
@@ -5708,6 +6992,7 @@ function registerAcpWsEndpoint(app, deps) {
5708
6992
  await connection.notify(note.method, note.params);
5709
6993
  }
5710
6994
  session.replayPendingPermissions(client);
6995
+ const modesPayload = buildModesPayload(session);
5711
6996
  return {
5712
6997
  sessionId: session.sessionId,
5713
6998
  clientId: client.clientId,
@@ -5718,6 +7003,7 @@ function registerAcpWsEndpoint(app, deps) {
5718
7003
  // ran, not what was asked for.
5719
7004
  historyPolicy: appliedPolicy,
5720
7005
  replayed: replay.length,
7006
+ ...modesPayload ? { modes: modesPayload } : {},
5721
7007
  _meta: buildResponseMeta(session)
5722
7008
  };
5723
7009
  });
@@ -5751,7 +7037,29 @@ function registerAcpWsEndpoint(app, deps) {
5751
7037
  err.code = JsonRpcErrorCodes.SessionNotFound;
5752
7038
  throw err;
5753
7039
  }
5754
- const session = deps.manager.require(params.sessionId);
7040
+ let session = deps.manager.get(params.sessionId);
7041
+ if (!session) {
7042
+ const fromDisk = await deps.manager.loadFromDisk(params.sessionId);
7043
+ if (!fromDisk) {
7044
+ const err = new Error(
7045
+ `session ${params.sessionId} not found`
7046
+ );
7047
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7048
+ throw err;
7049
+ }
7050
+ app.log.info(
7051
+ `session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
7052
+ );
7053
+ session = await deps.manager.resurrect(fromDisk);
7054
+ const client = bindClientToSession(
7055
+ connection,
7056
+ session,
7057
+ state,
7058
+ void 0,
7059
+ att.clientId
7060
+ );
7061
+ await session.attach(client, "none");
7062
+ }
5755
7063
  return session.prompt(att.clientId, params);
5756
7064
  });
5757
7065
  const handleCancelParams = (raw) => {
@@ -5783,6 +7091,26 @@ function registerAcpWsEndpoint(app, deps) {
5783
7091
  handleCancelParams(raw);
5784
7092
  return null;
5785
7093
  });
7094
+ connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
7095
+ const params = CancelPromptParams.parse(raw);
7096
+ const session = deps.manager.get(params.sessionId);
7097
+ if (!session) {
7098
+ const err = new Error(`session ${params.sessionId} not found`);
7099
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7100
+ throw err;
7101
+ }
7102
+ return session.cancelQueuedPrompt(params.messageId);
7103
+ });
7104
+ connection.onRequest("hydra-acp/update_prompt", async (raw) => {
7105
+ const params = UpdatePromptParams.parse(raw);
7106
+ const session = deps.manager.get(params.sessionId);
7107
+ if (!session) {
7108
+ const err = new Error(`session ${params.sessionId} not found`);
7109
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7110
+ throw err;
7111
+ }
7112
+ return session.updateQueuedPrompt(params.messageId, params.prompt);
7113
+ });
5786
7114
  connection.onRequest("session/load", async (raw) => {
5787
7115
  const rawObj = raw ?? {};
5788
7116
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -5814,8 +7142,13 @@ function registerAcpWsEndpoint(app, deps) {
5814
7142
  await connection.notify(note.method, note.params);
5815
7143
  }
5816
7144
  session.replayPendingPermissions(client);
7145
+ const modesPayload = buildModesPayload(session);
5817
7146
  return {
5818
7147
  sessionId: session.sessionId,
7148
+ // Same as session/new: include clientId so the deferred-echo
7149
+ // path in queue-aware clients can recognize own broadcasts.
7150
+ clientId: client.clientId,
7151
+ ...modesPayload ? { modes: modesPayload } : {},
5819
7152
  _meta: buildResponseMeta(session)
5820
7153
  };
5821
7154
  });
@@ -5841,6 +7174,26 @@ function registerAcpWsEndpoint(app, deps) {
5841
7174
  });
5842
7175
  });
5843
7176
  }
7177
+ function buildModesPayload(session) {
7178
+ const modes = session.availableModes();
7179
+ if (modes.length === 0) {
7180
+ return void 0;
7181
+ }
7182
+ const availableModes = modes.map((m) => {
7183
+ const out = {
7184
+ id: m.id,
7185
+ // ACP spec requires `name` — fall back to id when the agent didn't
7186
+ // supply one so we never emit an invalid SessionMode.
7187
+ name: m.name ?? m.id
7188
+ };
7189
+ if (m.description !== void 0) {
7190
+ out.description = m.description;
7191
+ }
7192
+ return out;
7193
+ });
7194
+ const currentModeId = session.currentMode ?? modes[0].id;
7195
+ return { currentModeId, availableModes };
7196
+ }
5844
7197
  function buildResponseMeta(session) {
5845
7198
  const ours = {
5846
7199
  upstreamSessionId: session.upstreamSessionId,
@@ -5866,9 +7219,17 @@ function buildResponseMeta(session) {
5866
7219
  if (commands.length > 0) {
5867
7220
  ours.availableCommands = commands;
5868
7221
  }
7222
+ const modes = session.availableModes();
7223
+ if (modes.length > 0) {
7224
+ ours.availableModes = modes;
7225
+ }
5869
7226
  if (session.turnStartedAt !== void 0) {
5870
7227
  ours.turnStartedAt = session.turnStartedAt;
5871
7228
  }
7229
+ const queue = session.queueSnapshot();
7230
+ if (queue.length > 0) {
7231
+ ours.queue = queue;
7232
+ }
5872
7233
  return mergeMeta(session.agentMeta, ours);
5873
7234
  }
5874
7235
  function buildInitializeResult() {
@@ -5899,7 +7260,13 @@ function buildInitializeResult() {
5899
7260
  id: "bearer-token",
5900
7261
  description: "Bearer token presented at WS upgrade"
5901
7262
  }
5902
- ]
7263
+ ],
7264
+ // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
7265
+ // ACP clients ignore the field; capability-aware clients learn here
7266
+ // that hydra accepts concurrent session/prompt requests and emits
7267
+ // prompt_queue_* notifications so they can stop running their own
7268
+ // local queue.
7269
+ _meta: mergeMeta(void 0, { promptQueueing: true })
5903
7270
  };
5904
7271
  }
5905
7272
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
@@ -5913,13 +7280,13 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
5913
7280
  }
5914
7281
 
5915
7282
  // src/daemon/server.ts
5916
- async function startDaemon(config) {
7283
+ async function startDaemon(config, serviceToken) {
5917
7284
  ensureLoopbackOrTls(config);
5918
7285
  const httpsOptions = config.daemon.tls ? {
5919
- key: await fsp4.readFile(config.daemon.tls.key),
5920
- cert: await fsp4.readFile(config.daemon.tls.cert)
7286
+ key: await fsp5.readFile(config.daemon.tls.key),
7287
+ cert: await fsp5.readFile(config.daemon.tls.cert)
5921
7288
  } : void 0;
5922
- await fsp4.mkdir(paths.home(), { recursive: true });
7289
+ await fsp5.mkdir(paths.home(), { recursive: true });
5923
7290
  const { stream: logStream, fileStream } = await buildLogStream(
5924
7291
  config.daemon.logLevel
5925
7292
  );
@@ -5940,7 +7307,13 @@ async function startDaemon(config) {
5940
7307
  setNpmInstallLogger((msg) => {
5941
7308
  app.log.info(msg);
5942
7309
  });
5943
- const auth = bearerAuth({ config });
7310
+ const sessionTokenStore = await SessionTokenStore.load();
7311
+ const authRateLimiter = new AuthRateLimiter();
7312
+ const validator = new CompositeTokenValidator([
7313
+ new StaticTokenValidator(serviceToken),
7314
+ new SessionTokenValidator(sessionTokenStore)
7315
+ ]);
7316
+ const auth = bearerAuth({ validator });
5944
7317
  app.addHook("onRequest", async (request, reply) => {
5945
7318
  if (request.routeOptions.config?.skipAuth) {
5946
7319
  return;
@@ -5950,7 +7323,19 @@ async function startDaemon(config) {
5950
7323
  }
5951
7324
  await auth(request, reply);
5952
7325
  });
5953
- const registry = new Registry(config);
7326
+ const sweepInterval = setInterval(
7327
+ () => {
7328
+ sessionTokenStore.sweepExpired();
7329
+ },
7330
+ 5 * 60 * 1e3
7331
+ );
7332
+ sweepInterval.unref();
7333
+ const registry = new Registry(config, {
7334
+ onFetched: () => {
7335
+ void pruneStaleAgentVersions(registry, manager);
7336
+ }
7337
+ });
7338
+ setAgentPruneLogger((msg) => app.log.info(msg));
5954
7339
  const agentLogger = {
5955
7340
  info: (msg) => app.log.info(msg),
5956
7341
  warn: (msg) => app.log.warn(msg)
@@ -5964,7 +7349,8 @@ async function startDaemon(config) {
5964
7349
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
5965
7350
  defaultModels: config.defaultModels,
5966
7351
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
5967
- logger: agentLogger
7352
+ logger: agentLogger,
7353
+ npmRegistry: config.npmRegistry
5968
7354
  });
5969
7355
  const extensions = new ExtensionManager(extensionList(config));
5970
7356
  registerHealthRoutes(app, HYDRA_VERSION);
@@ -5978,16 +7364,20 @@ async function startDaemon(config) {
5978
7364
  defaultAgent: config.defaultAgent,
5979
7365
  defaultCwd: config.defaultCwd
5980
7366
  });
7367
+ registerAuthRoutes(app, {
7368
+ store: sessionTokenStore,
7369
+ rateLimiter: authRateLimiter
7370
+ });
5981
7371
  registerAcpWsEndpoint(app, {
5982
- config,
7372
+ validator,
5983
7373
  manager,
5984
7374
  defaultAgent: config.defaultAgent
5985
7375
  });
5986
7376
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
5987
7377
  const address = app.server.address();
5988
7378
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
5989
- await fsp4.mkdir(paths.home(), { recursive: true });
5990
- await fsp4.writeFile(
7379
+ await fsp5.mkdir(paths.home(), { recursive: true });
7380
+ await fsp5.writeFile(
5991
7381
  paths.pidFile(),
5992
7382
  JSON.stringify({
5993
7383
  pid: process.pid,
@@ -6003,20 +7393,28 @@ async function startDaemon(config) {
6003
7393
  daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
6004
7394
  daemonHost: config.daemon.host,
6005
7395
  daemonPort: boundPort,
6006
- daemonToken: config.daemon.authToken,
7396
+ serviceToken,
6007
7397
  daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
6008
7398
  hydraHome: paths.home()
6009
7399
  });
6010
7400
  await extensions.start();
7401
+ void manager.resurrectPendingQueues().catch((err) => {
7402
+ app.log.warn(
7403
+ `queue replay scan failed: ${err.message}`
7404
+ );
7405
+ });
6011
7406
  const shutdown = async () => {
7407
+ clearInterval(sweepInterval);
7408
+ await sessionTokenStore.flush();
6012
7409
  await extensions.stop();
6013
7410
  await manager.closeAll();
6014
7411
  await manager.flushMetaWrites();
6015
7412
  setBinaryInstallLogger(null);
6016
7413
  setNpmInstallLogger(null);
7414
+ setAgentPruneLogger(null);
6017
7415
  await app.close();
6018
7416
  try {
6019
- fs10.unlinkSync(paths.pidFile());
7417
+ fs14.unlinkSync(paths.pidFile());
6020
7418
  } catch {
6021
7419
  }
6022
7420
  try {
@@ -6057,9 +7455,10 @@ export {
6057
7455
  Session,
6058
7456
  SessionManager,
6059
7457
  defaultConfig,
6060
- ensureConfig,
6061
- generateAuthToken,
7458
+ ensureServiceToken,
7459
+ generateServiceToken,
6062
7460
  loadConfig,
7461
+ loadServiceToken,
6063
7462
  ndjsonStreamFromStdio,
6064
7463
  paths,
6065
7464
  planSpawn,