@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/cli.js CHANGED
@@ -77,6 +77,12 @@ var init_paths = __esm({
77
77
  sessionDir: (id) => path.join(hydraHome(), "sessions", id),
78
78
  sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
79
79
  historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
80
+ // Persisted prompt queue for a session. ndjson, one record per
81
+ // entry. Survives daemon restarts so queued prompts get a chance to
82
+ // run rather than being silently lost. Entries are removed BEFORE
83
+ // the agent invocation (see Session.drainQueue) so a crash mid-
84
+ // generation doesn't double-run on restart.
85
+ queueFile: (id) => path.join(hydraHome(), "sessions", id, "queue.ndjson"),
80
86
  extensionsDir: () => path.join(hydraHome(), "extensions"),
81
87
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
82
88
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
@@ -86,8 +92,68 @@ var init_paths = __esm({
86
92
  }
87
93
  });
88
94
 
89
- // src/core/config.ts
95
+ // src/core/service-token.ts
90
96
  import * as fs from "fs/promises";
97
+ function generateServiceToken() {
98
+ const bytes = new Uint8Array(32);
99
+ crypto.getRandomValues(bytes);
100
+ let hex = "";
101
+ for (const b of bytes) {
102
+ hex += b.toString(16).padStart(2, "0");
103
+ }
104
+ return `hydra_token_${hex}`;
105
+ }
106
+ async function readServiceToken() {
107
+ try {
108
+ const text = await fs.readFile(paths.authToken(), "utf8");
109
+ const trimmed = text.trim();
110
+ return trimmed.length > 0 ? trimmed : void 0;
111
+ } catch (err) {
112
+ const e = err;
113
+ if (e.code === "ENOENT") {
114
+ return void 0;
115
+ }
116
+ throw err;
117
+ }
118
+ }
119
+ async function loadServiceToken() {
120
+ const token = await readServiceToken();
121
+ if (!token) {
122
+ throw new Error(
123
+ `No service token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
124
+ );
125
+ }
126
+ return token;
127
+ }
128
+ async function writeServiceToken(token) {
129
+ await fs.mkdir(paths.home(), { recursive: true });
130
+ await fs.writeFile(paths.authToken(), token + "\n", {
131
+ encoding: "utf8",
132
+ mode: 384
133
+ });
134
+ }
135
+ async function ensureServiceToken() {
136
+ const existing = await readServiceToken();
137
+ if (existing) {
138
+ return existing;
139
+ }
140
+ const token = generateServiceToken();
141
+ await writeServiceToken(token);
142
+ process.stderr.write(
143
+ `hydra-acp: initialized ${paths.authToken()} with a fresh service token.
144
+ `
145
+ );
146
+ return token;
147
+ }
148
+ var init_service_token = __esm({
149
+ "src/core/service-token.ts"() {
150
+ "use strict";
151
+ init_paths();
152
+ }
153
+ });
154
+
155
+ // src/core/config.ts
156
+ import * as fs2 from "fs/promises";
91
157
  import { homedir as homedir2 } from "os";
92
158
  import { z } from "zod";
93
159
  function extensionList(config) {
@@ -99,7 +165,7 @@ function extensionList(config) {
99
165
  async function readConfigFile() {
100
166
  let raw;
101
167
  try {
102
- raw = await fs.readFile(paths.config(), "utf8");
168
+ raw = await fs2.readFile(paths.config(), "utf8");
103
169
  } catch (err) {
104
170
  const e = err;
105
171
  if (e.code === "ENOENT") {
@@ -109,44 +175,34 @@ async function readConfigFile() {
109
175
  }
110
176
  return JSON.parse(raw);
111
177
  }
112
- async function loadAuthToken() {
113
- let tokenFile;
178
+ async function migrateLegacyAuthToken() {
179
+ const raw = await readConfigFile();
180
+ const daemon = raw.daemon;
181
+ const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
182
+ if (!legacy) {
183
+ return;
184
+ }
185
+ let tokenFileExists = false;
114
186
  try {
115
- const text = await fs.readFile(paths.authToken(), "utf8");
116
- const trimmed = text.trim();
117
- if (trimmed.length > 0) {
118
- tokenFile = trimmed;
119
- }
187
+ await fs2.access(paths.authToken());
188
+ tokenFileExists = true;
120
189
  } catch (err) {
121
190
  const e = err;
122
191
  if (e.code !== "ENOENT") {
123
192
  throw err;
124
193
  }
125
194
  }
126
- const raw = await readConfigFile();
127
- const daemon = raw.daemon;
128
- const legacy = daemon && typeof daemon.authToken === "string" ? daemon.authToken : void 0;
129
- if (tokenFile && legacy) {
195
+ if (tokenFileExists) {
130
196
  throw new Error(
131
197
  `Auth token present in both ${paths.authToken()} and ${paths.config()} (daemon.authToken). Remove daemon.authToken from config.json to resolve.`
132
198
  );
133
199
  }
134
- if (tokenFile) {
135
- return tokenFile;
136
- }
137
- if (legacy) {
138
- await migrateLegacyAuthToken(raw, daemon, legacy);
139
- return legacy;
140
- }
141
- return void 0;
142
- }
143
- async function migrateLegacyAuthToken(raw, daemon, token) {
144
- await writeAuthToken(token);
200
+ await writeServiceToken(legacy);
145
201
  delete daemon.authToken;
146
202
  if (Object.keys(daemon).length === 0) {
147
203
  delete raw.daemon;
148
204
  }
149
- await fs.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
205
+ await fs2.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
150
206
  encoding: "utf8",
151
207
  mode: 384
152
208
  });
@@ -155,47 +211,9 @@ async function migrateLegacyAuthToken(raw, daemon, token) {
155
211
  `
156
212
  );
157
213
  }
158
- async function writeAuthToken(token) {
159
- await fs.mkdir(paths.home(), { recursive: true });
160
- await fs.writeFile(paths.authToken(), token + "\n", {
161
- encoding: "utf8",
162
- mode: 384
163
- });
164
- }
165
214
  async function loadConfig() {
166
- const token = await loadAuthToken();
167
- if (!token) {
168
- throw new Error(
169
- `No auth token found at ${paths.authToken()}. Run \`hydra-acp init\` to create one.`
170
- );
171
- }
172
- const raw = await readConfigFile();
173
- const daemon = raw.daemon ??= {};
174
- daemon.authToken = token;
175
- return HydraConfig.parse(raw);
176
- }
177
- async function loadConfigReadOnly() {
178
- return HydraConfigReadOnly.parse(await readConfigFile());
179
- }
180
- async function ensureConfig() {
181
- if (!await loadAuthToken()) {
182
- const token = generateAuthToken();
183
- await writeAuthToken(token);
184
- process.stderr.write(
185
- `hydra-acp: initialized ${paths.authToken()} with a fresh auth token.
186
- `
187
- );
188
- }
189
- return loadConfig();
190
- }
191
- function generateAuthToken() {
192
- const bytes = new Uint8Array(32);
193
- crypto.getRandomValues(bytes);
194
- let hex = "";
195
- for (const b of bytes) {
196
- hex += b.toString(16).padStart(2, "0");
197
- }
198
- return `hydra_token_${hex}`;
215
+ await migrateLegacyAuthToken();
216
+ return HydraConfig.parse(await readConfigFile());
199
217
  }
200
218
  function expandHome(p) {
201
219
  if (p === "~" || p === "$HOME") {
@@ -209,20 +227,21 @@ function expandHome(p) {
209
227
  }
210
228
  return p;
211
229
  }
212
- var REGISTRY_URL_DEFAULT, TlsConfig, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig, HydraConfigReadOnly;
230
+ var REGISTRY_URL_DEFAULT, TlsConfig, DEFAULT_DAEMON_PORT, DaemonConfig, RegistryConfig, TuiConfig, ExtensionName, ExtensionBody, HydraConfig;
213
231
  var init_config = __esm({
214
232
  "src/core/config.ts"() {
215
233
  "use strict";
216
234
  init_paths();
235
+ init_service_token();
217
236
  REGISTRY_URL_DEFAULT = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
218
237
  TlsConfig = z.object({
219
238
  cert: z.string(),
220
239
  key: z.string()
221
240
  });
241
+ DEFAULT_DAEMON_PORT = 55514;
222
242
  DaemonConfig = z.object({
223
243
  host: z.string().default("127.0.0.1"),
224
- port: z.number().int().positive().default(8765),
225
- authToken: z.string().min(16),
244
+ port: z.number().int().positive().default(DEFAULT_DAEMON_PORT),
226
245
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
227
246
  tls: TlsConfig.optional(),
228
247
  sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
@@ -283,7 +302,7 @@ var init_config = __esm({
283
302
  enabled: z.boolean().default(true)
284
303
  });
285
304
  HydraConfig = z.object({
286
- daemon: DaemonConfig,
305
+ daemon: DaemonConfig.default({}),
287
306
  registry: RegistryConfig.default({ url: REGISTRY_URL_DEFAULT, ttlHours: 24 }),
288
307
  defaultAgent: z.string().default("claude-acp"),
289
308
  // Optional per-agent default model id. When a brand-new agent process
@@ -303,6 +322,11 @@ var init_config = __esm({
303
322
  // recency and truncated to this count. `--all` overrides in the CLI.
304
323
  sessionListColdLimit: z.number().int().nonnegative().default(20),
305
324
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
325
+ // npm registry URL used when installing npm-distributed agents into
326
+ // ~/.hydra-acp/agents. Overrides the global ~/.npmrc registry so a
327
+ // corporate .npmrc pointing at an internal registry doesn't break
328
+ // public-package installs. Omit to let npm use its own defaults.
329
+ npmRegistry: z.string().url().optional(),
306
330
  tui: TuiConfig.default({
307
331
  repaintThrottleMs: 1e3,
308
332
  maxScrollbackLines: 1e4,
@@ -312,9 +336,6 @@ var init_config = __esm({
312
336
  progressIndicator: true
313
337
  })
314
338
  });
315
- HydraConfigReadOnly = HydraConfig.extend({
316
- daemon: DaemonConfig.omit({ authToken: true }).default({})
317
- });
318
339
  }
319
340
  });
320
341
 
@@ -389,12 +410,64 @@ function extractHydraMeta(meta) {
389
410
  out.availableCommands = cmds;
390
411
  }
391
412
  }
413
+ if (typeof obj.promptQueueing === "boolean") {
414
+ out.promptQueueing = obj.promptQueueing;
415
+ }
416
+ if (Array.isArray(obj.queue)) {
417
+ const entries = [];
418
+ for (const raw of obj.queue) {
419
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
420
+ continue;
421
+ }
422
+ const r = raw;
423
+ const orig = r.originator;
424
+ if (typeof r.messageId !== "string" || !orig || typeof orig.clientId !== "string" || !Array.isArray(r.prompt) || typeof r.position !== "number" || typeof r.enqueuedAt !== "number") {
425
+ continue;
426
+ }
427
+ const originator = { clientId: orig.clientId };
428
+ if (typeof orig.name === "string") originator.name = orig.name;
429
+ if (typeof orig.version === "string") originator.version = orig.version;
430
+ entries.push({
431
+ messageId: r.messageId,
432
+ originator,
433
+ prompt: r.prompt,
434
+ position: r.position,
435
+ enqueuedAt: r.enqueuedAt
436
+ });
437
+ }
438
+ if (entries.length > 0) {
439
+ out.queue = entries;
440
+ }
441
+ }
442
+ if (Array.isArray(obj.availableModes)) {
443
+ const modes = [];
444
+ for (const raw of obj.availableModes) {
445
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
446
+ continue;
447
+ }
448
+ const m = raw;
449
+ if (typeof m.id !== "string") {
450
+ continue;
451
+ }
452
+ const mode = { id: m.id };
453
+ if (typeof m.name === "string") {
454
+ mode.name = m.name;
455
+ }
456
+ if (typeof m.description === "string") {
457
+ mode.description = m.description;
458
+ }
459
+ modes.push(mode);
460
+ }
461
+ if (modes.length > 0) {
462
+ out.availableModes = modes;
463
+ }
464
+ }
392
465
  return out;
393
466
  }
394
467
  function mergeMeta(passthrough, ours) {
395
468
  return { ...passthrough ?? {}, [HYDRA_META_KEY]: ours };
396
469
  }
397
- var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, ProxyInitializeParams;
470
+ var ACP_PROTOCOL_VERSION, JsonRpcErrorCodes, InitializeParams, HistoryPolicy, SessionNewParams, SessionResumeHints, SessionAttachParams, HYDRA_META_KEY, SessionDetachParams, SessionListParams, SessionListUsage, SessionListEntry, SessionListResult, SessionPromptParams, SessionCancelParams, PromptOriginatorSchema, PromptQueueAddedParams, PromptQueueUpdatedParams, PromptQueueRemovedParams, CancelPromptParams, CancelPromptResult, UpdatePromptParams, UpdatePromptResult, ProxyInitializeParams;
398
471
  var init_types = __esm({
399
472
  "src/acp/types.ts"() {
400
473
  "use strict";
@@ -517,6 +590,49 @@ var init_types = __esm({
517
590
  SessionCancelParams = z3.object({
518
591
  sessionId: z3.string()
519
592
  });
593
+ PromptOriginatorSchema = z3.object({
594
+ clientId: z3.string(),
595
+ name: z3.string().optional(),
596
+ version: z3.string().optional()
597
+ });
598
+ PromptQueueAddedParams = z3.object({
599
+ sessionId: z3.string(),
600
+ messageId: z3.string(),
601
+ originator: PromptOriginatorSchema,
602
+ prompt: z3.array(z3.unknown()),
603
+ // 0 = head (currently in-flight). At enqueue time the new entry's
604
+ // position equals the count of entries already ahead of it.
605
+ position: z3.number().int().nonnegative(),
606
+ queueDepth: z3.number().int().positive(),
607
+ enqueuedAt: z3.number()
608
+ });
609
+ PromptQueueUpdatedParams = z3.object({
610
+ sessionId: z3.string(),
611
+ messageId: z3.string(),
612
+ prompt: z3.array(z3.unknown())
613
+ });
614
+ PromptQueueRemovedParams = z3.object({
615
+ sessionId: z3.string(),
616
+ messageId: z3.string(),
617
+ reason: z3.enum(["started", "cancelled", "abandoned"])
618
+ });
619
+ CancelPromptParams = z3.object({
620
+ sessionId: z3.string(),
621
+ messageId: z3.string()
622
+ });
623
+ CancelPromptResult = z3.object({
624
+ cancelled: z3.boolean(),
625
+ reason: z3.enum(["ok", "not_found", "already_running"])
626
+ });
627
+ UpdatePromptParams = z3.object({
628
+ sessionId: z3.string(),
629
+ messageId: z3.string(),
630
+ prompt: z3.array(z3.unknown())
631
+ });
632
+ UpdatePromptResult = z3.object({
633
+ updated: z3.boolean(),
634
+ reason: z3.enum(["ok", "not_found", "already_running"])
635
+ });
520
636
  ProxyInitializeParams = z3.object({
521
637
  protocolVersion: z3.number().optional(),
522
638
  proxyInfo: z3.object({
@@ -766,6 +882,53 @@ var init_hydra_commands = __esm({
766
882
  }
767
883
  });
768
884
 
885
+ // src/core/queue-store.ts
886
+ import * as fs6 from "fs/promises";
887
+ async function rewriteQueue(sessionId, entries) {
888
+ const file = paths.queueFile(sessionId);
889
+ if (entries.length === 0) {
890
+ await fs6.unlink(file).catch(() => void 0);
891
+ return;
892
+ }
893
+ await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
894
+ const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
895
+ await fs6.writeFile(file, body, "utf8");
896
+ }
897
+ async function loadQueue(sessionId) {
898
+ const file = paths.queueFile(sessionId);
899
+ let text;
900
+ try {
901
+ text = await fs6.readFile(file, "utf8");
902
+ } catch (err) {
903
+ if (err.code === "ENOENT") {
904
+ return [];
905
+ }
906
+ throw err;
907
+ }
908
+ const out = [];
909
+ for (const line of text.split("\n")) {
910
+ if (!line.trim()) continue;
911
+ try {
912
+ const parsed = JSON.parse(line);
913
+ if (parsed && typeof parsed.messageId === "string" && Array.isArray(parsed.prompt) && typeof parsed.enqueuedAt === "number") {
914
+ out.push(parsed);
915
+ }
916
+ } catch {
917
+ }
918
+ }
919
+ return out;
920
+ }
921
+ async function deleteQueue(sessionId) {
922
+ const file = paths.queueFile(sessionId);
923
+ await fs6.unlink(file).catch(() => void 0);
924
+ }
925
+ var init_queue_store = __esm({
926
+ "src/core/queue-store.ts"() {
927
+ "use strict";
928
+ init_paths();
929
+ }
930
+ });
931
+
769
932
  // src/core/session.ts
770
933
  import { customAlphabet } from "nanoid";
771
934
  function generateMessageId() {
@@ -797,6 +960,47 @@ function sameAdvertisedCommands(a, b) {
797
960
  }
798
961
  return true;
799
962
  }
963
+ function sameAdvertisedModes(a, b) {
964
+ if (a.length !== b.length) {
965
+ return false;
966
+ }
967
+ for (let i = 0; i < a.length; i++) {
968
+ if (a[i]?.id !== b[i]?.id || a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
969
+ return false;
970
+ }
971
+ }
972
+ return true;
973
+ }
974
+ function extractAdvertisedModes(params) {
975
+ const obj = params ?? {};
976
+ const update = obj.update ?? {};
977
+ if (update.sessionUpdate !== "available_modes_update") {
978
+ return null;
979
+ }
980
+ const list = update.availableModes;
981
+ if (!Array.isArray(list)) {
982
+ return [];
983
+ }
984
+ const out = [];
985
+ for (const raw of list) {
986
+ if (!raw || typeof raw !== "object") {
987
+ continue;
988
+ }
989
+ const m = raw;
990
+ if (typeof m.id !== "string" || m.id.length === 0) {
991
+ continue;
992
+ }
993
+ const mode = { id: m.id };
994
+ if (typeof m.name === "string") {
995
+ mode.name = m.name;
996
+ }
997
+ if (typeof m.description === "string") {
998
+ mode.description = m.description;
999
+ }
1000
+ out.push(mode);
1001
+ }
1002
+ return out;
1003
+ }
800
1004
  function captureInternalChunk(capture, params) {
801
1005
  const obj = params ?? {};
802
1006
  const update = obj.update ?? {};
@@ -955,6 +1159,7 @@ var init_session = __esm({
955
1159
  "src/core/session.ts"() {
956
1160
  "use strict";
957
1161
  init_hydra_commands();
1162
+ init_queue_store();
958
1163
  init_types();
959
1164
  HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
960
1165
  generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
@@ -984,7 +1189,18 @@ var init_session = __esm({
984
1189
  clients = /* @__PURE__ */ new Map();
985
1190
  historyStore;
986
1191
  promptQueue = [];
1192
+ // The entry that drainQueue is currently awaiting. Distinct from
1193
+ // promptQueue[0] (which is the *next* one to dequeue): once shifted
1194
+ // off, the entry lives here for the duration of its task() so
1195
+ // cancelQueuedPrompt can distinguish "still in line" from "running"
1196
+ // and return already_running for the latter.
1197
+ currentEntry;
987
1198
  promptInFlight = false;
1199
+ // Serialize disk writes to the persisted queue file. Without this
1200
+ // chain, fire-and-forget appends/rewrites can interleave (e.g.
1201
+ // drainQueue's rewrite-to-empty races a sibling's append-on-
1202
+ // enqueue) and leave the file out of sync with in-memory state.
1203
+ queueWriteChain = Promise.resolve();
988
1204
  closed = false;
989
1205
  closeHandlers = [];
990
1206
  titleHandlers = [];
@@ -1037,10 +1253,14 @@ var init_session = __esm({
1037
1253
  // can deliver the merged list via _meta without depending on history
1038
1254
  // replay.
1039
1255
  agentAdvertisedCommands = [];
1256
+ // Last available_modes_update we observed from the agent. Same
1257
+ // pattern as commands: cache, persist, broadcast on change.
1258
+ agentAdvertisedModes = [];
1040
1259
  // Persist hooks for snapshot-shaped state. SessionManager hooks these
1041
1260
  // to mirror changes into meta.json so cold-resurrect attaches can
1042
1261
  // surface the latest snapshot via the attach response _meta.
1043
1262
  agentCommandsHandlers = [];
1263
+ agentModesHandlers = [];
1044
1264
  modelHandlers = [];
1045
1265
  modeHandlers = [];
1046
1266
  usageHandlers = [];
@@ -1059,6 +1279,9 @@ var init_session = __esm({
1059
1279
  if (init.agentCommands && init.agentCommands.length > 0) {
1060
1280
  this.agentAdvertisedCommands = [...init.agentCommands];
1061
1281
  }
1282
+ if (init.agentModes && init.agentModes.length > 0) {
1283
+ this.agentAdvertisedModes = [...init.agentModes];
1284
+ }
1062
1285
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
1063
1286
  this.spawnReplacementAgent = init.spawnReplacementAgent;
1064
1287
  this.logger = init.logger;
@@ -1087,6 +1310,15 @@ var init_session = __esm({
1087
1310
  }
1088
1311
  });
1089
1312
  }
1313
+ broadcastAvailableModes() {
1314
+ this.recordAndBroadcast("session/update", {
1315
+ sessionId: this.upstreamSessionId,
1316
+ update: {
1317
+ sessionUpdate: "available_modes_update",
1318
+ availableModes: this.agentAdvertisedModes
1319
+ }
1320
+ });
1321
+ }
1090
1322
  // Register session/update, session/request_permission, and onExit
1091
1323
  // handlers on an agent connection. Re-run on every /hydra agent so
1092
1324
  // the new agent is plumbed identically. The exit handler's identity
@@ -1104,6 +1336,11 @@ var init_session = __esm({
1104
1336
  this.setAgentAdvertisedCommands(agentCmds);
1105
1337
  return;
1106
1338
  }
1339
+ const agentModes = extractAdvertisedModes(params);
1340
+ if (agentModes !== null) {
1341
+ this.setAgentAdvertisedModes(agentModes);
1342
+ return;
1343
+ }
1107
1344
  if (this.maybeApplyAgentModel(params)) {
1108
1345
  this.recordAndBroadcast("session/update", params);
1109
1346
  return;
@@ -1280,7 +1517,7 @@ var init_session = __esm({
1280
1517
  sessionId,
1281
1518
  update: {
1282
1519
  sessionUpdate: "current_mode_update",
1283
- currentMode: this.currentMode
1520
+ currentModeId: this.currentMode
1284
1521
  }
1285
1522
  },
1286
1523
  recordedAt
@@ -1300,6 +1537,19 @@ var init_session = __esm({
1300
1537
  recordedAt
1301
1538
  });
1302
1539
  }
1540
+ if (this.agentAdvertisedModes.length > 0) {
1541
+ out.push({
1542
+ method: "session/update",
1543
+ params: {
1544
+ sessionId,
1545
+ update: {
1546
+ sessionUpdate: "available_modes_update",
1547
+ availableModes: [...this.agentAdvertisedModes]
1548
+ }
1549
+ },
1550
+ recordedAt
1551
+ });
1552
+ }
1303
1553
  if (this.currentUsage !== void 0) {
1304
1554
  const u = this.currentUsage;
1305
1555
  const update = {
@@ -1390,34 +1640,28 @@ var init_session = __esm({
1390
1640
  if (promptText.startsWith("/hydra")) {
1391
1641
  return this.handleSlashCommand(promptText);
1392
1642
  }
1393
- this.broadcastPromptReceived(client, params);
1643
+ const messageId = generateMessageId();
1394
1644
  this.maybeSeedTitleFromPrompt(params);
1395
- return this.enqueuePrompt(async () => {
1396
- let response;
1397
- try {
1398
- response = await this.agent.connection.request(
1399
- "session/prompt",
1400
- {
1401
- ...params,
1402
- sessionId: this.upstreamSessionId
1403
- }
1404
- );
1405
- } catch (err) {
1406
- this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
1407
- throw err;
1408
- }
1409
- this.broadcastTurnComplete(client.clientId, response);
1410
- return response;
1411
- });
1412
- }
1413
- broadcastPromptReceived(client, params) {
1414
- const promptParams = params ?? {};
1415
- const sentBy = { clientId: client.clientId };
1416
- if (client.clientInfo?.name) {
1417
- sentBy.name = client.clientInfo.name;
1418
- }
1419
- if (client.clientInfo?.version) {
1420
- sentBy.version = client.clientInfo.version;
1645
+ return this.enqueueUserPrompt(client, params, messageId);
1646
+ }
1647
+ // DEVIATION FROM RFD #533: this broadcast is deliberately deferred
1648
+ // until the prompt actually becomes the active turn (i.e. drainQueue
1649
+ // is about to forward it to the agent), NOT when hydra first accepts
1650
+ // the request. The literal RFD doesn't pin the timing — it just says
1651
+ // peers should learn about the turn — but it was authored before
1652
+ // prompt queueing existed, so accept-time and start-time were the
1653
+ // same moment. With hydra's per-session FIFO, deferring gives
1654
+ // prompt_received a single, useful meaning ("the agent is now taking
1655
+ // a turn on this prompt"), which is how attached clients (notably
1656
+ // agent-shell) consume it. The accept-time signal that peers can use
1657
+ // for queue chip rendering is hydra-acp/prompt_queue_added instead.
1658
+ broadcastPromptReceived(entry) {
1659
+ const sentBy = { clientId: entry.originator.clientId };
1660
+ if (entry.originator.name) {
1661
+ sentBy.name = entry.originator.name;
1662
+ }
1663
+ if (entry.originator.version) {
1664
+ sentBy.version = entry.originator.version;
1421
1665
  }
1422
1666
  this.promptStartedAt = Date.now();
1423
1667
  this.recordAndBroadcast(
@@ -1426,14 +1670,14 @@ var init_session = __esm({
1426
1670
  sessionId: this.sessionId,
1427
1671
  update: {
1428
1672
  sessionUpdate: "prompt_received",
1429
- messageId: generateMessageId(),
1430
- prompt: promptParams.prompt,
1673
+ messageId: entry.messageId,
1674
+ prompt: entry.prompt,
1431
1675
  sentBy
1432
1676
  }
1433
1677
  },
1434
- client.clientId
1678
+ entry.clientId
1435
1679
  );
1436
- const text = extractPromptText(promptParams.prompt);
1680
+ const text = extractPromptText(entry.prompt);
1437
1681
  if (text.length > 0) {
1438
1682
  this.recordAndBroadcast(
1439
1683
  "session/update",
@@ -1445,7 +1689,7 @@ var init_session = __esm({
1445
1689
  _meta: { "hydra-acp": { compatFor: "prompt_received" } }
1446
1690
  }
1447
1691
  },
1448
- client.clientId
1692
+ entry.clientId
1449
1693
  );
1450
1694
  }
1451
1695
  }
@@ -1468,6 +1712,172 @@ var init_session = __esm({
1468
1712
  originatorClientId
1469
1713
  );
1470
1714
  }
1715
+ // Total visible-or-running entries: the in-flight head (if any) plus
1716
+ // the queue's user-visible waiting entries. Internal entries don't
1717
+ // count — they're an implementation detail and the wire never
1718
+ // surfaces them.
1719
+ visibleQueueDepth() {
1720
+ let count = this.currentEntry?.kind === "user" && !this.currentEntry.cancelled ? 1 : 0;
1721
+ for (const entry of this.promptQueue) {
1722
+ if (entry.kind === "user" && !entry.cancelled) count += 1;
1723
+ }
1724
+ return count;
1725
+ }
1726
+ broadcastQueueAdded(entry) {
1727
+ const depth = this.visibleQueueDepth();
1728
+ const position = Math.max(0, depth - 1);
1729
+ const params = {
1730
+ sessionId: this.sessionId,
1731
+ messageId: entry.messageId,
1732
+ originator: entry.originator,
1733
+ prompt: entry.prompt,
1734
+ position,
1735
+ queueDepth: depth,
1736
+ enqueuedAt: entry.enqueuedAt
1737
+ };
1738
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
1739
+ }
1740
+ broadcastQueueUpdated(messageId, prompt) {
1741
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_updated", {
1742
+ sessionId: this.sessionId,
1743
+ messageId,
1744
+ prompt
1745
+ });
1746
+ }
1747
+ broadcastQueueRemoved(messageId, reason) {
1748
+ this.broadcastQueueNotification("hydra-acp/prompt_queue_removed", {
1749
+ sessionId: this.sessionId,
1750
+ messageId,
1751
+ reason
1752
+ });
1753
+ }
1754
+ // Fan-out for queue lifecycle notifications. Ephemeral by design —
1755
+ // these signals describe transient daemon state, not conversation
1756
+ // content, so we deliberately bypass recordAndBroadcast (no history,
1757
+ // no idle-timer arm, no rewrite-for-client since we already emit the
1758
+ // hydra sessionId).
1759
+ broadcastQueueNotification(method, params) {
1760
+ for (const client of this.clients.values()) {
1761
+ void client.connection.notify(method, params).catch(() => void 0);
1762
+ }
1763
+ }
1764
+ // Snapshot of user-visible queue state at this moment. Surfaced to
1765
+ // late-attaching clients via the session/attach response _meta so
1766
+ // they boot with the same chip list as their peers without waiting
1767
+ // for new prompt_queue_added notifications. Internal entries are
1768
+ // omitted (they're not surfaced on the wire at all).
1769
+ queueSnapshot() {
1770
+ const out = [];
1771
+ let position = 0;
1772
+ if (this.currentEntry?.kind === "user" && !this.currentEntry.cancelled) {
1773
+ out.push({
1774
+ messageId: this.currentEntry.messageId,
1775
+ originator: this.currentEntry.originator,
1776
+ prompt: this.currentEntry.prompt,
1777
+ position: position++,
1778
+ enqueuedAt: this.currentEntry.enqueuedAt
1779
+ });
1780
+ }
1781
+ for (const entry of this.promptQueue) {
1782
+ if (entry.kind !== "user" || entry.cancelled) continue;
1783
+ out.push({
1784
+ messageId: entry.messageId,
1785
+ originator: entry.originator,
1786
+ prompt: entry.prompt,
1787
+ position: position++,
1788
+ enqueuedAt: entry.enqueuedAt
1789
+ });
1790
+ }
1791
+ return out;
1792
+ }
1793
+ // Wait for any pending queue-file writes to settle. Test hook so
1794
+ // assertions about on-disk state don't race with fire-and-forget
1795
+ // rewrites. Production code doesn't need this — the chain
1796
+ // self-serializes.
1797
+ async flushPersistWrites() {
1798
+ await this.queueWriteChain.catch(() => void 0);
1799
+ }
1800
+ // Push pre-existing queue entries back through the daemon-side
1801
+ // pipeline on startup. Called by SessionManager after resurrecting
1802
+ // a session that had a non-empty queue.ndjson on disk. Each entry
1803
+ // gets a synthetic UserPromptQueueEntry with no real caller
1804
+ // (resolve/reject are no-ops since the original WS is long gone),
1805
+ // then drainQueue picks it up like any other entry. Late-attaching
1806
+ // clients see the entries via prompt_queue_added broadcasts and the
1807
+ // attach-response snapshot.
1808
+ replayPersistedQueue(entries) {
1809
+ for (const persisted of entries) {
1810
+ const originator = {
1811
+ clientId: `hydra-resurrected_${persisted.messageId}`
1812
+ };
1813
+ if (persisted.originator.clientInfo.name !== void 0) {
1814
+ originator.name = persisted.originator.clientInfo.name;
1815
+ }
1816
+ if (persisted.originator.clientInfo.version !== void 0) {
1817
+ originator.version = persisted.originator.clientInfo.version;
1818
+ }
1819
+ const entry = {
1820
+ kind: "user",
1821
+ messageId: persisted.messageId,
1822
+ originator,
1823
+ // Synthetic clientId. broadcastTurnComplete uses this as
1824
+ // excludeClientId for the peer-only broadcast; with a synthetic
1825
+ // id no real attached client matches the exclude, so everyone
1826
+ // sees turn_complete — which is what we want, since none of
1827
+ // them originated this restart-replayed prompt.
1828
+ clientId: originator.clientId,
1829
+ prompt: persisted.prompt,
1830
+ enqueuedAt: persisted.enqueuedAt,
1831
+ cancelled: false,
1832
+ resolve: () => void 0,
1833
+ reject: () => void 0
1834
+ };
1835
+ this.promptQueue.push(entry);
1836
+ this.broadcastQueueAdded(entry);
1837
+ }
1838
+ void this.drainQueue();
1839
+ }
1840
+ // Drop a queued prompt by messageId. Returns already_running when
1841
+ // the messageId names the in-flight entry — callers should fall back
1842
+ // to session/cancel for that case. Originator-agnostic: any attached
1843
+ // client may cancel any queued prompt (matches the existing slack
1844
+ // :stop_sign: reaction UX and the TUI's queue-edit dispatcher).
1845
+ cancelQueuedPrompt(messageId) {
1846
+ if (this.currentEntry?.messageId === messageId) {
1847
+ return { cancelled: false, reason: "already_running" };
1848
+ }
1849
+ const idx = this.promptQueue.findIndex((e) => e.messageId === messageId);
1850
+ if (idx < 0) {
1851
+ return { cancelled: false, reason: "not_found" };
1852
+ }
1853
+ const entry = this.promptQueue[idx];
1854
+ entry.cancelled = true;
1855
+ this.promptQueue.splice(idx, 1);
1856
+ if (entry.kind === "user") {
1857
+ this.broadcastQueueRemoved(messageId, "cancelled");
1858
+ this.persistRewrite();
1859
+ }
1860
+ entry.resolve({ stopReason: "cancelled" });
1861
+ return { cancelled: true, reason: "ok" };
1862
+ }
1863
+ // Replace the prompt payload of a queued (not-yet-running) entry.
1864
+ // Returns already_running for the in-flight head; not_found for
1865
+ // unknown messageIds or for internal queue entries (internal tasks
1866
+ // don't expose a mutable prompt). Broadcasts prompt_queue_updated on
1867
+ // success so every attached client refreshes its chip.
1868
+ updateQueuedPrompt(messageId, prompt) {
1869
+ if (this.currentEntry?.messageId === messageId) {
1870
+ return { updated: false, reason: "already_running" };
1871
+ }
1872
+ const entry = this.promptQueue.find((e) => e.messageId === messageId);
1873
+ if (!entry || entry.kind !== "user") {
1874
+ return { updated: false, reason: "not_found" };
1875
+ }
1876
+ entry.prompt = prompt;
1877
+ this.broadcastQueueUpdated(messageId, prompt);
1878
+ this.persistRewrite();
1879
+ return { updated: true, reason: "ok" };
1880
+ }
1471
1881
  async cancel(clientId) {
1472
1882
  const client = this.clients.get(clientId);
1473
1883
  if (!client) {
@@ -1594,7 +2004,7 @@ var init_session = __esm({
1594
2004
  if (update.sessionUpdate !== "current_mode_update") {
1595
2005
  return false;
1596
2006
  }
1597
- const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
2007
+ const raw = typeof update.currentModeId === "string" ? update.currentModeId : typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
1598
2008
  if (raw === void 0) {
1599
2009
  return true;
1600
2010
  }
@@ -1672,12 +2082,29 @@ var init_session = __esm({
1672
2082
  }
1673
2083
  this.broadcastMergedCommands();
1674
2084
  }
2085
+ setAgentAdvertisedModes(modes) {
2086
+ if (sameAdvertisedModes(this.agentAdvertisedModes, modes)) {
2087
+ this.broadcastAvailableModes();
2088
+ return;
2089
+ }
2090
+ this.agentAdvertisedModes = modes;
2091
+ for (const handler of this.agentModesHandlers) {
2092
+ try {
2093
+ handler(modes);
2094
+ } catch {
2095
+ }
2096
+ }
2097
+ this.broadcastAvailableModes();
2098
+ }
1675
2099
  // Subscribe to snapshot-state updates. SessionManager wires these to
1676
2100
  // persist the new value into meta.json so cold resurrect can restore
1677
2101
  // them via the attach response _meta.
1678
2102
  onAgentCommandsChange(handler) {
1679
2103
  this.agentCommandsHandlers.push(handler);
1680
2104
  }
2105
+ onAgentModesChange(handler) {
2106
+ this.agentModesHandlers.push(handler);
2107
+ }
1681
2108
  onModelChange(handler) {
1682
2109
  this.modelHandlers.push(handler);
1683
2110
  }
@@ -1699,6 +2126,10 @@ var init_session = __esm({
1699
2126
  agentOnlyAdvertisedCommands() {
1700
2127
  return [...this.agentAdvertisedCommands];
1701
2128
  }
2129
+ // The agent's advertised modes list, for callers that need a snapshot.
2130
+ availableModes() {
2131
+ return [...this.agentAdvertisedModes];
2132
+ }
1702
2133
  // Pick up an agent-emitted session_info_update and store its title
1703
2134
  // as our canonical record. The notification is also forwarded to
1704
2135
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -2011,6 +2442,20 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2011
2442
  }
2012
2443
  this.closed = true;
2013
2444
  this.cancelIdleTimer();
2445
+ const stranded = this.promptQueue;
2446
+ this.promptQueue = [];
2447
+ for (const entry of stranded) {
2448
+ entry.cancelled = true;
2449
+ if (entry.kind === "user") {
2450
+ this.broadcastQueueRemoved(entry.messageId, "abandoned");
2451
+ }
2452
+ try {
2453
+ entry.resolve({ stopReason: "cancelled" });
2454
+ } catch {
2455
+ }
2456
+ }
2457
+ const sessionId = this.sessionId;
2458
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => deleteQueue(sessionId).catch(() => void 0));
2014
2459
  for (const client of this.clients.values()) {
2015
2460
  void client.connection.notify("hydra-acp/session_closed", { sessionId: this.sessionId }).catch(() => void 0);
2016
2461
  }
@@ -2056,7 +2501,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2056
2501
  if (this.closed || this.idleTimeoutMs <= 0) {
2057
2502
  return;
2058
2503
  }
2059
- if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0) {
2504
+ if (this.turnStartedAt !== void 0 || this.inFlightPermissions.size > 0 || this.promptQueue.length > 0) {
2060
2505
  this.armIdleTimer(this.idleTimeoutMs);
2061
2506
  return;
2062
2507
  }
@@ -2185,20 +2630,88 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2185
2630
  }
2186
2631
  });
2187
2632
  }
2633
+ // Schedule an internal task (title regen, agent swap transcript
2634
+ // injection, import seed). Serializes behind any user prompts already
2635
+ // in flight, but doesn't emit prompt_queue_* broadcasts — clients
2636
+ // shouldn't see hydra's housekeeping in their chip list.
2188
2637
  async enqueuePrompt(task) {
2189
2638
  return new Promise((resolve5, reject) => {
2190
- const run3 = async () => {
2191
- try {
2192
- const result = await task();
2193
- resolve5(result);
2194
- } catch (err) {
2195
- reject(err);
2196
- }
2639
+ const entry = {
2640
+ kind: "internal",
2641
+ messageId: generateMessageId(),
2642
+ enqueuedAt: Date.now(),
2643
+ cancelled: false,
2644
+ task,
2645
+ resolve: resolve5,
2646
+ reject
2647
+ };
2648
+ this.promptQueue.push(entry);
2649
+ void this.drainQueue();
2650
+ });
2651
+ }
2652
+ // Schedule a user-originated session/prompt. Emits prompt_queue_added
2653
+ // immediately on enqueue (so peer clients can render the queued chip)
2654
+ // and prompt_queue_removed when the entry leaves the queue. The
2655
+ // returned promise resolves with the upstream agent's session/prompt
2656
+ // result, or { stopReason: "cancelled" } if the entry is dropped via
2657
+ // cancelQueuedPrompt before reaching the head.
2658
+ async enqueueUserPrompt(client, params, messageId) {
2659
+ const promptArray = (params ?? {}).prompt ?? [];
2660
+ const originator = { clientId: client.clientId };
2661
+ if (client.clientInfo?.name) originator.name = client.clientInfo.name;
2662
+ if (client.clientInfo?.version)
2663
+ originator.version = client.clientInfo.version;
2664
+ return new Promise((resolve5, reject) => {
2665
+ const entry = {
2666
+ kind: "user",
2667
+ messageId,
2668
+ originator,
2669
+ clientId: client.clientId,
2670
+ prompt: promptArray,
2671
+ enqueuedAt: Date.now(),
2672
+ cancelled: false,
2673
+ resolve: resolve5,
2674
+ reject
2197
2675
  };
2198
- this.promptQueue.push(run3);
2676
+ this.promptQueue.push(entry);
2677
+ this.persistRewrite();
2678
+ this.broadcastQueueAdded(entry);
2199
2679
  void this.drainQueue();
2200
2680
  });
2201
2681
  }
2682
+ // Rewrite the on-disk queue to reflect the current set of WAITING
2683
+ // entries (excluding currentEntry, the in-flight head). Excluding
2684
+ // the head is the key idempotency choice: once drainQueue shifts an
2685
+ // entry off and calls persistRewrite, a daemon crash mid-generation
2686
+ // will NOT re-run it on restart. Partial output (if any streamed
2687
+ // before the crash) stays in history; the prompt itself is lost
2688
+ // and the user can re-submit if they care.
2689
+ //
2690
+ // Snapshots in-memory state synchronously (so subsequent mutations
2691
+ // can't perturb what we're about to write) and chains the write
2692
+ // onto queueWriteChain so all persists are serialized.
2693
+ persistRewrite() {
2694
+ const entries = [];
2695
+ for (const entry of this.promptQueue) {
2696
+ if (entry.kind !== "user" || entry.cancelled) continue;
2697
+ entries.push(this.persistedFromEntry(entry));
2698
+ }
2699
+ const sessionId = this.sessionId;
2700
+ this.queueWriteChain = this.queueWriteChain.catch(() => void 0).then(() => rewriteQueue(sessionId, entries).catch(() => void 0));
2701
+ }
2702
+ persistedFromEntry(entry) {
2703
+ return {
2704
+ messageId: entry.messageId,
2705
+ originator: {
2706
+ clientInfo: {
2707
+ ...entry.originator.name !== void 0 ? { name: entry.originator.name } : {},
2708
+ ...entry.originator.version !== void 0 ? { version: entry.originator.version } : {}
2709
+ }
2710
+ },
2711
+ prompt: entry.prompt,
2712
+ enqueuedAt: entry.enqueuedAt
2713
+ };
2714
+ }
2202
2715
  async drainQueue() {
2203
2716
  if (this.promptInFlight) {
2204
2717
  return;
@@ -2207,27 +2720,78 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2207
2720
  try {
2208
2721
  while (this.promptQueue.length > 0) {
2209
2722
  const next = this.promptQueue.shift();
2210
- if (next) {
2211
- await next();
2723
+ if (!next) {
2724
+ break;
2725
+ }
2726
+ if (next.cancelled) {
2727
+ continue;
2728
+ }
2729
+ this.currentEntry = next;
2730
+ if (next.kind === "user") {
2731
+ this.persistRewrite();
2732
+ }
2733
+ if (next.kind === "user") {
2734
+ this.broadcastQueueRemoved(next.messageId, "started");
2735
+ }
2736
+ try {
2737
+ const result = await this.runQueueEntry(next);
2738
+ next.resolve(result);
2739
+ } catch (err) {
2740
+ next.reject(err);
2741
+ } finally {
2742
+ this.currentEntry = void 0;
2212
2743
  }
2213
2744
  }
2214
2745
  } finally {
2215
2746
  this.promptInFlight = false;
2216
2747
  }
2217
2748
  }
2749
+ // Execute a queue entry. User-prompt entries forward to the upstream
2750
+ // agent and pair with broadcastTurnComplete; internal entries run
2751
+ // their captured task closure. Reads entry.prompt at dispatch time
2752
+ // so updateQueuedPrompt's mutations are honoured.
2753
+ //
2754
+ // For user entries, broadcastPromptReceived fires HERE — not in
2755
+ // Session.prompt — so peer clients see prompt_received only when the
2756
+ // turn actually starts (a deliberate deviation from a naive reading
2757
+ // of RFD #533; see the comment on broadcastPromptReceived). Order on
2758
+ // the wire: prompt_queue_removed{started} (already emitted by
2759
+ // drainQueue) → prompt_received → upstream session/prompt.
2760
+ async runQueueEntry(entry) {
2761
+ if (entry.kind === "internal") {
2762
+ return entry.task();
2763
+ }
2764
+ this.broadcastPromptReceived(entry);
2765
+ let response;
2766
+ try {
2767
+ response = await this.agent.connection.request(
2768
+ "session/prompt",
2769
+ {
2770
+ sessionId: this.upstreamSessionId,
2771
+ prompt: entry.prompt
2772
+ }
2773
+ );
2774
+ } catch (err) {
2775
+ this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
2776
+ throw err;
2777
+ }
2778
+ this.broadcastTurnComplete(entry.clientId, response);
2779
+ return response;
2780
+ }
2218
2781
  };
2219
2782
  STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
2220
2783
  "session_info_update",
2221
2784
  "current_model_update",
2222
2785
  "current_mode_update",
2223
2786
  "available_commands_update",
2787
+ "available_modes_update",
2224
2788
  "usage_update"
2225
2789
  ]);
2226
2790
  }
2227
2791
  });
2228
2792
 
2229
2793
  // src/core/session-store.ts
2230
- import * as fs5 from "fs/promises";
2794
+ import * as fs7 from "fs/promises";
2231
2795
  import * as path4 from "path";
2232
2796
  import { customAlphabet as customAlphabet2 } from "nanoid";
2233
2797
  import { z as z4 } from "zod";
@@ -2256,11 +2820,12 @@ function recordFromMemorySession(args) {
2256
2820
  currentMode: args.currentMode,
2257
2821
  currentUsage: args.currentUsage,
2258
2822
  agentCommands: args.agentCommands,
2823
+ agentModes: args.agentModes,
2259
2824
  createdAt: args.createdAt ?? now,
2260
2825
  updatedAt: args.updatedAt ?? now
2261
2826
  };
2262
2827
  }
2263
- var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
2828
+ var HYDRA_ID_ALPHABET2, generateRawId, HYDRA_LINEAGE_PREFIX, PersistedAgentCommand, PersistedAgentMode, PersistedUsage, SessionRecord, SESSION_ID_PATTERN, SessionStore;
2264
2829
  var init_session_store = __esm({
2265
2830
  "src/core/session-store.ts"() {
2266
2831
  "use strict";
@@ -2272,6 +2837,11 @@ var init_session_store = __esm({
2272
2837
  name: z4.string(),
2273
2838
  description: z4.string().optional()
2274
2839
  });
2840
+ PersistedAgentMode = z4.object({
2841
+ id: z4.string(),
2842
+ name: z4.string().optional(),
2843
+ description: z4.string().optional()
2844
+ });
2275
2845
  PersistedUsage = z4.object({
2276
2846
  used: z4.number().optional(),
2277
2847
  size: z4.number().optional(),
@@ -2317,6 +2887,7 @@ var init_session_store = __esm({
2317
2887
  currentMode: z4.string().optional(),
2318
2888
  currentUsage: PersistedUsage.optional(),
2319
2889
  agentCommands: z4.array(PersistedAgentCommand).optional(),
2890
+ agentModes: z4.array(PersistedAgentMode).optional(),
2320
2891
  createdAt: z4.string(),
2321
2892
  updatedAt: z4.string()
2322
2893
  });
@@ -2324,9 +2895,9 @@ var init_session_store = __esm({
2324
2895
  SessionStore = class {
2325
2896
  async write(record) {
2326
2897
  assertSafeId(record.sessionId);
2327
- await fs5.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2898
+ await fs7.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
2328
2899
  const full = { version: 1, ...record };
2329
- await fs5.writeFile(
2900
+ await fs7.writeFile(
2330
2901
  paths.sessionFile(record.sessionId),
2331
2902
  JSON.stringify(full, null, 2) + "\n",
2332
2903
  { encoding: "utf8", mode: 384 }
@@ -2338,7 +2909,7 @@ var init_session_store = __esm({
2338
2909
  }
2339
2910
  let raw;
2340
2911
  try {
2341
- raw = await fs5.readFile(paths.sessionFile(sessionId), "utf8");
2912
+ raw = await fs7.readFile(paths.sessionFile(sessionId), "utf8");
2342
2913
  } catch (err) {
2343
2914
  const e = err;
2344
2915
  if (e.code === "ENOENT") {
@@ -2357,7 +2928,7 @@ var init_session_store = __esm({
2357
2928
  return;
2358
2929
  }
2359
2930
  try {
2360
- await fs5.unlink(paths.sessionFile(sessionId));
2931
+ await fs7.unlink(paths.sessionFile(sessionId));
2361
2932
  } catch (err) {
2362
2933
  const e = err;
2363
2934
  if (e.code !== "ENOENT") {
@@ -2365,7 +2936,7 @@ var init_session_store = __esm({
2365
2936
  }
2366
2937
  }
2367
2938
  try {
2368
- await fs5.rmdir(paths.sessionDir(sessionId));
2939
+ await fs7.rmdir(paths.sessionDir(sessionId));
2369
2940
  } catch (err) {
2370
2941
  const e = err;
2371
2942
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -2395,7 +2966,7 @@ var init_session_store = __esm({
2395
2966
  async list() {
2396
2967
  let entries;
2397
2968
  try {
2398
- entries = await fs5.readdir(paths.sessionsDir());
2969
+ entries = await fs7.readdir(paths.sessionsDir());
2399
2970
  } catch (err) {
2400
2971
  const e = err;
2401
2972
  if (e.code === "ENOENT") {
@@ -2417,12 +2988,12 @@ var init_session_store = __esm({
2417
2988
  });
2418
2989
 
2419
2990
  // src/tui/history.ts
2420
- import { promises as fs7 } from "fs";
2991
+ import { promises as fs9 } from "fs";
2421
2992
  import * as path5 from "path";
2422
2993
  async function loadHistory(file) {
2423
2994
  let text;
2424
2995
  try {
2425
- text = await fs7.readFile(file, "utf8");
2996
+ text = await fs9.readFile(file, "utf8");
2426
2997
  } catch (err) {
2427
2998
  if (err.code === "ENOENT") {
2428
2999
  return [];
@@ -2462,9 +3033,9 @@ function appendEntry(history, entry) {
2462
3033
  return out;
2463
3034
  }
2464
3035
  async function saveHistory(file, history) {
2465
- await fs7.mkdir(path5.dirname(file), { recursive: true });
3036
+ await fs9.mkdir(path5.dirname(file), { recursive: true });
2466
3037
  const lines = history.map((entry) => JSON.stringify(entry));
2467
- await fs7.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3038
+ await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2468
3039
  }
2469
3040
  var HISTORY_CAP;
2470
3041
  var init_history = __esm({
@@ -2477,14 +3048,14 @@ var init_history = __esm({
2477
3048
  // src/core/hydra-version.ts
2478
3049
  import { fileURLToPath } from "url";
2479
3050
  import * as path6 from "path";
2480
- import * as fs8 from "fs";
3051
+ import * as fs10 from "fs";
2481
3052
  function resolveVersion() {
2482
3053
  try {
2483
3054
  let dir = path6.dirname(fileURLToPath(import.meta.url));
2484
3055
  for (let i = 0; i < 8; i += 1) {
2485
3056
  const candidate = path6.join(dir, "package.json");
2486
- if (fs8.existsSync(candidate)) {
2487
- const pkg = JSON.parse(fs8.readFileSync(candidate, "utf8"));
3057
+ if (fs10.existsSync(candidate)) {
3058
+ const pkg = JSON.parse(fs10.readFileSync(candidate, "utf8"));
2488
3059
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
2489
3060
  return pkg.version;
2490
3061
  }
@@ -2528,6 +3099,7 @@ function encodeBundle(params) {
2528
3099
  ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
2529
3100
  ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
2530
3101
  ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
3102
+ ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
2531
3103
  createdAt: params.record.createdAt,
2532
3104
  updatedAt: params.record.updatedAt
2533
3105
  },
@@ -2571,6 +3143,7 @@ var init_bundle = __esm({
2571
3143
  currentMode: z5.string().optional(),
2572
3144
  currentUsage: PersistedUsage.optional(),
2573
3145
  agentCommands: z5.array(PersistedAgentCommand).optional(),
3146
+ agentModes: z5.array(PersistedAgentMode).optional(),
2574
3147
  createdAt: z5.string(),
2575
3148
  updatedAt: z5.string()
2576
3149
  });
@@ -2631,6 +3204,8 @@ function mapUpdate(update) {
2631
3204
  return mapUsage(u);
2632
3205
  case "available_commands_update":
2633
3206
  return mapAvailableCommands(u);
3207
+ case "available_modes_update":
3208
+ return mapAvailableModes(u);
2634
3209
  case "session_info_update":
2635
3210
  return mapSessionInfo(u);
2636
3211
  default:
@@ -2692,6 +3267,31 @@ function mapAvailableCommands(u) {
2692
3267
  }
2693
3268
  return { kind: "available-commands", commands: normalizeAdvertisedCommands(list) };
2694
3269
  }
3270
+ function mapAvailableModes(u) {
3271
+ const list = u.availableModes;
3272
+ if (!Array.isArray(list)) {
3273
+ return null;
3274
+ }
3275
+ const modes = [];
3276
+ for (const raw of list) {
3277
+ if (!raw || typeof raw !== "object") {
3278
+ continue;
3279
+ }
3280
+ const m = raw;
3281
+ if (typeof m.id !== "string" || m.id.length === 0) {
3282
+ continue;
3283
+ }
3284
+ const mode = { id: sanitizeSingleLine(m.id) };
3285
+ if (typeof m.name === "string") {
3286
+ mode.name = sanitizeSingleLine(m.name);
3287
+ }
3288
+ if (typeof m.description === "string") {
3289
+ mode.description = sanitizeSingleLine(m.description);
3290
+ }
3291
+ modes.push(mode);
3292
+ }
3293
+ return { kind: "available-modes", modes };
3294
+ }
2695
3295
  function mapUsage(u) {
2696
3296
  const event = { kind: "usage-update" };
2697
3297
  if (typeof u.used === "number") {
@@ -2812,7 +3412,7 @@ function mapPlan(u) {
2812
3412
  return { kind: "plan", entries: normalized };
2813
3413
  }
2814
3414
  function mapMode(u) {
2815
- const mode = readString(u, "currentMode") ?? readString(u, "mode");
3415
+ const mode = readString(u, "currentModeId") ?? readString(u, "currentMode") ?? readString(u, "mode");
2816
3416
  if (!mode) {
2817
3417
  return null;
2818
3418
  }
@@ -3466,14 +4066,15 @@ var init_session_row = __esm({
3466
4066
  });
3467
4067
 
3468
4068
  // src/cli/commands/sessions.ts
3469
- import * as fs13 from "fs/promises";
3470
- import * as path8 from "path";
4069
+ import * as fs17 from "fs/promises";
4070
+ import * as path11 from "path";
3471
4071
  async function runSessionsList(opts = {}) {
3472
4072
  const config = await loadConfig();
4073
+ const serviceToken = await loadServiceToken();
3473
4074
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3474
4075
  const url = new URL(`${baseUrl}/v1/sessions`);
3475
4076
  const response = await fetch(url.toString(), {
3476
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4077
+ headers: { Authorization: `Bearer ${serviceToken}` }
3477
4078
  });
3478
4079
  if (!response.ok) {
3479
4080
  process.stderr.write(`Daemon returned HTTP ${response.status}
@@ -3529,10 +4130,11 @@ async function runSessionsKill(id) {
3529
4130
  process.exit(2);
3530
4131
  }
3531
4132
  const config = await loadConfig();
4133
+ const serviceToken = await loadServiceToken();
3532
4134
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3533
4135
  const response = await fetch(`${baseUrl}/v1/sessions/${id}/kill`, {
3534
4136
  method: "POST",
3535
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4137
+ headers: { Authorization: `Bearer ${serviceToken}` }
3536
4138
  });
3537
4139
  if (!response.ok && response.status !== 204) {
3538
4140
  process.stderr.write(`Daemon returned HTTP ${response.status}
@@ -3548,10 +4150,11 @@ async function runSessionsRemove(id) {
3548
4150
  process.exit(2);
3549
4151
  }
3550
4152
  const config = await loadConfig();
4153
+ const serviceToken = await loadServiceToken();
3551
4154
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3552
4155
  const response = await fetch(`${baseUrl}/v1/sessions/${id}`, {
3553
4156
  method: "DELETE",
3554
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4157
+ headers: { Authorization: `Bearer ${serviceToken}` }
3555
4158
  });
3556
4159
  if (!response.ok && response.status !== 204) {
3557
4160
  process.stderr.write(`Daemon returned HTTP ${response.status}
@@ -3569,11 +4172,12 @@ async function runSessionsExport(id, outPath) {
3569
4172
  process.exit(2);
3570
4173
  }
3571
4174
  const config = await loadConfig();
4175
+ const serviceToken = await loadServiceToken();
3572
4176
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3573
4177
  const response = await fetch(
3574
4178
  `${baseUrl}/v1/sessions/${encodeURIComponent(id)}/export`,
3575
4179
  {
3576
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4180
+ headers: { Authorization: `Bearer ${serviceToken}` }
3577
4181
  }
3578
4182
  );
3579
4183
  if (!response.ok) {
@@ -3591,8 +4195,8 @@ async function runSessionsExport(id, outPath) {
3591
4195
  return;
3592
4196
  }
3593
4197
  const resolved = outPath === "." ? deriveFilenameFrom(response, id) : outPath;
3594
- await fs13.mkdir(path8.dirname(path8.resolve(resolved)), { recursive: true });
3595
- await fs13.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
4198
+ await fs17.mkdir(path11.dirname(path11.resolve(resolved)), { recursive: true });
4199
+ await fs17.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
3596
4200
  process.stdout.write(`Wrote ${resolved}
3597
4201
  `);
3598
4202
  }
@@ -3610,14 +4214,15 @@ async function runSessionsTranscript(idOrFile, outPath) {
3610
4214
  const bundle = decodeBundleOrExit(localFile.raw);
3611
4215
  body = bundleToMarkdown(bundle);
3612
4216
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3613
- defaultName = `${path8.basename(idOrFile, path8.extname(idOrFile))}-${stamp}.md`;
4217
+ defaultName = `${path11.basename(idOrFile, path11.extname(idOrFile))}-${stamp}.md`;
3614
4218
  } else {
3615
4219
  const config = await loadConfig();
4220
+ const serviceToken = await loadServiceToken();
3616
4221
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3617
4222
  const response = await fetch(
3618
4223
  `${baseUrl}/v1/sessions/${encodeURIComponent(idOrFile)}/transcript`,
3619
4224
  {
3620
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4225
+ headers: { Authorization: `Bearer ${serviceToken}` }
3621
4226
  }
3622
4227
  );
3623
4228
  if (!response.ok) {
@@ -3638,21 +4243,21 @@ async function runSessionsTranscript(idOrFile, outPath) {
3638
4243
  return;
3639
4244
  }
3640
4245
  const resolved = outPath === "." ? defaultName : outPath;
3641
- await fs13.mkdir(path8.dirname(path8.resolve(resolved)), { recursive: true });
3642
- await fs13.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
4246
+ await fs17.mkdir(path11.dirname(path11.resolve(resolved)), { recursive: true });
4247
+ await fs17.writeFile(resolved, body, { encoding: "utf8", mode: 384 });
3643
4248
  process.stdout.write(`Wrote ${resolved}
3644
4249
  `);
3645
4250
  }
3646
4251
  async function readBundleFileIfExists(arg) {
3647
4252
  try {
3648
- const stat4 = await fs13.stat(arg);
4253
+ const stat4 = await fs17.stat(arg);
3649
4254
  if (!stat4.isFile()) {
3650
4255
  return null;
3651
4256
  }
3652
4257
  } catch {
3653
4258
  return null;
3654
4259
  }
3655
- const text = await fs13.readFile(arg, "utf8");
4260
+ const text = await fs17.readFile(arg, "utf8");
3656
4261
  try {
3657
4262
  return { raw: JSON.parse(text) };
3658
4263
  } catch (err) {
@@ -3679,9 +4284,9 @@ async function runSessionsImport(file, opts = {}) {
3679
4284
  }
3680
4285
  let cwdOverride;
3681
4286
  if (opts.cwd !== void 0) {
3682
- const resolved = path8.resolve(opts.cwd);
4287
+ const resolved = path11.resolve(opts.cwd);
3683
4288
  try {
3684
- const stat4 = await fs13.stat(resolved);
4289
+ const stat4 = await fs17.stat(resolved);
3685
4290
  if (!stat4.isDirectory()) {
3686
4291
  process.stderr.write(`--cwd ${resolved} is not a directory
3687
4292
  `);
@@ -3698,7 +4303,7 @@ async function runSessionsImport(file, opts = {}) {
3698
4303
  if (file === "-") {
3699
4304
  body = await readStdin();
3700
4305
  } else {
3701
- body = await fs13.readFile(file, "utf8");
4306
+ body = await fs17.readFile(file, "utf8");
3702
4307
  }
3703
4308
  let bundle;
3704
4309
  try {
@@ -3709,17 +4314,18 @@ async function runSessionsImport(file, opts = {}) {
3709
4314
  process.exit(1);
3710
4315
  }
3711
4316
  if (opts.info === true) {
3712
- const inspectConfig = await loadConfigReadOnly();
4317
+ const inspectConfig = await loadConfig();
3713
4318
  printBundleInfo(bundle, inspectConfig.tui.cwdColumnMaxWidth);
3714
4319
  return;
3715
4320
  }
3716
4321
  const config = await loadConfig();
4322
+ const serviceToken = await loadServiceToken();
3717
4323
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
3718
4324
  const response = await fetch(`${baseUrl}/v1/sessions/import`, {
3719
4325
  method: "POST",
3720
4326
  headers: {
3721
4327
  "Content-Type": "application/json",
3722
- Authorization: `Bearer ${config.daemon.authToken}`
4328
+ Authorization: `Bearer ${serviceToken}`
3723
4329
  },
3724
4330
  body: JSON.stringify({
3725
4331
  bundle,
@@ -3813,6 +4419,7 @@ var init_sessions = __esm({
3813
4419
  "src/cli/commands/sessions.ts"() {
3814
4420
  "use strict";
3815
4421
  init_config();
4422
+ init_service_token();
3816
4423
  init_bundle();
3817
4424
  init_transcript();
3818
4425
  init_session_row();
@@ -4110,7 +4717,7 @@ var init_update_check = __esm({
4110
4717
  });
4111
4718
 
4112
4719
  // src/tui/discovery.ts
4113
- async function listSessions(config, opts = {}, fetchImpl = fetch) {
4720
+ async function listSessions(config, serviceToken, opts = {}, fetchImpl = fetch) {
4114
4721
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
4115
4722
  const url = new URL(`${base}/v1/sessions`);
4116
4723
  if (opts.cwd) {
@@ -4120,7 +4727,7 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
4120
4727
  url.searchParams.set("all", "true");
4121
4728
  }
4122
4729
  const response = await fetchImpl(url.toString(), {
4123
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4730
+ headers: { Authorization: `Bearer ${serviceToken}` }
4124
4731
  });
4125
4732
  if (!response.ok) {
4126
4733
  throw new Error(`daemon returned HTTP ${response.status}`);
@@ -4144,21 +4751,21 @@ async function listSessions(config, opts = {}, fetchImpl = fetch) {
4144
4751
  importedFromUpstreamSessionId: s.importedFromUpstreamSessionId
4145
4752
  }));
4146
4753
  }
4147
- async function killSession(config, id, fetchImpl = fetch) {
4754
+ async function killSession(config, serviceToken, id, fetchImpl = fetch) {
4148
4755
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
4149
4756
  const response = await fetchImpl(`${base}/v1/sessions/${id}/kill`, {
4150
4757
  method: "POST",
4151
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4758
+ headers: { Authorization: `Bearer ${serviceToken}` }
4152
4759
  });
4153
4760
  if (!response.ok && response.status !== 204 && response.status !== 404) {
4154
4761
  throw new Error(`daemon returned HTTP ${response.status}`);
4155
4762
  }
4156
4763
  }
4157
- async function deleteSession(config, id, fetchImpl = fetch) {
4764
+ async function deleteSession(config, serviceToken, id, fetchImpl = fetch) {
4158
4765
  const base = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
4159
4766
  const response = await fetchImpl(`${base}/v1/sessions/${id}`, {
4160
4767
  method: "DELETE",
4161
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
4768
+ headers: { Authorization: `Bearer ${serviceToken}` }
4162
4769
  });
4163
4770
  if (!response.ok && response.status !== 204 && response.status !== 404) {
4164
4771
  throw new Error(`daemon returned HTTP ${response.status}`);
@@ -4348,6 +4955,10 @@ async function pickSession(term, opts) {
4348
4955
  const indicatorRow = () => startRow + 3 + viewportSize;
4349
4956
  const sessionRow = (sessionIdx) => startRow + 3 + (sessionIdx - scrollOffset);
4350
4957
  const renderFromScratch = () => {
4958
+ if (mode === "help") {
4959
+ renderHelp();
4960
+ return;
4961
+ }
4351
4962
  computeLayout();
4352
4963
  adjustScroll();
4353
4964
  startRow = 1;
@@ -4362,6 +4973,21 @@ async function pickSession(term, opts) {
4362
4973
  paintIndicator();
4363
4974
  term("\n");
4364
4975
  };
4976
+ const renderHelp = () => {
4977
+ term.moveTo(1, 1).eraseDisplayBelow();
4978
+ term.brightWhite.bold.noFormat(" Picker hotkeys")("\n\n");
4979
+ for (const entry of HELP_ENTRIES) {
4980
+ if (entry === null) {
4981
+ term("\n");
4982
+ continue;
4983
+ }
4984
+ const [keys, desc] = entry;
4985
+ term.brightCyan.noFormat(` ${keys.padEnd(HELP_KEYS_WIDTH)}`);
4986
+ term.noFormat(desc)("\n");
4987
+ }
4988
+ term("\n");
4989
+ term.dim.noFormat(" press any key to dismiss")("\n");
4990
+ };
4365
4991
  const repaintNewItem = () => {
4366
4992
  term.moveTo(1, startRow).eraseLineAfter();
4367
4993
  paintNewItem();
@@ -4408,7 +5034,7 @@ async function pickSession(term, opts) {
4408
5034
  };
4409
5035
  const refresh = async (preferredId) => {
4410
5036
  try {
4411
- const next = await listSessions(opts.config);
5037
+ const next = await listSessions(opts.config, opts.serviceToken);
4412
5038
  allSessions = sortSessions(next);
4413
5039
  applyFilter();
4414
5040
  if (preferredId !== void 0) {
@@ -4439,9 +5065,9 @@ async function pickSession(term, opts) {
4439
5065
  paintIndicator();
4440
5066
  try {
4441
5067
  if (kind === "kill") {
4442
- await killSession(opts.config, target.sessionId);
5068
+ await killSession(opts.config, opts.serviceToken, target.sessionId);
4443
5069
  } else {
4444
- await deleteSession(opts.config, target.sessionId);
5070
+ await deleteSession(opts.config, opts.serviceToken, target.sessionId);
4445
5071
  }
4446
5072
  mode = "normal";
4447
5073
  pendingAction = null;
@@ -4492,6 +5118,16 @@ async function pickSession(term, opts) {
4492
5118
  if (mode === "busy") {
4493
5119
  return;
4494
5120
  }
5121
+ if (mode === "help") {
5122
+ if (name === "CTRL_C") {
5123
+ cleanup();
5124
+ resolve5({ kind: "abort" });
5125
+ return;
5126
+ }
5127
+ mode = "normal";
5128
+ renderFromScratch();
5129
+ return;
5130
+ }
4495
5131
  if (mode === "confirm-kill" || mode === "confirm-delete") {
4496
5132
  if (data?.isCharacter && (name === "y" || name === "Y")) {
4497
5133
  const kind = mode === "confirm-kill" ? "kill" : "delete";
@@ -4507,6 +5143,11 @@ async function pickSession(term, opts) {
4507
5143
  return;
4508
5144
  }
4509
5145
  clearTransient();
5146
+ if (!searchActive && data?.isCharacter && name === "?") {
5147
+ mode = "help";
5148
+ renderHelp();
5149
+ return;
5150
+ }
4510
5151
  if (searchActive) {
4511
5152
  if (data?.isCharacter) {
4512
5153
  searchTerm += name;
@@ -4655,6 +5296,7 @@ async function pickSession(term, opts) {
4655
5296
  }
4656
5297
  case "ESCAPE":
4657
5298
  case "CTRL_C":
5299
+ case "CTRL_D":
4658
5300
  cleanup();
4659
5301
  resolve5({ kind: "abort" });
4660
5302
  return;
@@ -4696,7 +5338,7 @@ function matchesSearch(s, term) {
4696
5338
  }
4697
5339
  return false;
4698
5340
  }
4699
- var ROW_PREFIX_WIDTH;
5341
+ var ROW_PREFIX_WIDTH, HELP_KEYS_WIDTH, HELP_ENTRIES;
4700
5342
  var init_picker = __esm({
4701
5343
  "src/tui/picker.ts"() {
4702
5344
  "use strict";
@@ -4705,13 +5347,31 @@ var init_picker = __esm({
4705
5347
  init_session();
4706
5348
  init_discovery();
4707
5349
  ROW_PREFIX_WIDTH = 2;
5350
+ HELP_KEYS_WIDTH = 20;
5351
+ HELP_ENTRIES = [
5352
+ ["\u2191 / \u2193 or n / p", "navigate"],
5353
+ ["PgUp / PgDn", "page up / page down"],
5354
+ ["Home / End", "first / last"],
5355
+ ["Enter", "open selected session (or create new)"],
5356
+ null,
5357
+ ["/", "search sessions"],
5358
+ ["o", "toggle cwd-only filter"],
5359
+ ["r", "refresh from daemon"],
5360
+ null,
5361
+ ["k", "kill the selected live session"],
5362
+ ["d", "delete the selected cold session"],
5363
+ null,
5364
+ ["c", "create new session"],
5365
+ ["?", "toggle this help"],
5366
+ ["q / Esc / ^C / ^D", "quit picker (detach)"]
5367
+ ];
4708
5368
  }
4709
5369
  });
4710
5370
 
4711
5371
  // src/tui/attachments.ts
4712
- import path9 from "path";
5372
+ import path12 from "path";
4713
5373
  function mimeFromExtension(p) {
4714
- return EXTENSION_TO_MIME[path9.extname(p).toLowerCase()] ?? null;
5374
+ return EXTENSION_TO_MIME[path12.extname(p).toLowerCase()] ?? null;
4715
5375
  }
4716
5376
  function isSupportedImagePath(p) {
4717
5377
  return mimeFromExtension(p) !== null;
@@ -4820,6 +5480,10 @@ var init_attachments = __esm({
4820
5480
  // src/tui/screen.ts
4821
5481
  import stringWidth from "string-width";
4822
5482
  import wrapAnsi from "wrap-ansi";
5483
+ function matchBareUrl(text) {
5484
+ const stripped = text.replace(/\r\n?$|\n$/, "");
5485
+ return BARE_URL_RE.test(stripped) ? stripped : null;
5486
+ }
4823
5487
  function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null) {
4824
5488
  const active = activeCol === null ? "" : `a${activeCol}`;
4825
5489
  if (!line) {
@@ -5283,6 +5947,14 @@ function mapKeyName(name) {
5283
5947
  case "ALT_ENTER":
5284
5948
  case "META_ENTER":
5285
5949
  return "alt-enter";
5950
+ case "ALT_B":
5951
+ case "META_B":
5952
+ return "alt-b";
5953
+ case "ALT_F":
5954
+ case "META_F":
5955
+ return "alt-f";
5956
+ case "CTRL_T":
5957
+ return "ctrl-t";
5286
5958
  case "SHIFT_TAB":
5287
5959
  return "shift-tab";
5288
5960
  case "TAB":
@@ -5315,6 +5987,8 @@ function mapKeyName(name) {
5315
5987
  return "ctrl-e";
5316
5988
  case "CTRL_F":
5317
5989
  return "ctrl-f";
5990
+ case "CTRL_G":
5991
+ return "ctrl-g";
5318
5992
  case "CTRL_K":
5319
5993
  return "ctrl-k";
5320
5994
  case "CTRL_L":
@@ -5343,7 +6017,7 @@ function mapKeyName(name) {
5343
6017
  return null;
5344
6018
  }
5345
6019
  }
5346
- var SESSIONBAR_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, MAX_CHIP_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
6020
+ var SESSIONBAR_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_HELP_ROWS, MAX_COMPLETION_ROWS, MAX_CHIP_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, BARE_URL_RE, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
5347
6021
  var init_screen = __esm({
5348
6022
  "src/tui/screen.ts"() {
5349
6023
  "use strict";
@@ -5357,11 +6031,13 @@ var init_screen = __esm({
5357
6031
  MAX_PROMPT_ROWS = 8;
5358
6032
  MAX_QUEUED_ROWS = 5;
5359
6033
  MAX_PERMISSION_ROWS = 12;
6034
+ MAX_HELP_ROWS = 30;
5360
6035
  MAX_COMPLETION_ROWS = 6;
5361
6036
  MAX_CHIP_ROWS = 4;
5362
6037
  CONFIRM_PROMPT_ROWS = 2;
5363
6038
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
5364
6039
  DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
6040
+ BARE_URL_RE = /^(https?|ftp):\/\/\S+$/;
5365
6041
  Screen = class {
5366
6042
  term;
5367
6043
  dispatcher;
@@ -5418,6 +6094,7 @@ var init_screen = __esm({
5418
6094
  lastFrameH = 0;
5419
6095
  permissionPrompt = null;
5420
6096
  confirmPrompt = null;
6097
+ helpPrompt = null;
5421
6098
  completions = [];
5422
6099
  // Scrollback offset: 0 = pinned to bottom (live), N = N wrapped lines
5423
6100
  // above the bottom. Mouse wheel and PgUp/PgDn adjust this; new content
@@ -5445,8 +6122,8 @@ var init_screen = __esm({
5445
6122
  bannerSearchIndicator = null;
5446
6123
  banner = {
5447
6124
  status: "ready",
5448
- planMode: false,
5449
- hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303V paste \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
6125
+ currentMode: void 0,
6126
+ hint: "\u21E7\u21E5 mode \xB7 \u2303P pick \xB7 \u2303G guide \xB7 \u2303D detach",
5450
6127
  queued: 0
5451
6128
  };
5452
6129
  sessionbar = { agent: "?", cwd: "?", sessionId: "?" };
@@ -5591,7 +6268,10 @@ var init_screen = __esm({
5591
6268
  }
5592
6269
  const startIdx = text.indexOf(startMarker);
5593
6270
  if (startIdx === -1) {
5594
- if (this.terminalKitStdinHandler) {
6271
+ const url = matchBareUrl(text);
6272
+ if (url !== null) {
6273
+ this.onKey([{ type: "paste", text: url }]);
6274
+ } else if (this.terminalKitStdinHandler) {
5595
6275
  this.terminalKitStdinHandler(Buffer.from(text, "binary"));
5596
6276
  }
5597
6277
  return;
@@ -5821,11 +6501,11 @@ var init_screen = __esm({
5821
6501
  return;
5822
6502
  }
5823
6503
  this.lastWindowTitle = clean;
5824
- process.stdout.write(`\x1B]2;${clean}\x1B\\`);
6504
+ process.stdout.write(`\x1B]0;${clean}\x1B\\`);
5825
6505
  }
5826
6506
  clearWindowTitle() {
5827
6507
  this.lastWindowTitle = null;
5828
- process.stdout.write("\x1B]2;\x1B\\");
6508
+ process.stdout.write("\x1B]0;\x1B\\");
5829
6509
  }
5830
6510
  setBanner(banner) {
5831
6511
  this.banner = { ...this.banner, ...banner };
@@ -5833,6 +6513,9 @@ var init_screen = __esm({
5833
6513
  this.drawBanner();
5834
6514
  this.placeCursor();
5835
6515
  }
6516
+ currentModeId() {
6517
+ return this.banner.currentMode;
6518
+ }
5836
6519
  // OSC 9;4 progress-bar control. State 3 = indeterminate (pulsing
5837
6520
  // taskbar / dock badge while a turn is running); state 0 = remove.
5838
6521
  // ConEmu-flavor sequence — supported by Windows Terminal, WezTerm,
@@ -6003,6 +6686,16 @@ var init_screen = __esm({
6003
6686
  this.confirmPrompt = spec ? { ...spec } : null;
6004
6687
  this.repaint();
6005
6688
  }
6689
+ // Multi-row help cheatsheet that takes over the prompt area. Used by
6690
+ // the ^G hotkey to surface every binding without dropping the user
6691
+ // out of the session. Pass null to dismiss.
6692
+ setHelpPrompt(spec) {
6693
+ this.helpPrompt = spec ? { ...spec, entries: [...spec.entries] } : null;
6694
+ this.repaint();
6695
+ }
6696
+ isHelpPromptActive() {
6697
+ return this.helpPrompt !== null;
6698
+ }
6006
6699
  // Slash-command completion list shown directly above the separator. App
6007
6700
  // calls this after each keystroke; pass [] to dismiss. Suppressed when
6008
6701
  // the permission modal is active (the modal owns the prompt area).
@@ -6538,7 +7231,7 @@ var init_screen = __esm({
6538
7231
  this.repaint();
6539
7232
  }
6540
7233
  completionRows() {
6541
- if (this.permissionPrompt) {
7234
+ if (this.permissionPrompt || this.confirmPrompt || this.helpPrompt) {
6542
7235
  return 0;
6543
7236
  }
6544
7237
  return Math.min(MAX_COMPLETION_ROWS, this.completions.length);
@@ -6682,6 +7375,10 @@ var init_screen = __esm({
6682
7375
  this.drawConfirmPrompt();
6683
7376
  return;
6684
7377
  }
7378
+ if (this.helpPrompt) {
7379
+ this.drawHelpPrompt();
7380
+ return;
7381
+ }
6685
7382
  const w = this.term.width;
6686
7383
  const room = Math.max(1, w - 2);
6687
7384
  const state = this.dispatcher.state();
@@ -6731,6 +7428,58 @@ var init_screen = __esm({
6731
7428
  this.term.dim(` ${truncate(spec.hint, w - 2)}`);
6732
7429
  });
6733
7430
  }
7431
+ drawHelpPrompt() {
7432
+ const spec = this.helpPrompt;
7433
+ if (!spec) {
7434
+ return;
7435
+ }
7436
+ const w = this.term.width;
7437
+ const rows = this.helpRows();
7438
+ const top = this.term.height - rows - BANNER_ROWS - SEPARATOR_ROWS - SESSIONBAR_ROWS + 1;
7439
+ let row = top;
7440
+ const writeRow = (sig, paint) => {
7441
+ if (row >= top + rows) {
7442
+ return;
7443
+ }
7444
+ this.paintRow(row, sig, paint);
7445
+ row += 1;
7446
+ };
7447
+ writeRow(`help|t|${w}|${spec.title}`, () => {
7448
+ this.term.brightYellow(` \u2753 ${truncate(spec.title, w - 5)}`);
7449
+ });
7450
+ const keysWidth = Math.min(
7451
+ 24,
7452
+ Math.max(
7453
+ ...spec.entries.map((e) => e === null ? 0 : e[0].length),
7454
+ 4
7455
+ )
7456
+ );
7457
+ for (const entry of spec.entries) {
7458
+ if (row >= top + rows - 1) {
7459
+ break;
7460
+ }
7461
+ if (entry === null) {
7462
+ writeRow(`help|sep|${w}|${row}`, () => void 0);
7463
+ continue;
7464
+ }
7465
+ const [keys, desc] = entry;
7466
+ const paddedKeys = keys.padEnd(keysWidth);
7467
+ writeRow(`help|e|${w}|${keys}|${desc}`, () => {
7468
+ this.term(" ");
7469
+ this.term.brightCyan.noFormat(paddedKeys);
7470
+ this.term.noFormat(` ${truncate(desc, w - 2 - keysWidth - 1)}`);
7471
+ });
7472
+ }
7473
+ writeRow(`help|hint|${w}|${spec.hint}`, () => {
7474
+ this.term.dim(` ${truncate(spec.hint, w - 2)}`);
7475
+ });
7476
+ }
7477
+ helpRows() {
7478
+ if (!this.helpPrompt) {
7479
+ return 0;
7480
+ }
7481
+ return Math.min(MAX_HELP_ROWS, 2 + this.helpPrompt.entries.length);
7482
+ }
6734
7483
  drawPermissionPrompt() {
6735
7484
  const spec = this.permissionPrompt;
6736
7485
  if (!spec) {
@@ -6785,10 +7534,9 @@ var init_screen = __esm({
6785
7534
  const elapsedStr = this.banner.status === "busy" && this.banner.elapsedMs !== void 0 && this.banner.elapsedMs >= 1e3 ? formatElapsed(this.banner.elapsedMs) : "";
6786
7535
  const right = this.bannerRightContent();
6787
7536
  const rightSig = right ? `${right.kind}|${right.text}` : "";
6788
- const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.planMode ? "1" : "0"}|${this.banner.hint}|` + rightSig;
7537
+ const sig = `bnr|${w}|${this.banner.status}|${elapsedStr}|${this.banner.queued}|${this.scrollOffset}|${this.banner.currentMode ?? ""}|${this.banner.hint}|` + rightSig;
6789
7538
  this.paintRow(row, sig, () => {
6790
7539
  const dot = this.banner.status === "busy" ? "\u25CF" : "\u25CB";
6791
- const planLabel = this.banner.planMode ? "plan: ON " : "plan: off";
6792
7540
  if (this.banner.status === "busy") {
6793
7541
  this.term.brightYellow(`${dot} ${this.banner.status}`);
6794
7542
  if (elapsedStr) {
@@ -6807,13 +7555,11 @@ var init_screen = __esm({
6807
7555
  if (this.scrollOffset > 0) {
6808
7556
  this.term(" \xB7 ").brightCyan(`\u2191 ${this.scrollOffset}`);
6809
7557
  }
6810
- this.term(" \xB7 ");
6811
- if (this.banner.planMode) {
6812
- this.term.brightYellow(planLabel);
6813
- } else {
6814
- this.term.dim(planLabel);
6815
- }
6816
- this.term(" \xB7 ").dim(this.banner.hint);
7558
+ const hint = this.banner.currentMode ? this.banner.hint.replace(
7559
+ "\u21E7\u21E5 mode",
7560
+ `\u21E7\u21E5 mode(${this.banner.currentMode})`
7561
+ ) : this.banner.hint;
7562
+ this.term(" \xB7 ").dim(hint);
6817
7563
  if (right) {
6818
7564
  const visibleWidth = stringWidth(right.text);
6819
7565
  const col = Math.max(1, w - visibleWidth + 1);
@@ -6843,6 +7589,12 @@ var init_screen = __esm({
6843
7589
  this.term.moveTo(2, top2);
6844
7590
  return;
6845
7591
  }
7592
+ if (this.helpPrompt) {
7593
+ const rows = this.helpRows();
7594
+ const top2 = this.term.height - rows - BANNER_ROWS - SEPARATOR_ROWS - SESSIONBAR_ROWS + 1;
7595
+ this.term.moveTo(2, top2);
7596
+ return;
7597
+ }
6846
7598
  if (this.scrollbackSearch) {
6847
7599
  this.term.hideCursor(true);
6848
7600
  return;
@@ -6869,6 +7621,9 @@ var init_screen = __esm({
6869
7621
  if (this.confirmPrompt) {
6870
7622
  return CONFIRM_PROMPT_ROWS;
6871
7623
  }
7624
+ if (this.helpPrompt) {
7625
+ return this.helpRows();
7626
+ }
6872
7627
  const w = this.term.width;
6873
7628
  const room = Math.max(1, w - 2);
6874
7629
  const state = this.dispatcher.state();
@@ -7156,7 +7911,7 @@ var init_input = __esm({
7156
7911
  this.retreatHistorySearch();
7157
7912
  return [];
7158
7913
  }
7159
- if (event.name === "escape") {
7914
+ if (event.name === "escape" || event.name === "ctrl-c") {
7160
7915
  this.cancelHistorySearch();
7161
7916
  return [];
7162
7917
  }
@@ -7227,6 +7982,14 @@ var init_input = __esm({
7227
7982
  case "ctrl-f":
7228
7983
  this.moveRight();
7229
7984
  return [];
7985
+ case "ctrl-g":
7986
+ return [{ type: "show-help" }];
7987
+ case "alt-b":
7988
+ this.moveWordBackward();
7989
+ return [];
7990
+ case "alt-f":
7991
+ this.moveWordForward();
7992
+ return [];
7230
7993
  case "ctrl-k":
7231
7994
  this.killToEnd();
7232
7995
  return [];
@@ -7252,6 +8015,8 @@ var init_input = __esm({
7252
8015
  return [{ type: "redraw" }];
7253
8016
  case "ctrl-p":
7254
8017
  return [{ type: "switch-session" }];
8018
+ case "ctrl-t":
8019
+ return [{ type: "next-live-session" }];
7255
8020
  case "ctrl-r":
7256
8021
  return this.startHistorySearch();
7257
8022
  case "ctrl-s":
@@ -7428,6 +8193,44 @@ var init_input = __esm({
7428
8193
  this.col = 0;
7429
8194
  }
7430
8195
  }
8196
+ moveWordBackward() {
8197
+ if (this.col === 0) {
8198
+ if (this.row === 0) {
8199
+ return;
8200
+ }
8201
+ this.row -= 1;
8202
+ this.col = this.currentLine().length;
8203
+ return;
8204
+ }
8205
+ const line = this.currentLine();
8206
+ let i = this.col;
8207
+ while (i > 0 && /\s/.test(line[i - 1] ?? "")) {
8208
+ i -= 1;
8209
+ }
8210
+ while (i > 0 && !/\s/.test(line[i - 1] ?? "")) {
8211
+ i -= 1;
8212
+ }
8213
+ this.col = i;
8214
+ }
8215
+ moveWordForward() {
8216
+ const line = this.currentLine();
8217
+ if (this.col >= line.length) {
8218
+ if (this.row >= this.buffer.length - 1) {
8219
+ return;
8220
+ }
8221
+ this.row += 1;
8222
+ this.col = 0;
8223
+ return;
8224
+ }
8225
+ let i = this.col;
8226
+ while (i < line.length && /\s/.test(line[i] ?? "")) {
8227
+ i += 1;
8228
+ }
8229
+ while (i < line.length && !/\s/.test(line[i] ?? "")) {
8230
+ i += 1;
8231
+ }
8232
+ this.col = i;
8233
+ }
7431
8234
  // Up walks the navigation stack from newest to oldest: pending queue
7432
8235
  // items first (so the user can edit something they just enqueued),
7433
8236
  // then prompt history. Cursor movement within a multi-line buffer
@@ -7741,9 +8544,9 @@ var init_input = __esm({
7741
8544
 
7742
8545
  // src/tui/clipboard.ts
7743
8546
  import { spawn as nodeSpawn } from "child_process";
7744
- import fs14 from "fs/promises";
8547
+ import fs18 from "fs/promises";
7745
8548
  import os4 from "os";
7746
- import path10 from "path";
8549
+ import path13 from "path";
7747
8550
  async function readClipboard(envIn = {}) {
7748
8551
  const env = { ...defaultEnv, ...envIn };
7749
8552
  if (env.platform === "darwin") {
@@ -7758,7 +8561,7 @@ async function readClipboard(envIn = {}) {
7758
8561
  };
7759
8562
  }
7760
8563
  async function readMacOS(env) {
7761
- const tmpPath = path10.join(
8564
+ const tmpPath = path13.join(
7762
8565
  env.tmpdir(),
7763
8566
  `hydra-clipboard-${Date.now()}-${process.pid}.png`
7764
8567
  );
@@ -7782,7 +8585,7 @@ async function readMacOS(env) {
7782
8585
  return img;
7783
8586
  }
7784
8587
  } catch {
7785
- await fs14.unlink(tmpPath).catch(() => void 0);
8588
+ await fs18.unlink(tmpPath).catch(() => void 0);
7786
8589
  }
7787
8590
  try {
7788
8591
  const buf = await runCapture(env.spawn, "pbpaste", []);
@@ -7870,9 +8673,9 @@ async function which(env, cmd) {
7870
8673
  }
7871
8674
  async function readFileAsAttachment(p, unlinkAfter) {
7872
8675
  try {
7873
- const buf = await fs14.readFile(p);
8676
+ const buf = await fs18.readFile(p);
7874
8677
  if (unlinkAfter) {
7875
- await fs14.unlink(p).catch(() => void 0);
8678
+ await fs18.unlink(p).catch(() => void 0);
7876
8679
  }
7877
8680
  if (buf.length === 0) {
7878
8681
  return { ok: false, reason: "no image on clipboard" };
@@ -8062,6 +8865,7 @@ function formatEvent(event) {
8062
8865
  case "usage-update":
8063
8866
  return [];
8064
8867
  case "available-commands":
8868
+ case "available-modes":
8065
8869
  return [];
8066
8870
  case "session-info":
8067
8871
  return [];
@@ -8360,17 +9164,18 @@ var init_format = __esm({
8360
9164
  import { appendFileSync, statSync, renameSync } from "fs";
8361
9165
  import { nanoid as nanoid3 } from "nanoid";
8362
9166
  import termkit from "terminal-kit";
8363
- import fs15 from "fs/promises";
8364
- import path11 from "path";
9167
+ import fs19 from "fs/promises";
9168
+ import path14 from "path";
8365
9169
  async function runTuiApp(opts) {
8366
- const config = await ensureConfig();
9170
+ const config = await loadConfig();
9171
+ const serviceToken = await ensureServiceToken();
8367
9172
  logMaxBytes = config.tui.logMaxBytes;
8368
9173
  await ensureDaemonReachable(config);
8369
9174
  const term = termkit.terminal;
8370
9175
  const exitHint = {};
8371
9176
  let nextOpts = opts;
8372
9177
  while (nextOpts !== null) {
8373
- nextOpts = await runSession(term, config, nextOpts, exitHint);
9178
+ nextOpts = await runSession(term, config, serviceToken, nextOpts, exitHint);
8374
9179
  }
8375
9180
  const pendingUpdate = await getPendingUpdate();
8376
9181
  if (pendingUpdate) {
@@ -8383,8 +9188,8 @@ async function runTuiApp(opts) {
8383
9188
  `);
8384
9189
  }
8385
9190
  }
8386
- async function runSession(term, config, opts, exitHint) {
8387
- const ctx = await resolveSession(term, config, opts);
9191
+ async function runSession(term, config, serviceToken, opts, exitHint) {
9192
+ const ctx = await resolveSession(term, config, serviceToken, opts);
8388
9193
  if (!ctx) {
8389
9194
  term.grabInput(false);
8390
9195
  process.exit(0);
@@ -8393,7 +9198,7 @@ async function runSession(term, config, opts, exitHint) {
8393
9198
  term.brightYellow(launchLabel)("\n");
8394
9199
  const protocol = config.daemon.tls ? "wss" : "ws";
8395
9200
  const wsUrl = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
8396
- const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
9201
+ const subprotocols = ["acp.v1", `hydra-acp-token.${serviceToken}`];
8397
9202
  let onReconnect = null;
8398
9203
  let onDisconnectHook = null;
8399
9204
  const stream = new ResilientWsStream({
@@ -8462,20 +9267,31 @@ async function runSession(term, config, opts, exitHint) {
8462
9267
  screenRef.setBanner({ status: "ready", elapsedMs: void 0 });
8463
9268
  }
8464
9269
  }
8465
- if (delta < 0) {
8466
- tickWorker();
8467
- }
9270
+ void delta;
8468
9271
  };
8469
9272
  let screenRef = null;
8470
9273
  let dispatcherRef = null;
8471
- conn.onNotification("session/update", (params) => {
8472
- if (teardownStarted) {
8473
- return;
8474
- }
9274
+ let lastSeenMessageId = void 0;
9275
+ let reconnectReplayBuffer = null;
9276
+ const STATE_UPDATE_KINDS2 = /* @__PURE__ */ new Set([
9277
+ "session_info_update",
9278
+ "current_model_update",
9279
+ "current_mode_update",
9280
+ "available_commands_update",
9281
+ "available_modes_update",
9282
+ "usage_update"
9283
+ ]);
9284
+ const handleSessionUpdate = (params) => {
8475
9285
  const { update } = params ?? {};
8476
9286
  const event = mapUpdate(update);
8477
9287
  debugLogUpdate(update, event);
8478
9288
  const rawTag = update?.sessionUpdate;
9289
+ if (typeof rawTag === "string" && !STATE_UPDATE_KINDS2.has(rawTag)) {
9290
+ const u = update ?? {};
9291
+ if (typeof u.messageId === "string") {
9292
+ lastSeenMessageId = u.messageId;
9293
+ }
9294
+ }
8479
9295
  if (rawTag === "prompt_received") {
8480
9296
  adjustPendingTurns(1);
8481
9297
  } else if (event?.kind === "turn-complete") {
@@ -8487,6 +9303,16 @@ async function runSession(term, config, opts, exitHint) {
8487
9303
  }
8488
9304
  appendRender(event);
8489
9305
  maybeDismissPermissionByToolUpdate(update);
9306
+ };
9307
+ conn.onNotification("session/update", (params) => {
9308
+ if (teardownStarted) {
9309
+ return;
9310
+ }
9311
+ if (reconnectReplayBuffer !== null) {
9312
+ reconnectReplayBuffer.push(params);
9313
+ return;
9314
+ }
9315
+ handleSessionUpdate(params);
8490
9316
  });
8491
9317
  conn.onNotification("hydra-acp/session_closed", () => {
8492
9318
  if (teardownStarted) {
@@ -8500,6 +9326,75 @@ async function runSession(term, config, opts, exitHint) {
8500
9326
  screenRef.setBanner({ status: "cold", elapsedMs: void 0 });
8501
9327
  }
8502
9328
  });
9329
+ conn.onNotification("hydra-acp/prompt_queue_added", (params) => {
9330
+ if (teardownStarted) return;
9331
+ const p = params ?? {};
9332
+ if (typeof p.messageId !== "string") return;
9333
+ queueCache.set(p.messageId, chipFromPrompt(p.messageId, p.prompt));
9334
+ if (screenRef && dispatcherRef) {
9335
+ refreshQueueDisplay();
9336
+ }
9337
+ if (ownClientId !== void 0 && p.originator?.clientId === ownClientId) {
9338
+ const echo = pendingEchoes.shift();
9339
+ if (echo) {
9340
+ echo.messageId = p.messageId;
9341
+ ownPendingByMid.set(p.messageId, echo);
9342
+ }
9343
+ }
9344
+ });
9345
+ conn.onNotification("hydra-acp/prompt_queue_updated", (params) => {
9346
+ if (teardownStarted) return;
9347
+ const p = params ?? {};
9348
+ if (typeof p.messageId !== "string") return;
9349
+ if (!queueCache.has(p.messageId)) return;
9350
+ queueCache.set(p.messageId, chipFromPrompt(p.messageId, p.prompt));
9351
+ const pending = ownPendingByMid.get(p.messageId);
9352
+ if (pending) {
9353
+ const blocks = Array.isArray(p.prompt) ? p.prompt : [];
9354
+ let text = "";
9355
+ const attachments = [];
9356
+ for (const raw of blocks) {
9357
+ if (!raw || typeof raw !== "object") continue;
9358
+ const b = raw;
9359
+ if (b.type === "text" && typeof b.text === "string") {
9360
+ text += b.text;
9361
+ } else if (b.type === "image" && typeof b.data === "string" && typeof b.mimeType === "string") {
9362
+ attachments.push({
9363
+ data: b.data,
9364
+ mimeType: b.mimeType,
9365
+ sizeBytes: Math.floor(b.data.length * 3 / 4)
9366
+ });
9367
+ }
9368
+ }
9369
+ pending.text = text;
9370
+ pending.attachments = attachments;
9371
+ }
9372
+ if (screenRef && dispatcherRef) {
9373
+ refreshQueueDisplay();
9374
+ }
9375
+ });
9376
+ conn.onNotification("hydra-acp/prompt_queue_removed", (params) => {
9377
+ if (teardownStarted) return;
9378
+ const p = params ?? {};
9379
+ if (typeof p.messageId !== "string") return;
9380
+ const hadChip = queueCache.delete(p.messageId);
9381
+ if (hadChip && screenRef && dispatcherRef) {
9382
+ refreshQueueDisplay();
9383
+ }
9384
+ const echo = ownPendingByMid.get(p.messageId);
9385
+ if (echo) {
9386
+ ownPendingByMid.delete(p.messageId);
9387
+ if (p.reason === "started") {
9388
+ echo.flushed = true;
9389
+ appendRender({
9390
+ kind: "user-text",
9391
+ text: echo.text,
9392
+ attachments: echo.attachments
9393
+ });
9394
+ currentTurnEcho = echo;
9395
+ }
9396
+ }
9397
+ });
8503
9398
  const handlePermissionResolved = (update) => {
8504
9399
  const u = update ?? {};
8505
9400
  const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
@@ -8627,9 +9522,12 @@ async function runSession(term, config, opts, exitHint) {
8627
9522
  let resolvedAgentId = ctx.agentId;
8628
9523
  let resolvedCwd = ctx.cwd;
8629
9524
  let resolvedTitle;
9525
+ let ownClientId;
8630
9526
  let initialModel;
8631
9527
  let initialMode;
8632
9528
  let initialCommands;
9529
+ let initialModes;
9530
+ let initialQueue;
8633
9531
  let initialUsage;
8634
9532
  let initialTurnStartedAt;
8635
9533
  if (ctx.sessionId === "__new__") {
@@ -8646,6 +9544,9 @@ async function runSession(term, config, opts, exitHint) {
8646
9544
  ...Object.keys(hydraNewMeta).length > 0 ? { _meta: { [HYDRA_META_KEY]: hydraNewMeta } } : {}
8647
9545
  });
8648
9546
  resolvedSessionId = created.sessionId;
9547
+ if (created.clientId) {
9548
+ ownClientId = created.clientId;
9549
+ }
8649
9550
  exitHint.sessionId = resolvedSessionId;
8650
9551
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
8651
9552
  upstreamSessionId = hydraMeta.upstreamSessionId;
@@ -8665,6 +9566,10 @@ async function runSession(term, config, opts, exitHint) {
8665
9566
  if (hydraMeta.availableCommands) {
8666
9567
  initialCommands = normalizeAdvertisedCommands(hydraMeta.availableCommands);
8667
9568
  }
9569
+ if (hydraMeta.availableModes) {
9570
+ initialModes = hydraMeta.availableModes;
9571
+ }
9572
+ initialQueue = hydraMeta.queue;
8668
9573
  } else {
8669
9574
  const attached = await conn.request("session/attach", {
8670
9575
  sessionId: ctx.sessionId,
@@ -8672,6 +9577,9 @@ async function runSession(term, config, opts, exitHint) {
8672
9577
  clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
8673
9578
  });
8674
9579
  resolvedSessionId = attached.sessionId;
9580
+ if (attached.clientId) {
9581
+ ownClientId = attached.clientId;
9582
+ }
8675
9583
  exitHint.sessionId = resolvedSessionId;
8676
9584
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
8677
9585
  upstreamSessionId = hydraMeta.upstreamSessionId;
@@ -8691,6 +9599,10 @@ async function runSession(term, config, opts, exitHint) {
8691
9599
  if (hydraMeta.availableCommands) {
8692
9600
  initialCommands = normalizeAdvertisedCommands(hydraMeta.availableCommands);
8693
9601
  }
9602
+ if (hydraMeta.availableModes) {
9603
+ initialModes = hydraMeta.availableModes;
9604
+ }
9605
+ initialQueue = hydraMeta.queue;
8694
9606
  }
8695
9607
  const historyFile = paths.tuiHistoryFile(resolvedSessionId);
8696
9608
  let history = await loadHistory(historyFile).catch(() => []);
@@ -8716,6 +9628,9 @@ async function runSession(term, config, opts, exitHint) {
8716
9628
  if (exitConfirmation && tryHandleExitConfirmKey(ev)) {
8717
9629
  continue;
8718
9630
  }
9631
+ if (tryHandleHelpKey(ev)) {
9632
+ continue;
9633
+ }
8719
9634
  if (tryHandleScrollbackSearchKey(ev)) {
8720
9635
  continue;
8721
9636
  }
@@ -8750,6 +9665,7 @@ async function runSession(term, config, opts, exitHint) {
8750
9665
  { name: "/demo-tool", description: "Inject a synthetic tool-call sequence (UI test)" }
8751
9666
  ];
8752
9667
  let agentCommands = initialCommands ?? [];
9668
+ let agentModes = initialModes ?? [];
8753
9669
  const allCommands = () => {
8754
9670
  const seen = /* @__PURE__ */ new Set();
8755
9671
  const out = [];
@@ -8905,7 +9821,7 @@ async function runSession(term, config, opts, exitHint) {
8905
9821
  usage: { ...usage }
8906
9822
  });
8907
9823
  if (initialMode) {
8908
- screen.appendLines(formatEvent({ kind: "mode-changed", mode: initialMode }));
9824
+ screen.setBanner({ currentMode: initialMode });
8909
9825
  }
8910
9826
  void getPendingUpdate().then((info) => {
8911
9827
  if (info) {
@@ -8942,7 +9858,7 @@ async function runSession(term, config, opts, exitHint) {
8942
9858
  }
8943
9859
  let onlyClient = false;
8944
9860
  try {
8945
- const sessions = await listSessions(config);
9861
+ const sessions = await listSessions(config, serviceToken);
8946
9862
  const me = sessions.find((s) => s.sessionId === resolvedSessionId);
8947
9863
  onlyClient = !me || me.attachedClients <= 1;
8948
9864
  } catch {
@@ -9000,6 +9916,28 @@ async function runSession(term, config, opts, exitHint) {
9000
9916
  }
9001
9917
  return true;
9002
9918
  };
9919
+ const toggleHelpModal = () => {
9920
+ if (screen.isHelpPromptActive()) {
9921
+ screen.setHelpPrompt(null);
9922
+ return;
9923
+ }
9924
+ screen.setHelpPrompt({
9925
+ title: "Hotkeys",
9926
+ entries: HELP_ENTRIES2,
9927
+ hint: "any key dismisses \xB7 /help lists commands"
9928
+ });
9929
+ };
9930
+ const tryHandleHelpKey = (ev) => {
9931
+ if (!screen.isHelpPromptActive()) {
9932
+ return false;
9933
+ }
9934
+ if (ev.type === "key" && ev.name === "ctrl-g") {
9935
+ screen.setHelpPrompt(null);
9936
+ return true;
9937
+ }
9938
+ screen.setHelpPrompt(null);
9939
+ return true;
9940
+ };
9003
9941
  const teardown = () => {
9004
9942
  teardownStarted = true;
9005
9943
  process.off("SIGINT", sigintHandler);
@@ -9034,11 +9972,12 @@ async function runSession(term, config, opts, exitHint) {
9034
9972
  screen.pauseRepaint();
9035
9973
  screen.stop();
9036
9974
  saveHistory(historyFile, history).catch(() => void 0);
9037
- const sessions = await listSessions(config);
9975
+ const sessions = await listSessions(config, serviceToken);
9038
9976
  const choice = await pickSession(term, {
9039
9977
  cwd: resolvedCwd,
9040
9978
  sessions,
9041
9979
  config,
9980
+ serviceToken,
9042
9981
  currentSessionId: resolvedSessionId
9043
9982
  });
9044
9983
  if (choice.kind === "abort") {
@@ -9066,37 +10005,76 @@ async function runSession(term, config, opts, exitHint) {
9066
10005
  }
9067
10006
  resume(nextOpts);
9068
10007
  };
9069
- const queueHeadOffset = () => workerActive ? 1 : 0;
10008
+ const cycleLiveSession = async () => {
10009
+ if (!finishSession)
10010
+ return;
10011
+ const sessions = await listSessions(config, serviceToken);
10012
+ const live = sessions.filter((s) => s.status === "live");
10013
+ if (live.length <= 1)
10014
+ return;
10015
+ const idx = live.findIndex((s) => s.sessionId === resolvedSessionId);
10016
+ const next = live[(idx + 1) % live.length];
10017
+ const resume = finishSession;
10018
+ finishSession = null;
10019
+ process.off("SIGINT", sigintHandler);
10020
+ void stream.close().catch(() => void 0);
10021
+ const nextOpts = { ...opts, sessionId: next.sessionId, cwd: resolvedCwd };
10022
+ if (next.agentId !== void 0)
10023
+ nextOpts.agentId = next.agentId;
10024
+ resume(nextOpts);
10025
+ };
9070
10026
  const handleEffect = (effect) => {
9071
10027
  switch (effect.type) {
9072
10028
  case "send":
9073
- enqueuePrompt(effect.text, effect.planMode, effect.attachments);
10029
+ enqueuePrompt(effect.text, effect.attachments);
9074
10030
  return;
9075
10031
  case "queue-edit": {
9076
- const realIdx = effect.index + queueHeadOffset();
9077
- const existing = promptQueue[realIdx];
9078
- if (existing) {
9079
- promptQueue[realIdx] = {
9080
- text: effect.text,
9081
- planMode: existing.planMode,
9082
- attachments: effect.attachments
9083
- };
9084
- refreshQueueDisplay();
10032
+ const mid = queueMessageIdAt(effect.index);
10033
+ if (!mid) {
10034
+ return;
10035
+ }
10036
+ const blocks = [];
10037
+ if (effect.text.length > 0) {
10038
+ blocks.push({ type: "text", text: effect.text });
10039
+ }
10040
+ for (const a of effect.attachments) {
10041
+ blocks.push({ type: "image", data: a.data, mimeType: a.mimeType });
9085
10042
  }
10043
+ conn.request("hydra-acp/update_prompt", {
10044
+ sessionId: resolvedSessionId,
10045
+ messageId: mid,
10046
+ prompt: blocks
10047
+ }).then((raw) => {
10048
+ const res = raw;
10049
+ if (!res.updated && res.reason !== "ok") {
10050
+ screen.notify(`queue edit skipped (${res.reason})`);
10051
+ }
10052
+ }).catch((err) => {
10053
+ screen.notify(`queue edit failed: ${err.message}`);
10054
+ });
9086
10055
  return;
9087
10056
  }
9088
10057
  case "queue-remove": {
9089
- const realIdx = effect.index + queueHeadOffset();
9090
- if (realIdx >= 0 && realIdx < promptQueue.length) {
9091
- promptQueue.splice(realIdx, 1);
9092
- refreshQueueDisplay();
10058
+ const mid = queueMessageIdAt(effect.index);
10059
+ if (!mid) {
10060
+ return;
9093
10061
  }
10062
+ conn.request("hydra-acp/cancel_prompt", {
10063
+ sessionId: resolvedSessionId,
10064
+ messageId: mid
10065
+ }).then((raw) => {
10066
+ const res = raw;
10067
+ if (!res.cancelled && res.reason !== "ok") {
10068
+ screen.notify(`queue cancel skipped (${res.reason})`);
10069
+ }
10070
+ }).catch((err) => {
10071
+ screen.notify(`queue cancel failed: ${err.message}`);
10072
+ });
9094
10073
  return;
9095
10074
  }
9096
10075
  case "cancel": {
9097
10076
  if (effect.prefill && turnInFlight) {
9098
- const headOffset = workerActive ? 1 : 0;
9099
- const waitingEmpty = promptQueue.length <= headOffset;
10077
+ const waitingEmpty = queueCache.size === 0;
9100
10078
  const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
9101
10079
  if (waitingEmpty && bufferEmpty) {
9102
10080
  pendingPrefill = {
@@ -9116,7 +10094,7 @@ async function runSession(term, config, opts, exitHint) {
9116
10094
  void requestExit();
9117
10095
  return;
9118
10096
  case "plan-toggle":
9119
- screen.setBanner({ planMode: effect.on });
10097
+ void handleModeToggle(effect.on);
9120
10098
  return;
9121
10099
  case "redraw-banner":
9122
10100
  screen.setBanner({});
@@ -9133,10 +10111,16 @@ async function runSession(term, config, opts, exitHint) {
9133
10111
  case "switch-session":
9134
10112
  void switchSession();
9135
10113
  return;
10114
+ case "next-live-session":
10115
+ void cycleLiveSession();
10116
+ return;
9136
10117
  case "toggle-tools":
9137
10118
  toolsExpanded = !toolsExpanded;
9138
10119
  renderToolsBlock();
9139
10120
  return;
10121
+ case "show-help":
10122
+ toggleHelpModal();
10123
+ return;
9140
10124
  case "escalate-search":
9141
10125
  screen.enterScrollbackSearch();
9142
10126
  screen.updateScrollbackSearchTerm(effect.query);
@@ -9176,11 +10160,11 @@ async function runSession(term, config, opts, exitHint) {
9176
10160
  }
9177
10161
  const mimeType = mimeFromExtension(token);
9178
10162
  if (!mimeType) {
9179
- screen.notify(`unsupported image type: ${path11.basename(token)}`);
10163
+ screen.notify(`unsupported image type: ${path14.basename(token)}`);
9180
10164
  continue;
9181
10165
  }
9182
10166
  try {
9183
- const buf = await fs15.readFile(token);
10167
+ const buf = await fs19.readFile(token);
9184
10168
  if (buf.length > MAX_ATTACHMENT_BYTES) {
9185
10169
  screen.notify(
9186
10170
  `image too large (${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)})`
@@ -9190,13 +10174,13 @@ async function runSession(term, config, opts, exitHint) {
9190
10174
  dispatcher.addAttachment({
9191
10175
  mimeType,
9192
10176
  data: buf.toString("base64"),
9193
- name: path11.basename(token),
10177
+ name: path14.basename(token),
9194
10178
  sizeBytes: buf.length
9195
10179
  });
9196
10180
  added++;
9197
10181
  } catch (err) {
9198
10182
  screen.notify(
9199
- `cannot read ${path11.basename(token)}: ${err.message}`
10183
+ `cannot read ${path14.basename(token)}: ${err.message}`
9200
10184
  );
9201
10185
  }
9202
10186
  }
@@ -9227,18 +10211,54 @@ async function runSession(term, config, opts, exitHint) {
9227
10211
  }
9228
10212
  screen.refreshPrompt();
9229
10213
  };
9230
- const promptQueue = [];
9231
- let workerActive = false;
10214
+ const formatQueueChipText = (entry) => entry.attachmentCount > 0 ? `${entry.text} \xB7 \u{1F4CE}\xD7${entry.attachmentCount}` : entry.text;
10215
+ const chipFromPrompt = (messageId, prompt) => {
10216
+ const blocks = Array.isArray(prompt) ? prompt : [];
10217
+ let text = "";
10218
+ let attachmentCount = 0;
10219
+ for (const raw of blocks) {
10220
+ if (!raw || typeof raw !== "object") continue;
10221
+ const b = raw;
10222
+ if (b.type === "text" && typeof b.text === "string") {
10223
+ text += b.text;
10224
+ } else if (b.type === "image") {
10225
+ attachmentCount += 1;
10226
+ }
10227
+ }
10228
+ return {
10229
+ messageId,
10230
+ text: sanitizeSingleLine(text),
10231
+ attachmentCount
10232
+ };
10233
+ };
10234
+ const queueCache = /* @__PURE__ */ new Map();
10235
+ const pendingEchoes = [];
10236
+ const ownPendingByMid = /* @__PURE__ */ new Map();
10237
+ let currentTurnEcho = null;
9232
10238
  const refreshQueueDisplay = () => {
9233
- const waiting = promptQueue.slice(workerActive ? 1 : 0);
9234
- const displayTexts = waiting.map(
9235
- (p) => p.attachments.length > 0 ? `${p.text} \xB7 \u{1F4CE}\xD7${p.attachments.length}` : p.text
9236
- );
10239
+ const entries = [...queueCache.values()];
10240
+ const displayTexts = entries.map(formatQueueChipText);
9237
10241
  screen.setQueuedPrompts(displayTexts);
9238
- screen.setBanner({ queued: waiting.length });
9239
- dispatcher.setQueue(waiting.map((p) => p.text));
10242
+ screen.setBanner({ queued: entries.length });
10243
+ dispatcher.setQueue(entries.map((e) => e.text));
10244
+ };
10245
+ const queueMessageIdAt = (index) => {
10246
+ const entries = [...queueCache.values()];
10247
+ return entries[index]?.messageId;
9240
10248
  };
9241
- const enqueuePrompt = (text, planMode, attachments) => {
10249
+ if (initialQueue && initialQueue.length > 0) {
10250
+ for (const entry of initialQueue) {
10251
+ if (entry.position === 0) continue;
10252
+ queueCache.set(
10253
+ entry.messageId,
10254
+ chipFromPrompt(entry.messageId, entry.prompt)
10255
+ );
10256
+ }
10257
+ if (queueCache.size > 0) {
10258
+ refreshQueueDisplay();
10259
+ }
10260
+ }
10261
+ const enqueuePrompt = (text, attachments) => {
9242
10262
  screen.scrollToBottom();
9243
10263
  if (handleBuiltinCommand(text)) {
9244
10264
  return;
@@ -9246,15 +10266,29 @@ async function runSession(term, config, opts, exitHint) {
9246
10266
  history = appendEntry(history, text);
9247
10267
  dispatcher.setHistory(history);
9248
10268
  saveHistory(historyFile, history).catch(() => void 0);
9249
- promptQueue.push({ text, planMode, attachments });
9250
- refreshQueueDisplay();
9251
- tickWorker();
10269
+ void runPrompt(text, attachments);
9252
10270
  };
9253
- const tickWorker = () => {
9254
- if (workerActive || pendingTurns > 0 || promptQueue.length === 0) {
10271
+ const handleModeToggle = async (_on) => {
10272
+ if (agentModes.length === 0) {
10273
+ screen.notify("no modes advertised by agent");
10274
+ return;
10275
+ }
10276
+ const currentMode = screen.currentModeId();
10277
+ const idx = agentModes.findIndex((m) => m.id === currentMode);
10278
+ const nextIdx = idx === -1 ? 0 : (idx + 1) % agentModes.length;
10279
+ const newModeId = agentModes[nextIdx]?.id;
10280
+ if (!newModeId) {
9255
10281
  return;
9256
10282
  }
9257
- void runQueueWorker();
10283
+ screen.setBanner({ currentMode: newModeId });
10284
+ try {
10285
+ await conn.request("session/set_mode", {
10286
+ sessionId: resolvedSessionId,
10287
+ modeId: newModeId
10288
+ });
10289
+ } catch (err) {
10290
+ screen.notify(`set_mode failed: ${err.message}`);
10291
+ }
9258
10292
  };
9259
10293
  const handleBuiltinCommand = (text) => {
9260
10294
  const trimmed = text.trim();
@@ -9404,33 +10438,7 @@ async function runSession(term, config, opts, exitHint) {
9404
10438
  return false;
9405
10439
  }
9406
10440
  };
9407
- const runQueueWorker = async () => {
9408
- workerActive = true;
9409
- try {
9410
- while (promptQueue.length > 0 && pendingTurns === 0) {
9411
- const next = promptQueue[0];
9412
- if (!next) {
9413
- break;
9414
- }
9415
- refreshQueueDisplay();
9416
- await processPrompt(next.text, next.planMode, next.attachments);
9417
- promptQueue.shift();
9418
- }
9419
- } finally {
9420
- workerActive = false;
9421
- refreshQueueDisplay();
9422
- if (pendingPrefill !== null) {
9423
- const { text, attachments } = pendingPrefill;
9424
- pendingPrefill = null;
9425
- const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
9426
- if (bufferEmpty) {
9427
- dispatcher.setBuffer(text, attachments);
9428
- screen.refreshPrompt();
9429
- }
9430
- }
9431
- }
9432
- };
9433
- const processPrompt = async (text, planMode, attachments) => {
10441
+ const runPrompt = async (text, attachments) => {
9434
10442
  const userBlocks = [];
9435
10443
  if (text.length > 0) {
9436
10444
  userBlocks.push({ type: "text", text });
@@ -9438,9 +10446,9 @@ async function runSession(term, config, opts, exitHint) {
9438
10446
  for (const a of attachments) {
9439
10447
  userBlocks.push({ type: "image", data: a.data, mimeType: a.mimeType });
9440
10448
  }
9441
- const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
9442
10449
  adjustPendingTurns(1);
9443
- appendRender({ kind: "user-text", text, attachments });
10450
+ const echo = { text, attachments, flushed: false };
10451
+ pendingEchoes.push(echo);
9444
10452
  let cancelled = false;
9445
10453
  turnInFlight = {
9446
10454
  text,
@@ -9459,23 +10467,45 @@ async function runSession(term, config, opts, exitHint) {
9459
10467
  try {
9460
10468
  const response = await conn.request("session/prompt", {
9461
10469
  sessionId: resolvedSessionId,
9462
- prompt: promptArr
10470
+ prompt: userBlocks
9463
10471
  });
9464
10472
  if (response && typeof response.stopReason === "string") {
9465
10473
  stopReason = response.stopReason;
9466
10474
  }
9467
10475
  } catch (err) {
9468
- appendRender({
9469
- kind: "unknown",
9470
- sessionUpdate: "error",
9471
- raw: { error: err.message }
9472
- });
10476
+ const idx = pendingEchoes.indexOf(echo);
10477
+ if (idx >= 0) {
10478
+ pendingEchoes.splice(idx, 1);
10479
+ }
10480
+ if (echo.messageId !== void 0) {
10481
+ ownPendingByMid.delete(echo.messageId);
10482
+ }
10483
+ screen.appendLines([
10484
+ {
10485
+ prefix: "\u2717 ",
10486
+ prefixStyle: "tool-status-fail",
10487
+ body: err.message,
10488
+ bodyStyle: "tool-status-fail"
10489
+ }
10490
+ ]);
9473
10491
  } finally {
9474
10492
  turnInFlight = null;
9475
10493
  adjustPendingTurns(-1);
9476
- appendRender(
9477
- stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
9478
- );
10494
+ if (echo.flushed && currentTurnEcho === echo) {
10495
+ appendRender(
10496
+ stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" }
10497
+ );
10498
+ currentTurnEcho = null;
10499
+ }
10500
+ if (pendingPrefill !== null) {
10501
+ const { text: pt, attachments: pa } = pendingPrefill;
10502
+ pendingPrefill = null;
10503
+ const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
10504
+ if (bufferEmpty) {
10505
+ dispatcher.setBuffer(pt, pa);
10506
+ screen.refreshPrompt();
10507
+ }
10508
+ }
9479
10509
  }
9480
10510
  };
9481
10511
  const toolStates = /* @__PURE__ */ new Map();
@@ -9605,6 +10635,14 @@ async function runSession(term, config, opts, exitHint) {
9605
10635
  refreshCompletions();
9606
10636
  return;
9607
10637
  }
10638
+ if (event.kind === "available-modes") {
10639
+ agentModes = event.modes;
10640
+ return;
10641
+ }
10642
+ if (event.kind === "mode-changed") {
10643
+ screen.setBanner({ currentMode: event.mode || void 0 });
10644
+ return;
10645
+ }
9608
10646
  if (event.kind === "session-info") {
9609
10647
  if (event.title !== void 0) {
9610
10648
  screen.setSessionbar({ title: event.title });
@@ -9640,6 +10678,11 @@ async function runSession(term, config, opts, exitHint) {
9640
10678
  }
9641
10679
  if (event.kind === "user-text") {
9642
10680
  closeAgentText();
10681
+ if (toolsBlockStartedAt !== null) {
10682
+ toolsBlockEndedAt = Date.now();
10683
+ renderToolsBlock();
10684
+ }
10685
+ currentTurnEcho = null;
9643
10686
  screen.ensureSeparator();
9644
10687
  const formatted2 = formatEvent(event);
9645
10688
  if (formatted2.length > 0) {
@@ -9708,6 +10751,15 @@ async function runSession(term, config, opts, exitHint) {
9708
10751
  toolsBlockStopReason = event.stopReason ?? null;
9709
10752
  renderToolsBlock();
9710
10753
  screen.clearKey("tools");
10754
+ } else if (event.stopReason !== void 0 && event.stopReason !== "end_turn") {
10755
+ screen.appendLines([
10756
+ {
10757
+ prefix: "\u26A0 ",
10758
+ prefixStyle: "tool-status-fail",
10759
+ body: `turn ended: ${event.stopReason}`,
10760
+ bodyStyle: "tool-status-fail"
10761
+ }
10762
+ ]);
9711
10763
  }
9712
10764
  toolStates.clear();
9713
10765
  toolCallOrder.length = 0;
@@ -9755,23 +10807,21 @@ async function runSession(term, config, opts, exitHint) {
9755
10807
  resolve5({ outcome: { outcome: "cancelled" } });
9756
10808
  }
9757
10809
  closeAgentText();
9758
- if (toolsBlockStartedAt !== null) {
9759
- toolsBlockEndedAt = Date.now();
9760
- toolsBlockStopReason = null;
9761
- renderToolsBlock();
9762
- screen.clearKey("tools");
9763
- toolStates.clear();
9764
- toolCallOrder.length = 0;
9765
- toolsBlockStartedAt = null;
9766
- toolsBlockEndedAt = null;
9767
- toolsBlockStopReason = null;
9768
- toolsExpanded = false;
9769
- }
9770
- screen.clearKey("plan");
9771
- lastPlanEvent = null;
9772
- if (pendingTurns > 0) {
9773
- adjustPendingTurns(-pendingTurns);
10810
+ };
10811
+ const markToolsBlockRecoveryFailed = () => {
10812
+ if (toolsBlockStartedAt === null) {
10813
+ return;
9774
10814
  }
10815
+ toolsBlockEndedAt = Date.now();
10816
+ toolsBlockStopReason = "reconnect-recovery-failed";
10817
+ renderToolsBlock();
10818
+ screen.clearKey("tools");
10819
+ toolStates.clear();
10820
+ toolCallOrder.length = 0;
10821
+ toolsBlockStartedAt = null;
10822
+ toolsBlockEndedAt = null;
10823
+ toolsBlockStopReason = null;
10824
+ toolsExpanded = false;
9775
10825
  };
9776
10826
  onDisconnectHook = () => {
9777
10827
  screen.setBanner({ status: "disconnected", elapsedMs: void 0 });
@@ -9795,13 +10845,15 @@ async function runSession(term, config, opts, exitHint) {
9795
10845
  await stream.request(initReq);
9796
10846
  } catch {
9797
10847
  }
10848
+ const useAfterMessage = lastSeenMessageId !== void 0;
9798
10849
  const attachReq = {
9799
10850
  jsonrpc: "2.0",
9800
10851
  id: `tui-reattach-${nanoid3()}`,
9801
10852
  method: "session/attach",
9802
10853
  params: {
9803
10854
  sessionId: resolvedSessionId,
9804
- historyPolicy: "none",
10855
+ historyPolicy: useAfterMessage ? "after_message" : "none",
10856
+ ...useAfterMessage ? { afterMessageId: lastSeenMessageId } : {},
9805
10857
  clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
9806
10858
  ...upstreamSessionId !== void 0 ? {
9807
10859
  _meta: {
@@ -9816,19 +10868,46 @@ async function runSession(term, config, opts, exitHint) {
9816
10868
  } : {}
9817
10869
  }
9818
10870
  };
10871
+ reconnectReplayBuffer = [];
10872
+ let appliedPolicy;
10873
+ let attachErr;
9819
10874
  try {
9820
10875
  const resp = await stream.request(attachReq);
9821
10876
  if (resp.error) {
9822
10877
  throw new Error(resp.error.message);
9823
10878
  }
10879
+ const result = resp.result ?? {};
10880
+ if (typeof result.historyPolicy === "string") {
10881
+ appliedPolicy = result.historyPolicy;
10882
+ }
9824
10883
  } catch (err) {
10884
+ attachErr = err;
10885
+ }
10886
+ const buffered2 = reconnectReplayBuffer ?? [];
10887
+ reconnectReplayBuffer = null;
10888
+ if (attachErr) {
10889
+ markToolsBlockRecoveryFailed();
9825
10890
  screen.appendLines([
9826
10891
  {
9827
10892
  prefix: " ",
9828
- body: `reattach failed: ${err.message}`,
10893
+ body: `reattach failed: ${attachErr.message}`,
10894
+ bodyStyle: "tool-status-fail"
10895
+ }
10896
+ ]);
10897
+ } else if (useAfterMessage && appliedPolicy !== "after_message") {
10898
+ markToolsBlockRecoveryFailed();
10899
+ screen.appendLines([
10900
+ {
10901
+ prefix: "\u26A0 ",
10902
+ prefixStyle: "tool-status-fail",
10903
+ body: "reconnect couldn't replay events since last seen \u2014 scrollback may be incomplete",
9829
10904
  bodyStyle: "tool-status-fail"
9830
10905
  }
9831
10906
  ]);
10907
+ } else {
10908
+ for (const params of buffered2) {
10909
+ handleSessionUpdate(params);
10910
+ }
9832
10911
  }
9833
10912
  screen.setBanner({
9834
10913
  status: pendingTurns > 0 ? "busy" : "ready",
@@ -9846,7 +10925,7 @@ connection lost: ${err.message}
9846
10925
  process.on("SIGINT", sigintHandler);
9847
10926
  return await sessionDone;
9848
10927
  }
9849
- async function resolveSession(term, config, opts) {
10928
+ async function resolveSession(term, config, serviceToken, opts) {
9850
10929
  const cwd = opts.cwd ?? process.cwd();
9851
10930
  if (opts.sessionId) {
9852
10931
  return {
@@ -9859,7 +10938,7 @@ async function resolveSession(term, config, opts) {
9859
10938
  return newCtx(opts, cwd, config);
9860
10939
  }
9861
10940
  if (opts.resume) {
9862
- const sessions2 = await listSessions(config, { cwd, all: true });
10941
+ const sessions2 = await listSessions(config, serviceToken, { cwd, all: true });
9863
10942
  const target = pickMostRecent(sessions2, cwd);
9864
10943
  if (!target) {
9865
10944
  term.yellow(`No sessions found for ${cwd}.
@@ -9872,14 +10951,15 @@ async function resolveSession(term, config, opts) {
9872
10951
  cwd
9873
10952
  };
9874
10953
  }
9875
- const sessions = await listSessions(config);
10954
+ const sessions = await listSessions(config, serviceToken);
9876
10955
  if (sessions.length === 0) {
9877
10956
  return newCtx(opts, cwd, config);
9878
10957
  }
9879
10958
  const choice = await pickSession(term, {
9880
10959
  cwd,
9881
10960
  sessions,
9882
- config
10961
+ config,
10962
+ serviceToken
9883
10963
  });
9884
10964
  if (choice.kind === "abort") {
9885
10965
  return null;
@@ -9934,7 +11014,7 @@ function rotateIfBig(target) {
9934
11014
  } catch {
9935
11015
  }
9936
11016
  }
9937
- var PLAN_PREFIX_TEXT, logMaxBytes;
11017
+ var HELP_ENTRIES2, logMaxBytes;
9938
11018
  var init_app = __esm({
9939
11019
  "src/tui/app.ts"() {
9940
11020
  "use strict";
@@ -9942,6 +11022,7 @@ var init_app = __esm({
9942
11022
  init_types();
9943
11023
  init_resilient_ws();
9944
11024
  init_config();
11025
+ init_service_token();
9945
11026
  init_daemon_bootstrap();
9946
11027
  init_session();
9947
11028
  init_paths();
@@ -9957,7 +11038,34 @@ var init_app = __esm({
9957
11038
  init_completion();
9958
11039
  init_render_update();
9959
11040
  init_format();
9960
- PLAN_PREFIX_TEXT = "Plan mode is on. Outline what you would do without making any changes. Do not edit files, run shell commands, or otherwise execute side effects; produce a plan only.";
11041
+ HELP_ENTRIES2 = [
11042
+ ["Enter", "send prompt (or queue while a turn is running)"],
11043
+ ["Alt+Enter", "newline in prompt"],
11044
+ ["Shift+Tab", "cycle agent modes (plan / accept-edits / etc.)"],
11045
+ ["Tab", "indent \xB7 slash-command completion"],
11046
+ null,
11047
+ ["\u2191 / \u2193", "prompt history \xB7 queue navigation"],
11048
+ ["\u2190/\u2192 Home/End", "cursor movement"],
11049
+ ["Alt+B / Alt+F", "word back / forward"],
11050
+ ["^A / ^E", "line start / end"],
11051
+ ["^W / ^U / ^K", "kill word / line / to end"],
11052
+ ["^Y", "yank last kill"],
11053
+ null,
11054
+ ["^P", "switch session (picker)"],
11055
+ ["^T", "next live session"],
11056
+ ["^V", "paste image from clipboard"],
11057
+ ["^O", "expand / collapse tools block"],
11058
+ null,
11059
+ ["^R / ^S", "history reverse / forward search"],
11060
+ ["PgUp / PgDn", "scroll scrollback"],
11061
+ ["Mouse wheel", "scroll scrollback (when mouse capture is on)"],
11062
+ null,
11063
+ ["^C", "cancel turn (twice to exit)"],
11064
+ ["Esc", "cancel turn and prefill draft"],
11065
+ ["^D", "exit (or delete-forward in prompt)"],
11066
+ ["^L", "force full redraw"],
11067
+ ["^G", "toggle this help"]
11068
+ ];
9961
11069
  logMaxBytes = 5 * 1024 * 1024;
9962
11070
  }
9963
11071
  });
@@ -10055,23 +11163,25 @@ function resolveOption(flags, key) {
10055
11163
  // src/cli/commands/init.ts
10056
11164
  init_paths();
10057
11165
  init_config();
10058
- import * as fs2 from "fs/promises";
11166
+ init_service_token();
11167
+ import * as fs3 from "fs/promises";
10059
11168
  async function runInit(flags) {
10060
- await fs2.mkdir(paths.home(), { recursive: true });
10061
- const existingToken = await loadAuthToken();
11169
+ await fs3.mkdir(paths.home(), { recursive: true });
11170
+ await migrateLegacyAuthToken();
11171
+ const existingToken = await readServiceToken();
10062
11172
  if (!existingToken) {
10063
- const token = generateAuthToken();
10064
- await writeAuthToken(token);
11173
+ const token = generateServiceToken();
11174
+ await writeServiceToken(token);
10065
11175
  process.stdout.write(
10066
11176
  `Initialized ${paths.authToken()}
10067
- Auth token: ${token}
11177
+ Service token: ${token}
10068
11178
  `
10069
11179
  );
10070
11180
  return;
10071
11181
  }
10072
11182
  if (flagBool(flags, "rotate-token")) {
10073
- const newToken = generateAuthToken();
10074
- await writeAuthToken(newToken);
11183
+ const newToken = generateServiceToken();
11184
+ await writeServiceToken(newToken);
10075
11185
  process.stdout.write(
10076
11186
  `Rotated token in ${paths.authToken()}
10077
11187
  New token: ${newToken}
@@ -10079,21 +11189,22 @@ New token: ${newToken}
10079
11189
  );
10080
11190
  return;
10081
11191
  }
10082
- process.stdout.write(`Auth token already exists at ${paths.authToken()}.
11192
+ process.stdout.write(`Service token already exists at ${paths.authToken()}.
10083
11193
  `);
10084
- process.stdout.write("Pass --rotate-token to generate a new auth token.\n");
11194
+ process.stdout.write("Pass --rotate-token to generate a new service token.\n");
10085
11195
  }
10086
11196
 
10087
11197
  // src/cli/commands/daemon.ts
10088
11198
  init_paths();
10089
11199
  init_config();
10090
- import * as fsp6 from "fs/promises";
11200
+ init_service_token();
11201
+ import * as fsp7 from "fs/promises";
10091
11202
  import { setTimeout as sleep2 } from "timers/promises";
10092
11203
 
10093
11204
  // src/daemon/server.ts
10094
11205
  init_config();
10095
- import * as fs11 from "fs";
10096
- import * as fsp4 from "fs/promises";
11206
+ import * as fs15 from "fs";
11207
+ import * as fsp5 from "fs/promises";
10097
11208
  import Fastify from "fastify";
10098
11209
  import websocketPlugin from "@fastify/websocket";
10099
11210
  import pino from "pino";
@@ -10101,12 +11212,12 @@ import createPinoRoll from "pino-roll";
10101
11212
 
10102
11213
  // src/core/registry.ts
10103
11214
  init_paths();
10104
- import * as fs4 from "fs/promises";
11215
+ import * as fs5 from "fs/promises";
10105
11216
  import { z as z2 } from "zod";
10106
11217
 
10107
11218
  // src/core/binary-install.ts
10108
11219
  init_paths();
10109
- import * as fs3 from "fs";
11220
+ import * as fs4 from "fs";
10110
11221
  import * as fsp from "fs/promises";
10111
11222
  import * as path2 from "path";
10112
11223
  import { spawn } from "child_process";
@@ -10211,7 +11322,7 @@ async function downloadTo(args) {
10211
11322
  );
10212
11323
  }
10213
11324
  const total = Number(response.headers.get("content-length") ?? "0");
10214
- const out = fs3.createWriteStream(dest);
11325
+ const out = fs4.createWriteStream(dest);
10215
11326
  const nodeStream = Readable.fromWeb(response.body);
10216
11327
  let received = 0;
10217
11328
  let lastEmit = Date.now();
@@ -10338,7 +11449,8 @@ async function ensureNpmPackage(args) {
10338
11449
  await installInto({
10339
11450
  agentId: args.agentId,
10340
11451
  packageSpec: args.packageSpec,
10341
- installDir
11452
+ installDir,
11453
+ registry: args.registry
10342
11454
  });
10343
11455
  if (!await fileExists2(binPath)) {
10344
11456
  throw new Error(
@@ -10356,7 +11468,8 @@ async function installInto(args) {
10356
11468
  );
10357
11469
  await runNpmInstall({
10358
11470
  packageSpec: args.packageSpec,
10359
- cwd: tempDir
11471
+ cwd: tempDir,
11472
+ registry: args.registry
10360
11473
  });
10361
11474
  try {
10362
11475
  await fsp2.rename(tempDir, args.installDir);
@@ -10380,9 +11493,10 @@ async function installInto(args) {
10380
11493
  }
10381
11494
  function runNpmInstall(args) {
10382
11495
  return new Promise((resolve5, reject) => {
11496
+ const registryArgs = args.registry ? ["--registry", args.registry] : [];
10383
11497
  const child = spawn2(
10384
11498
  "npm",
10385
- ["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
11499
+ ["install", "--no-audit", "--no-fund", "--silent", ...registryArgs, args.packageSpec],
10386
11500
  {
10387
11501
  cwd: args.cwd,
10388
11502
  stdio: ["ignore", "pipe", "pipe"]
@@ -10477,10 +11591,12 @@ var RegistryDocument = z2.object({
10477
11591
  extensions: z2.array(z2.unknown()).optional()
10478
11592
  });
10479
11593
  var Registry = class {
10480
- constructor(config) {
11594
+ constructor(config, options = {}) {
10481
11595
  this.config = config;
11596
+ this.options = options;
10482
11597
  }
10483
11598
  config;
11599
+ options;
10484
11600
  cache;
10485
11601
  async load() {
10486
11602
  if (this.cache && this.isFresh(this.cache.fetchedAt)) {
@@ -10530,12 +11646,17 @@ var Registry = class {
10530
11646
  }
10531
11647
  const raw = await response.json();
10532
11648
  const data = RegistryDocument.parse(raw);
10533
- return { fetchedAt: Date.now(), raw, data };
11649
+ const cached2 = { fetchedAt: Date.now(), raw, data };
11650
+ const hook = this.options.onFetched;
11651
+ if (hook) {
11652
+ void Promise.resolve().then(() => hook(data)).catch(() => void 0);
11653
+ }
11654
+ return cached2;
10534
11655
  }
10535
11656
  async readDiskCache() {
10536
11657
  let text;
10537
11658
  try {
10538
- text = await fs4.readFile(paths.registryCache(), "utf8");
11659
+ text = await fs5.readFile(paths.registryCache(), "utf8");
10539
11660
  } catch (err) {
10540
11661
  const e = err;
10541
11662
  if (e.code === "ENOENT") {
@@ -10561,7 +11682,7 @@ var Registry = class {
10561
11682
  // without a lock file: the loser of the rename race just gets its
10562
11683
  // version replaced by the winner's.
10563
11684
  async writeDiskCache(cache) {
10564
- await fs4.mkdir(paths.home(), { recursive: true });
11685
+ await fs5.mkdir(paths.home(), { recursive: true });
10565
11686
  const final = paths.registryCache();
10566
11687
  const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
10567
11688
  const body = JSON.stringify(
@@ -10570,10 +11691,10 @@ var Registry = class {
10570
11691
  2
10571
11692
  ) + "\n";
10572
11693
  try {
10573
- await fs4.writeFile(tmp, body, "utf8");
10574
- await fs4.rename(tmp, final);
11694
+ await fs5.writeFile(tmp, body, "utf8");
11695
+ await fs5.rename(tmp, final);
10575
11696
  } catch (err) {
10576
- await fs4.unlink(tmp).catch(() => void 0);
11697
+ await fs5.unlink(tmp).catch(() => void 0);
10577
11698
  throw err;
10578
11699
  }
10579
11700
  }
@@ -10591,7 +11712,8 @@ function npxPackageBasename(agent) {
10591
11712
  const atIdx = afterSlash.lastIndexOf("@");
10592
11713
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
10593
11714
  }
10594
- async function planSpawn(agent, callerArgs = []) {
11715
+ async function planSpawn(agent, callerArgs = [], options = {}) {
11716
+ const version = agent.version ?? "current";
10595
11717
  if (agent.distribution.npx) {
10596
11718
  const npx = agent.distribution.npx;
10597
11719
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
@@ -10599,20 +11721,23 @@ async function planSpawn(agent, callerArgs = []) {
10599
11721
  return {
10600
11722
  command: "npx",
10601
11723
  args: ["-y", npx.package, ...tail],
10602
- env: npx.env ?? {}
11724
+ env: npx.env ?? {},
11725
+ version
10603
11726
  };
10604
11727
  }
10605
11728
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
10606
11729
  const binPath = await ensureNpmPackage({
10607
11730
  agentId: agent.id,
10608
- version: agent.version ?? "current",
11731
+ version,
10609
11732
  packageSpec: npx.package,
10610
- bin
11733
+ bin,
11734
+ registry: options.npmRegistry
10611
11735
  });
10612
11736
  return {
10613
11737
  command: binPath,
10614
11738
  args: tail,
10615
- env: npx.env ?? {}
11739
+ env: npx.env ?? {},
11740
+ version
10616
11741
  };
10617
11742
  }
10618
11743
  if (agent.distribution.binary) {
@@ -10624,14 +11749,15 @@ async function planSpawn(agent, callerArgs = []) {
10624
11749
  }
10625
11750
  const cmdPath = await ensureBinary({
10626
11751
  agentId: agent.id,
10627
- version: agent.version ?? "current",
11752
+ version,
10628
11753
  target
10629
11754
  });
10630
11755
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
10631
11756
  return {
10632
11757
  command: cmdPath,
10633
11758
  args: tail,
10634
- env: target.env ?? {}
11759
+ env: target.env ?? {},
11760
+ version
10635
11761
  };
10636
11762
  }
10637
11763
  if (agent.distribution.uvx) {
@@ -10640,7 +11766,8 @@ async function planSpawn(agent, callerArgs = []) {
10640
11766
  return {
10641
11767
  command: "uvx",
10642
11768
  args: [uvx.package, ...tail],
10643
- env: uvx.env ?? {}
11769
+ env: uvx.env ?? {},
11770
+ version
10644
11771
  };
10645
11772
  }
10646
11773
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
@@ -10731,6 +11858,9 @@ init_connection();
10731
11858
  var DEFAULT_STDERR_TAIL_BYTES = 4096;
10732
11859
  var AgentInstance = class _AgentInstance {
10733
11860
  agentId;
11861
+ // Version this process was spawned from — used by the registry-fetch
11862
+ // prune sweep to skip install dirs belonging to a live agent.
11863
+ version;
10734
11864
  cwd;
10735
11865
  connection;
10736
11866
  child;
@@ -10742,6 +11872,7 @@ var AgentInstance = class _AgentInstance {
10742
11872
  exitHandlers = [];
10743
11873
  constructor(opts, child) {
10744
11874
  this.agentId = opts.agentId;
11875
+ this.version = opts.plan.version;
10745
11876
  this.cwd = opts.cwd;
10746
11877
  this.child = child;
10747
11878
  this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
@@ -10832,7 +11963,7 @@ stderr: ${tail}` : reason;
10832
11963
  };
10833
11964
 
10834
11965
  // src/core/session-manager.ts
10835
- import * as fs9 from "fs/promises";
11966
+ import * as fs11 from "fs/promises";
10836
11967
  import * as os2 from "os";
10837
11968
  import { customAlphabet as customAlphabet3 } from "nanoid";
10838
11969
  init_session();
@@ -10840,7 +11971,7 @@ init_session_store();
10840
11971
 
10841
11972
  // src/core/history-store.ts
10842
11973
  init_paths();
10843
- import * as fs6 from "fs/promises";
11974
+ import * as fs8 from "fs/promises";
10844
11975
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
10845
11976
  var DEFAULT_MAX_ENTRIES = 1e3;
10846
11977
  var HistoryStore = class {
@@ -10857,9 +11988,9 @@ var HistoryStore = class {
10857
11988
  return;
10858
11989
  }
10859
11990
  return this.enqueue(sessionId, async () => {
10860
- await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
11991
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
10861
11992
  const line = JSON.stringify(entry) + "\n";
10862
- await fs6.appendFile(paths.historyFile(sessionId), line, {
11993
+ await fs8.appendFile(paths.historyFile(sessionId), line, {
10863
11994
  encoding: "utf8",
10864
11995
  mode: 384
10865
11996
  });
@@ -10870,9 +12001,9 @@ var HistoryStore = class {
10870
12001
  return;
10871
12002
  }
10872
12003
  return this.enqueue(sessionId, async () => {
10873
- await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
12004
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
10874
12005
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
10875
- await fs6.writeFile(paths.historyFile(sessionId), body, {
12006
+ await fs8.writeFile(paths.historyFile(sessionId), body, {
10876
12007
  encoding: "utf8",
10877
12008
  mode: 384
10878
12009
  });
@@ -10889,7 +12020,7 @@ var HistoryStore = class {
10889
12020
  return this.enqueue(sessionId, async () => {
10890
12021
  let raw;
10891
12022
  try {
10892
- raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
12023
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
10893
12024
  } catch (err) {
10894
12025
  const e = err;
10895
12026
  if (e.code === "ENOENT") {
@@ -10902,7 +12033,7 @@ var HistoryStore = class {
10902
12033
  return;
10903
12034
  }
10904
12035
  const trimmed = lines.slice(-maxEntries);
10905
- await fs6.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
12036
+ await fs8.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
10906
12037
  encoding: "utf8",
10907
12038
  mode: 384
10908
12039
  });
@@ -10918,7 +12049,7 @@ var HistoryStore = class {
10918
12049
  }
10919
12050
  let raw;
10920
12051
  try {
10921
- raw = await fs6.readFile(paths.historyFile(sessionId), "utf8");
12052
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
10922
12053
  } catch (err) {
10923
12054
  const e = err;
10924
12055
  if (e.code === "ENOENT") {
@@ -10964,7 +12095,7 @@ var HistoryStore = class {
10964
12095
  }
10965
12096
  return this.enqueue(sessionId, async () => {
10966
12097
  try {
10967
- await fs6.unlink(paths.historyFile(sessionId));
12098
+ await fs8.unlink(paths.historyFile(sessionId));
10968
12099
  } catch (err) {
10969
12100
  const e = err;
10970
12101
  if (e.code !== "ENOENT") {
@@ -10972,7 +12103,7 @@ var HistoryStore = class {
10972
12103
  }
10973
12104
  }
10974
12105
  try {
10975
- await fs6.rmdir(paths.sessionDir(sessionId));
12106
+ await fs8.rmdir(paths.sessionDir(sessionId));
10976
12107
  } catch (err) {
10977
12108
  const e = err;
10978
12109
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -11000,6 +12131,8 @@ init_paths();
11000
12131
  init_history();
11001
12132
  init_types();
11002
12133
  init_hydra_version();
12134
+ init_queue_store();
12135
+ var QUEUE_REPLAY_TTL_MS = 15 * 60 * 1e3;
11003
12136
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
11004
12137
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
11005
12138
  var SessionManager = class {
@@ -11012,6 +12145,7 @@ var SessionManager = class {
11012
12145
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
11013
12146
  this.defaultModels = options.defaultModels ?? {};
11014
12147
  this.logger = options.logger;
12148
+ this.npmRegistry = options.npmRegistry;
11015
12149
  }
11016
12150
  registry;
11017
12151
  sessions = /* @__PURE__ */ new Map();
@@ -11027,6 +12161,7 @@ var SessionManager = class {
11027
12161
  // back-to-back) don't lose writes via interleaved reads.
11028
12162
  metaWriteQueues = /* @__PURE__ */ new Map();
11029
12163
  logger;
12164
+ npmRegistry;
11030
12165
  async create(params) {
11031
12166
  const fresh = await this.bootstrapAgent({
11032
12167
  agentId: params.agentId,
@@ -11048,7 +12183,9 @@ var SessionManager = class {
11048
12183
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
11049
12184
  historyStore: this.histories,
11050
12185
  historyMaxEntries: this.sessionHistoryMaxEntries,
11051
- currentModel: fresh.initialModel
12186
+ currentModel: fresh.initialModel,
12187
+ currentMode: fresh.initialMode,
12188
+ agentModes: fresh.initialModes
11052
12189
  });
11053
12190
  await this.attachManagerHooks(session);
11054
12191
  return session;
@@ -11093,7 +12230,7 @@ var SessionManager = class {
11093
12230
  if (params.upstreamSessionId === "") {
11094
12231
  return this.doResurrectFromImport(params);
11095
12232
  }
11096
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
12233
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
11097
12234
  const agent = this.spawner({
11098
12235
  agentId: params.agentId,
11099
12236
  cwd: params.cwd,
@@ -11147,9 +12284,10 @@ var SessionManager = class {
11147
12284
  // this fix), fall back to the model the agent ships in its
11148
12285
  // session/load response body.
11149
12286
  currentModel: params.currentModel ?? extractInitialModel(loadResult ?? {}),
11150
- currentMode: params.currentMode,
12287
+ currentMode: params.currentMode ?? extractInitialCurrentMode(loadResult ?? {}),
11151
12288
  currentUsage: params.currentUsage,
11152
12289
  agentCommands: params.agentCommands,
12290
+ agentModes: params.agentModes ?? nonEmptyOrUndefined(extractInitialModes(loadResult ?? {})),
11153
12291
  // Only gate the first-prompt title heuristic when we actually have
11154
12292
  // a title to preserve. A title-less session (lost to a write race
11155
12293
  // or never seeded) should re-derive from the next prompt rather
@@ -11192,9 +12330,10 @@ var SessionManager = class {
11192
12330
  // Prefer the stored value (set by a previous current_model_update);
11193
12331
  // fall back to whatever the agent ships in its session/new response.
11194
12332
  currentModel: params.currentModel ?? fresh.initialModel,
11195
- currentMode: params.currentMode,
12333
+ currentMode: params.currentMode ?? fresh.initialMode,
11196
12334
  currentUsage: params.currentUsage,
11197
12335
  agentCommands: params.agentCommands,
12336
+ agentModes: params.agentModes ?? fresh.initialModes,
11198
12337
  firstPromptSeeded: !!params.title,
11199
12338
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0
11200
12339
  });
@@ -11204,7 +12343,7 @@ var SessionManager = class {
11204
12343
  }
11205
12344
  async resolveImportCwd(cwd) {
11206
12345
  try {
11207
- const stat4 = await fs9.stat(cwd);
12346
+ const stat4 = await fs11.stat(cwd);
11208
12347
  if (stat4.isDirectory()) {
11209
12348
  return cwd;
11210
12349
  }
@@ -11224,7 +12363,7 @@ var SessionManager = class {
11224
12363
  err.code = JsonRpcErrorCodes.AgentNotInstalled;
11225
12364
  throw err;
11226
12365
  }
11227
- const plan = await planSpawn(agentDef, params.agentArgs ?? []);
12366
+ const plan = await planSpawn(agentDef, params.agentArgs ?? [], { npmRegistry: this.npmRegistry });
11228
12367
  const agent = this.spawner({
11229
12368
  agentId: params.agentId,
11230
12369
  cwd: params.cwd,
@@ -11261,11 +12400,15 @@ var SessionManager = class {
11261
12400
  } catch {
11262
12401
  }
11263
12402
  }
12403
+ const initialModes = extractInitialModes(newResult);
12404
+ const initialMode = extractInitialCurrentMode(newResult);
11264
12405
  return {
11265
12406
  agent,
11266
12407
  upstreamSessionId: sessionIdRaw,
11267
12408
  agentMeta: newResult._meta,
11268
- initialModel
12409
+ initialModel,
12410
+ initialModes: initialModes.length > 0 ? initialModes : void 0,
12411
+ initialMode
11269
12412
  };
11270
12413
  } catch (err) {
11271
12414
  await agent.kill().catch(() => void 0);
@@ -11317,6 +12460,15 @@ var SessionManager = class {
11317
12460
  }))
11318
12461
  }).catch(() => void 0);
11319
12462
  });
12463
+ session.onAgentModesChange((modes) => {
12464
+ void this.persistSnapshot(session.sessionId, {
12465
+ agentModes: modes.map((m) => ({
12466
+ id: m.id,
12467
+ ...m.name !== void 0 ? { name: m.name } : {},
12468
+ ...m.description !== void 0 ? { description: m.description } : {}
12469
+ }))
12470
+ }).catch(() => void 0);
12471
+ });
11320
12472
  this.sessions.set(session.sessionId, session);
11321
12473
  await this.enqueueMetaWrite(session.sessionId, async () => {
11322
12474
  const existing = await this.store.read(session.sessionId);
@@ -11359,6 +12511,7 @@ var SessionManager = class {
11359
12511
  currentMode: record.currentMode,
11360
12512
  currentUsage: persistedUsageToSnapshot(record.currentUsage),
11361
12513
  agentCommands: record.agentCommands,
12514
+ agentModes: record.agentModes,
11362
12515
  createdAt: record.createdAt
11363
12516
  };
11364
12517
  }
@@ -11383,6 +12536,23 @@ var SessionManager = class {
11383
12536
  get(sessionId) {
11384
12537
  return this.sessions.get(sessionId);
11385
12538
  }
12539
+ // Snapshot of which agent versions are currently in use by live
12540
+ // sessions, keyed by agentId. Read by the registry-fetch prune sweep
12541
+ // so it can skip install dirs that still back a running process.
12542
+ activeAgentVersions() {
12543
+ const out = /* @__PURE__ */ new Map();
12544
+ for (const session of this.sessions.values()) {
12545
+ const id = session.agent.agentId;
12546
+ const version = session.agent.version;
12547
+ let set = out.get(id);
12548
+ if (!set) {
12549
+ set = /* @__PURE__ */ new Set();
12550
+ out.set(id, set);
12551
+ }
12552
+ set.add(version);
12553
+ }
12554
+ return out;
12555
+ }
11386
12556
  // Resolve a user-typed session id (which may have the hydra_session_
11387
12557
  // prefix stripped — that's what `sessions list` and the picker show) to
11388
12558
  // the canonical form that actually exists. Tries the input as-given
@@ -11636,6 +12806,7 @@ var SessionManager = class {
11636
12806
  ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
11637
12807
  ...update.currentUsage !== void 0 ? { currentUsage: update.currentUsage } : {},
11638
12808
  ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
12809
+ ...update.agentModes !== void 0 ? { agentModes: update.agentModes } : {},
11639
12810
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
11640
12811
  });
11641
12812
  });
@@ -11669,6 +12840,53 @@ var SessionManager = class {
11669
12840
  }
11670
12841
  await Promise.allSettled(pending);
11671
12842
  }
12843
+ // Startup hook: scan persisted sessions for non-empty queue files,
12844
+ // apply the TTL, resurrect anything with surviving entries, and
12845
+ // replay them through the normal queue path. Called from the daemon
12846
+ // boot sequence; failures per session are logged and don't block
12847
+ // the boot.
12848
+ //
12849
+ // Concurrency is deliberately sequential — resurrect each session
12850
+ // one at a time so a runaway daemon with 100 queued sessions
12851
+ // doesn't burst-spawn 100 agents on startup. Inside a single
12852
+ // session, the queue still drains in parallel-friendly fashion via
12853
+ // drainQueue once resurrect() completes.
12854
+ async resurrectPendingQueues() {
12855
+ const records = await this.store.list().catch(() => []);
12856
+ for (const rec of records) {
12857
+ const queue = await loadQueue(rec.sessionId).catch(() => []);
12858
+ if (queue.length === 0) continue;
12859
+ const now = Date.now();
12860
+ const fresh = queue.filter((e) => now - e.enqueuedAt < QUEUE_REPLAY_TTL_MS);
12861
+ const dropped = queue.length - fresh.length;
12862
+ if (dropped > 0) {
12863
+ this.logger?.info(
12864
+ `queue replay: dropping ${dropped} stale prompt(s) for ${rec.sessionId} (TTL ${QUEUE_REPLAY_TTL_MS / 1e3}s)`
12865
+ );
12866
+ await rewriteQueue(rec.sessionId, fresh).catch(() => void 0);
12867
+ }
12868
+ if (fresh.length === 0) continue;
12869
+ const fromDisk = await this.loadFromDisk(rec.sessionId).catch(() => void 0);
12870
+ if (!fromDisk) {
12871
+ this.logger?.warn(
12872
+ `queue replay: no meta for ${rec.sessionId}; discarding ${fresh.length} entr${fresh.length === 1 ? "y" : "ies"}`
12873
+ );
12874
+ await rewriteQueue(rec.sessionId, []).catch(() => void 0);
12875
+ continue;
12876
+ }
12877
+ try {
12878
+ const session = await this.resurrect(fromDisk);
12879
+ this.logger?.info(
12880
+ `queue replay: resurrected ${rec.sessionId} and replaying ${fresh.length} prompt(s)`
12881
+ );
12882
+ session.replayPersistedQueue(fresh);
12883
+ } catch (err) {
12884
+ this.logger?.warn(
12885
+ `queue replay: failed to resurrect ${rec.sessionId}: ${err.message}`
12886
+ );
12887
+ }
12888
+ }
12889
+ }
11672
12890
  };
11673
12891
  function mergeForPersistence(session, existing) {
11674
12892
  const persistedCommands = session.mergedAvailableCommands().length > 0 ? session.agentOnlyAdvertisedCommands().map((c) => {
@@ -11678,6 +12896,18 @@ function mergeForPersistence(session, existing) {
11678
12896
  return { name: c.name };
11679
12897
  }) : void 0;
11680
12898
  const agentCommands = persistedCommands ?? existing?.agentCommands;
12899
+ const sessionModes = session.availableModes();
12900
+ const persistedModes = sessionModes.length > 0 ? sessionModes.map((m) => {
12901
+ const out = { id: m.id };
12902
+ if (m.name !== void 0) {
12903
+ out.name = m.name;
12904
+ }
12905
+ if (m.description !== void 0) {
12906
+ out.description = m.description;
12907
+ }
12908
+ return out;
12909
+ }) : void 0;
12910
+ const agentModes = persistedModes ?? existing?.agentModes;
11681
12911
  return recordFromMemorySession({
11682
12912
  sessionId: session.sessionId,
11683
12913
  lineageId: existing?.lineageId ?? generateLineageId(),
@@ -11693,6 +12923,7 @@ function mergeForPersistence(session, existing) {
11693
12923
  currentMode: session.currentMode ?? existing?.currentMode,
11694
12924
  currentUsage: usageSnapshotToPersisted(session.currentUsage) ?? existing?.currentUsage,
11695
12925
  agentCommands,
12926
+ agentModes,
11696
12927
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
11697
12928
  });
11698
12929
  }
@@ -11755,9 +12986,103 @@ function asString(value) {
11755
12986
  const trimmed = value.trim();
11756
12987
  return trimmed.length > 0 ? trimmed : void 0;
11757
12988
  }
12989
+ function nonEmptyOrUndefined(arr) {
12990
+ return arr.length > 0 ? arr : void 0;
12991
+ }
12992
+ function extractInitialModes(result) {
12993
+ const direct = parseModesList(result.availableModes);
12994
+ if (direct.length > 0) {
12995
+ return direct;
12996
+ }
12997
+ const modes = result.modes;
12998
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
12999
+ const fromModesObj = parseModesList(
13000
+ modes.availableModes
13001
+ );
13002
+ if (fromModesObj.length > 0) {
13003
+ return fromModesObj;
13004
+ }
13005
+ }
13006
+ const meta = result._meta;
13007
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
13008
+ for (const [key, value] of Object.entries(
13009
+ meta
13010
+ )) {
13011
+ if (key === "hydra-acp") {
13012
+ continue;
13013
+ }
13014
+ if (value && typeof value === "object" && !Array.isArray(value)) {
13015
+ const fromMeta = parseModesList(
13016
+ value.availableModes
13017
+ );
13018
+ if (fromMeta.length > 0) {
13019
+ return fromMeta;
13020
+ }
13021
+ }
13022
+ }
13023
+ }
13024
+ return [];
13025
+ }
13026
+ function extractInitialCurrentMode(result) {
13027
+ const direct = asString(result.currentModeId) ?? asString(result.currentMode) ?? asString(result.modeId) ?? asString(result.mode);
13028
+ if (direct) {
13029
+ return direct;
13030
+ }
13031
+ const modes = result.modes;
13032
+ if (modes && typeof modes === "object" && !Array.isArray(modes)) {
13033
+ const m = asString(modes.currentModeId) ?? asString(modes.currentMode);
13034
+ if (m) {
13035
+ return m;
13036
+ }
13037
+ }
13038
+ const meta = result._meta;
13039
+ if (meta && typeof meta === "object" && !Array.isArray(meta)) {
13040
+ for (const [key, value] of Object.entries(
13041
+ meta
13042
+ )) {
13043
+ if (key === "hydra-acp") {
13044
+ continue;
13045
+ }
13046
+ if (value && typeof value === "object" && !Array.isArray(value)) {
13047
+ const m = asString(value.currentModeId) ?? asString(value.currentMode) ?? asString(value.modeId);
13048
+ if (m) {
13049
+ return m;
13050
+ }
13051
+ }
13052
+ }
13053
+ }
13054
+ return void 0;
13055
+ }
13056
+ function parseModesList(list) {
13057
+ if (!Array.isArray(list)) {
13058
+ return [];
13059
+ }
13060
+ const out = [];
13061
+ for (const raw of list) {
13062
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
13063
+ continue;
13064
+ }
13065
+ const r = raw;
13066
+ const id = asString(r.id) ?? asString(r.modeId);
13067
+ if (!id) {
13068
+ continue;
13069
+ }
13070
+ const mode = { id };
13071
+ const name = asString(r.name);
13072
+ if (name) {
13073
+ mode.name = name;
13074
+ }
13075
+ const description = asString(r.description);
13076
+ if (description) {
13077
+ mode.description = description;
13078
+ }
13079
+ out.push(mode);
13080
+ }
13081
+ return out;
13082
+ }
11758
13083
  async function loadPromptHistorySafely(sessionId) {
11759
13084
  try {
11760
- const raw = await fs9.readFile(paths.tuiHistoryFile(sessionId), "utf8");
13085
+ const raw = await fs11.readFile(paths.tuiHistoryFile(sessionId), "utf8");
11761
13086
  const out = [];
11762
13087
  for (const line of raw.split("\n")) {
11763
13088
  if (line.length === 0) {
@@ -11778,7 +13103,7 @@ async function loadPromptHistorySafely(sessionId) {
11778
13103
  }
11779
13104
  async function historyMtimeIso(sessionId) {
11780
13105
  try {
11781
- const st = await fs9.stat(paths.historyFile(sessionId));
13106
+ const st = await fs11.stat(paths.historyFile(sessionId));
11782
13107
  return new Date(st.mtimeMs).toISOString();
11783
13108
  } catch {
11784
13109
  return void 0;
@@ -11788,7 +13113,7 @@ async function historyMtimeIso(sessionId) {
11788
13113
  // src/core/extensions.ts
11789
13114
  init_paths();
11790
13115
  import { spawn as spawn4 } from "child_process";
11791
- import * as fs10 from "fs";
13116
+ import * as fs12 from "fs";
11792
13117
  import * as fsp3 from "fs/promises";
11793
13118
  import * as path7 from "path";
11794
13119
  var RESTART_BASE_MS = 1e3;
@@ -12071,7 +13396,7 @@ var ExtensionManager = class {
12071
13396
  }
12072
13397
  const ext = entry.config;
12073
13398
  const command = ext.command.length > 0 ? ext.command : [ext.name];
12074
- const logStream = fs10.createWriteStream(paths.extensionLogFile(ext.name), {
13399
+ const logStream = fs12.createWriteStream(paths.extensionLogFile(ext.name), {
12075
13400
  flags: "a"
12076
13401
  });
12077
13402
  logStream.write(
@@ -12083,7 +13408,7 @@ var ExtensionManager = class {
12083
13408
  HYDRA_ACP_DAEMON_URL: ctx.daemonUrl,
12084
13409
  HYDRA_ACP_DAEMON_HOST: ctx.daemonHost,
12085
13410
  HYDRA_ACP_DAEMON_PORT: String(ctx.daemonPort),
12086
- HYDRA_ACP_TOKEN: ctx.daemonToken,
13411
+ HYDRA_ACP_TOKEN: ctx.serviceToken,
12087
13412
  HYDRA_ACP_WS_URL: ctx.daemonWsUrl,
12088
13413
  HYDRA_ACP_HOME: ctx.hydraHome,
12089
13414
  HYDRA_ACP_EXTENSION_NAME: ext.name,
@@ -12121,7 +13446,7 @@ var ExtensionManager = class {
12121
13446
  }
12122
13447
  if (typeof child.pid === "number") {
12123
13448
  try {
12124
- fs10.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
13449
+ fs12.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
12125
13450
  `, {
12126
13451
  encoding: "utf8",
12127
13452
  mode: 384
@@ -12146,7 +13471,7 @@ var ExtensionManager = class {
12146
13471
  });
12147
13472
  child.on("exit", (code, signal) => {
12148
13473
  try {
12149
- fs10.unlinkSync(paths.extensionPidFile(ext.name));
13474
+ fs12.unlinkSync(paths.extensionPidFile(ext.name));
12150
13475
  } catch {
12151
13476
  }
12152
13477
  logStream.write(
@@ -12189,25 +13514,324 @@ var ExtensionManager = class {
12189
13514
  }
12190
13515
  }
12191
13516
  };
12192
- function isAlive(pid) {
12193
- try {
12194
- process.kill(pid, 0);
12195
- return true;
12196
- } catch {
13517
+ function isAlive(pid) {
13518
+ try {
13519
+ process.kill(pid, 0);
13520
+ return true;
13521
+ } catch {
13522
+ return false;
13523
+ }
13524
+ }
13525
+ function withCode2(err, code) {
13526
+ err.code = code;
13527
+ return err;
13528
+ }
13529
+
13530
+ // src/daemon/server.ts
13531
+ init_paths();
13532
+
13533
+ // src/core/agent-prune.ts
13534
+ init_paths();
13535
+ import * as fsp4 from "fs/promises";
13536
+ import * as path8 from "path";
13537
+ var logSink3 = (msg) => {
13538
+ process.stderr.write(msg + "\n");
13539
+ };
13540
+ function setAgentPruneLogger(log) {
13541
+ logSink3 = log ?? ((msg) => process.stderr.write(msg + "\n"));
13542
+ }
13543
+ async function pruneStaleAgentVersions(registry, sessionManager) {
13544
+ const platformKey = currentPlatformKey();
13545
+ if (!platformKey) {
13546
+ return;
13547
+ }
13548
+ const doc = await registry.load();
13549
+ const desiredByAgent = /* @__PURE__ */ new Map();
13550
+ for (const a of doc.agents) {
13551
+ desiredByAgent.set(a.id, a.version ?? "current");
13552
+ }
13553
+ const activeByAgent = sessionManager.activeAgentVersions();
13554
+ const platformDir = path8.join(paths.agentsDir(), platformKey);
13555
+ let agentEntries;
13556
+ try {
13557
+ agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
13558
+ } catch (err) {
13559
+ const e = err;
13560
+ if (e.code === "ENOENT") {
13561
+ return;
13562
+ }
13563
+ logSink3(`hydra-acp: prune: failed to read ${platformDir}: ${e.message}`);
13564
+ return;
13565
+ }
13566
+ for (const agentEntry of agentEntries) {
13567
+ if (!agentEntry.isDirectory()) {
13568
+ continue;
13569
+ }
13570
+ const agentId = agentEntry.name;
13571
+ const desired = desiredByAgent.get(agentId);
13572
+ if (desired === void 0) {
13573
+ continue;
13574
+ }
13575
+ const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
13576
+ const agentDir = path8.join(platformDir, agentId);
13577
+ let versionEntries;
13578
+ try {
13579
+ versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
13580
+ } catch (err) {
13581
+ logSink3(
13582
+ `hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
13583
+ );
13584
+ continue;
13585
+ }
13586
+ for (const versionEntry of versionEntries) {
13587
+ if (!versionEntry.isDirectory()) {
13588
+ continue;
13589
+ }
13590
+ const version = versionEntry.name;
13591
+ if (version === desired) {
13592
+ continue;
13593
+ }
13594
+ if (activeVersions.has(version)) {
13595
+ continue;
13596
+ }
13597
+ const versionDir = path8.join(agentDir, version);
13598
+ try {
13599
+ await fsp4.rm(versionDir, { recursive: true, force: true });
13600
+ logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
13601
+ } catch (err) {
13602
+ logSink3(
13603
+ `hydra-acp: prune: failed to remove ${versionDir}: ${err.message}`
13604
+ );
13605
+ }
13606
+ }
13607
+ }
13608
+ }
13609
+
13610
+ // src/daemon/server.ts
13611
+ init_hydra_version();
13612
+
13613
+ // src/core/session-tokens.ts
13614
+ init_paths();
13615
+ import * as fs13 from "fs/promises";
13616
+ import * as path9 from "path";
13617
+ import { createHash, randomBytes, timingSafeEqual } from "crypto";
13618
+ var TOKEN_PREFIX = "hydra_session_";
13619
+ var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
13620
+ var ID_LENGTH = 12;
13621
+ var TOKEN_BYTES = 32;
13622
+ var WRITE_DEBOUNCE_MS = 50;
13623
+ function tokensFilePath() {
13624
+ return path9.join(paths.home(), "session-tokens.json");
13625
+ }
13626
+ function sha256Hex(input) {
13627
+ return createHash("sha256").update(input).digest("hex");
13628
+ }
13629
+ function randomHex(bytes) {
13630
+ return randomBytes(bytes).toString("hex");
13631
+ }
13632
+ function generateId() {
13633
+ return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
13634
+ }
13635
+ function generateToken() {
13636
+ return `${TOKEN_PREFIX}${randomHex(TOKEN_BYTES)}`;
13637
+ }
13638
+ var SessionTokenStore = class _SessionTokenStore {
13639
+ records = /* @__PURE__ */ new Map();
13640
+ // keyed by hash
13641
+ writeTimer = null;
13642
+ writeInflight = null;
13643
+ constructor(records) {
13644
+ for (const r of records) {
13645
+ this.records.set(r.hash, r);
13646
+ }
13647
+ }
13648
+ static async load() {
13649
+ let records = [];
13650
+ try {
13651
+ const raw = await fs13.readFile(tokensFilePath(), "utf8");
13652
+ const parsed = JSON.parse(raw);
13653
+ if (parsed && Array.isArray(parsed.records)) {
13654
+ records = parsed.records.filter(isRecord);
13655
+ }
13656
+ } catch (err) {
13657
+ const e = err;
13658
+ if (e.code !== "ENOENT") {
13659
+ throw err;
13660
+ }
13661
+ }
13662
+ const store = new _SessionTokenStore(records);
13663
+ const removed = store.sweepExpired(/* @__PURE__ */ new Date());
13664
+ if (removed > 0) {
13665
+ await store.flush();
13666
+ }
13667
+ return store;
13668
+ }
13669
+ async issue(opts = {}) {
13670
+ const token = generateToken();
13671
+ const hash = sha256Hex(token);
13672
+ const id = generateId();
13673
+ const now = /* @__PURE__ */ new Date();
13674
+ const ttlSec = opts.ttlSec && opts.ttlSec > 0 ? opts.ttlSec : DEFAULT_TTL_SEC;
13675
+ const expiresAt = new Date(now.getTime() + ttlSec * 1e3);
13676
+ const record = {
13677
+ id,
13678
+ hash,
13679
+ label: opts.label,
13680
+ createdAt: now.toISOString(),
13681
+ expiresAt: expiresAt.toISOString(),
13682
+ lastUsedAt: now.toISOString()
13683
+ };
13684
+ this.records.set(hash, record);
13685
+ this.scheduleWrite();
13686
+ return { id, token, expiresAt: record.expiresAt };
13687
+ }
13688
+ // Verifies a presented token. Returns the matching record id (so the
13689
+ // caller can revoke it on logout) and bumps lastUsedAt; returns
13690
+ // undefined when no record matches or when the matched record has
13691
+ // expired.
13692
+ async verify(token) {
13693
+ if (typeof token !== "string" || !token.startsWith(TOKEN_PREFIX)) {
13694
+ return void 0;
13695
+ }
13696
+ const hash = sha256Hex(token);
13697
+ const record = this.records.get(hash);
13698
+ if (!record) {
13699
+ return void 0;
13700
+ }
13701
+ const expected = Buffer.from(record.hash, "hex");
13702
+ const actual = Buffer.from(hash, "hex");
13703
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
13704
+ return void 0;
13705
+ }
13706
+ const now = /* @__PURE__ */ new Date();
13707
+ if (new Date(record.expiresAt).getTime() <= now.getTime()) {
13708
+ this.records.delete(hash);
13709
+ this.scheduleWrite();
13710
+ return void 0;
13711
+ }
13712
+ record.lastUsedAt = now.toISOString();
13713
+ this.scheduleWrite();
13714
+ return record.id;
13715
+ }
13716
+ async revoke(id) {
13717
+ for (const [hash, r] of this.records) {
13718
+ if (r.id === id) {
13719
+ this.records.delete(hash);
13720
+ this.scheduleWrite();
13721
+ return true;
13722
+ }
13723
+ }
13724
+ return false;
13725
+ }
13726
+ async revokeAll() {
13727
+ const n = this.records.size;
13728
+ this.records.clear();
13729
+ this.scheduleWrite();
13730
+ return n;
13731
+ }
13732
+ list() {
13733
+ return Array.from(this.records.values()).map(({ id, label, createdAt, expiresAt, lastUsedAt }) => ({
13734
+ id,
13735
+ label,
13736
+ createdAt,
13737
+ expiresAt,
13738
+ lastUsedAt
13739
+ })).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
13740
+ }
13741
+ sweepExpired(now = /* @__PURE__ */ new Date()) {
13742
+ let removed = 0;
13743
+ for (const [hash, r] of this.records) {
13744
+ if (new Date(r.expiresAt).getTime() <= now.getTime()) {
13745
+ this.records.delete(hash);
13746
+ removed += 1;
13747
+ }
13748
+ }
13749
+ if (removed > 0) {
13750
+ this.scheduleWrite();
13751
+ }
13752
+ return removed;
13753
+ }
13754
+ // Force any pending write to complete. Useful in tests and at shutdown.
13755
+ async flush() {
13756
+ if (this.writeTimer) {
13757
+ clearTimeout(this.writeTimer);
13758
+ this.writeTimer = null;
13759
+ }
13760
+ await this.persist();
13761
+ }
13762
+ scheduleWrite() {
13763
+ if (this.writeTimer) {
13764
+ return;
13765
+ }
13766
+ this.writeTimer = setTimeout(() => {
13767
+ this.writeTimer = null;
13768
+ this.persist().catch(() => {
13769
+ });
13770
+ }, WRITE_DEBOUNCE_MS);
13771
+ }
13772
+ async persist() {
13773
+ if (this.writeInflight) {
13774
+ await this.writeInflight;
13775
+ }
13776
+ const records = Array.from(this.records.values());
13777
+ const payload = JSON.stringify({ records }, null, 2) + "\n";
13778
+ this.writeInflight = (async () => {
13779
+ await fs13.mkdir(paths.home(), { recursive: true });
13780
+ await fs13.writeFile(tokensFilePath(), payload, {
13781
+ encoding: "utf8",
13782
+ mode: 384
13783
+ });
13784
+ })();
13785
+ try {
13786
+ await this.writeInflight;
13787
+ } finally {
13788
+ this.writeInflight = null;
13789
+ }
13790
+ }
13791
+ };
13792
+ function isRecord(value) {
13793
+ if (!value || typeof value !== "object") {
12197
13794
  return false;
12198
13795
  }
13796
+ const v = value;
13797
+ 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");
12199
13798
  }
12200
- function withCode2(err, code) {
12201
- err.code = code;
12202
- return err;
12203
- }
12204
-
12205
- // src/daemon/server.ts
12206
- init_paths();
12207
- init_hydra_version();
12208
13799
 
12209
13800
  // src/daemon/auth.ts
12210
13801
  var BEARER_PREFIX = "Bearer ";
13802
+ var StaticTokenValidator = class {
13803
+ constructor(token) {
13804
+ this.token = token;
13805
+ }
13806
+ token;
13807
+ async validate(token) {
13808
+ return constantTimeEqual(token, this.token) ? "service" : void 0;
13809
+ }
13810
+ };
13811
+ var SessionTokenValidator = class {
13812
+ constructor(store) {
13813
+ this.store = store;
13814
+ }
13815
+ store;
13816
+ async validate(token) {
13817
+ return this.store.verify(token);
13818
+ }
13819
+ };
13820
+ var CompositeTokenValidator = class {
13821
+ constructor(validators) {
13822
+ this.validators = validators;
13823
+ }
13824
+ validators;
13825
+ async validate(token) {
13826
+ for (const v of this.validators) {
13827
+ const id = await v.validate(token);
13828
+ if (id !== void 0) {
13829
+ return id;
13830
+ }
13831
+ }
13832
+ return void 0;
13833
+ }
13834
+ };
12211
13835
  function bearerAuth(opts) {
12212
13836
  return async function authMiddleware(request, reply) {
12213
13837
  const header = request.headers.authorization;
@@ -12216,10 +13840,12 @@ function bearerAuth(opts) {
12216
13840
  return;
12217
13841
  }
12218
13842
  const token = header.slice(BEARER_PREFIX.length).trim();
12219
- if (!constantTimeEqual(token, opts.config.daemon.authToken)) {
13843
+ const identity = await opts.validator.validate(token);
13844
+ if (!identity) {
12220
13845
  reply.code(403).send({ error: "Invalid token" });
12221
13846
  return;
12222
13847
  }
13848
+ request.authIdentity = identity;
12223
13849
  };
12224
13850
  }
12225
13851
  function tokenFromUpgradeRequest(req) {
@@ -12258,6 +13884,40 @@ function constantTimeEqual(a, b) {
12258
13884
  return mismatch === 0;
12259
13885
  }
12260
13886
 
13887
+ // src/daemon/rate-limit.ts
13888
+ var AuthRateLimiter = class {
13889
+ entries = /* @__PURE__ */ new Map();
13890
+ maxFails;
13891
+ windowMs;
13892
+ constructor(maxFails = 10, windowMs = 15 * 60 * 1e3) {
13893
+ this.maxFails = maxFails;
13894
+ this.windowMs = windowMs;
13895
+ }
13896
+ isBlocked(ip) {
13897
+ const e = this.entries.get(ip);
13898
+ if (!e) {
13899
+ return false;
13900
+ }
13901
+ if (Date.now() - e.windowStart > this.windowMs) {
13902
+ this.entries.delete(ip);
13903
+ return false;
13904
+ }
13905
+ return e.fails >= this.maxFails;
13906
+ }
13907
+ recordFailure(ip) {
13908
+ const now = Date.now();
13909
+ const e = this.entries.get(ip);
13910
+ if (!e || now - e.windowStart > this.windowMs) {
13911
+ this.entries.set(ip, { fails: 1, windowStart: now });
13912
+ return;
13913
+ }
13914
+ e.fails += 1;
13915
+ }
13916
+ recordSuccess(ip) {
13917
+ this.entries.delete(ip);
13918
+ }
13919
+ };
13920
+
12261
13921
  // src/daemon/routes/sessions.ts
12262
13922
  init_config();
12263
13923
  init_bundle();
@@ -12625,6 +14285,181 @@ function registerConfigRoutes(app, defaults) {
12625
14285
  });
12626
14286
  }
12627
14287
 
14288
+ // src/daemon/routes/auth.ts
14289
+ import { z as z6 } from "zod";
14290
+
14291
+ // src/core/password.ts
14292
+ init_paths();
14293
+ import * as fs14 from "fs/promises";
14294
+ import * as path10 from "path";
14295
+ import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
14296
+ import { promisify } from "util";
14297
+ var scryptAsync = promisify(scrypt);
14298
+ function passwordHashPath() {
14299
+ return path10.join(paths.home(), "password-hash");
14300
+ }
14301
+ var DEFAULT_N = 1 << 15;
14302
+ var DEFAULT_R = 8;
14303
+ var DEFAULT_P = 1;
14304
+ var KEY_LEN = 64;
14305
+ var SALT_LEN = 16;
14306
+ var MAX_MEM = 128 * 1024 * 1024;
14307
+ async function setPassword(plaintext) {
14308
+ if (typeof plaintext !== "string" || plaintext.length === 0) {
14309
+ throw new Error("password must be a non-empty string");
14310
+ }
14311
+ const salt = randomBytes2(SALT_LEN);
14312
+ const key = await scryptAsync(plaintext, salt, KEY_LEN, {
14313
+ N: DEFAULT_N,
14314
+ r: DEFAULT_R,
14315
+ p: DEFAULT_P,
14316
+ maxmem: MAX_MEM
14317
+ });
14318
+ const encoded = `scrypt$${DEFAULT_N}$${DEFAULT_R}$${DEFAULT_P}$${salt.toString("hex")}$${key.toString("hex")}
14319
+ `;
14320
+ await fs14.mkdir(paths.home(), { recursive: true });
14321
+ await fs14.writeFile(passwordHashPath(), encoded, {
14322
+ encoding: "utf8",
14323
+ mode: 384
14324
+ });
14325
+ }
14326
+ async function hasPassword() {
14327
+ try {
14328
+ const text = await fs14.readFile(passwordHashPath(), "utf8");
14329
+ return text.trim().length > 0;
14330
+ } catch (err) {
14331
+ const e = err;
14332
+ if (e.code === "ENOENT") {
14333
+ return false;
14334
+ }
14335
+ throw err;
14336
+ }
14337
+ }
14338
+ async function verifyPassword(plaintext) {
14339
+ if (typeof plaintext !== "string" || plaintext.length === 0) {
14340
+ return false;
14341
+ }
14342
+ let line;
14343
+ try {
14344
+ line = (await fs14.readFile(passwordHashPath(), "utf8")).trim();
14345
+ } catch (err) {
14346
+ const e = err;
14347
+ if (e.code === "ENOENT") {
14348
+ return false;
14349
+ }
14350
+ throw err;
14351
+ }
14352
+ const parts = line.split("$");
14353
+ if (parts.length !== 6 || parts[0] !== "scrypt") {
14354
+ return false;
14355
+ }
14356
+ const N = parseInt(parts[1], 10);
14357
+ const r = parseInt(parts[2], 10);
14358
+ const p = parseInt(parts[3], 10);
14359
+ if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) {
14360
+ return false;
14361
+ }
14362
+ const salt = Buffer.from(parts[4], "hex");
14363
+ const expected = Buffer.from(parts[5], "hex");
14364
+ if (salt.length === 0 || expected.length === 0) {
14365
+ return false;
14366
+ }
14367
+ const actual = await scryptAsync(plaintext, salt, expected.length, {
14368
+ N,
14369
+ r,
14370
+ p,
14371
+ maxmem: MAX_MEM
14372
+ });
14373
+ if (actual.length !== expected.length) {
14374
+ return false;
14375
+ }
14376
+ return timingSafeEqual2(actual, expected);
14377
+ }
14378
+
14379
+ // src/daemon/routes/auth.ts
14380
+ var LoginBody = z6.object({
14381
+ password: z6.string().min(1),
14382
+ label: z6.string().min(1).max(256).optional(),
14383
+ ttlSec: z6.number().int().positive().optional()
14384
+ });
14385
+ var LogoutBody = z6.object({
14386
+ id: z6.string().optional()
14387
+ }).optional();
14388
+ function registerAuthRoutes(app, deps) {
14389
+ app.post(
14390
+ "/v1/auth/login",
14391
+ { config: { skipAuth: true } },
14392
+ async (request, reply) => {
14393
+ const ip = remoteIp(request);
14394
+ if (deps.rateLimiter.isBlocked(ip)) {
14395
+ return reply.code(429).send({
14396
+ error: "Too many failed attempts; try again later."
14397
+ });
14398
+ }
14399
+ let body;
14400
+ try {
14401
+ body = LoginBody.parse(request.body);
14402
+ } catch {
14403
+ return reply.code(400).send({ error: "Invalid request body" });
14404
+ }
14405
+ if (!await hasPassword()) {
14406
+ return reply.code(403).send({
14407
+ error: "No password configured. Run `hydra-acp auth password` on the daemon host."
14408
+ });
14409
+ }
14410
+ const ok = await verifyPassword(body.password);
14411
+ if (!ok) {
14412
+ deps.rateLimiter.recordFailure(ip);
14413
+ return reply.code(401).send({ error: "Invalid password" });
14414
+ }
14415
+ deps.rateLimiter.recordSuccess(ip);
14416
+ const issued = await deps.store.issue({
14417
+ label: body.label,
14418
+ ttlSec: body.ttlSec
14419
+ });
14420
+ return reply.code(200).send({
14421
+ session_token: issued.token,
14422
+ id: issued.id,
14423
+ expires_at: issued.expiresAt
14424
+ });
14425
+ }
14426
+ );
14427
+ app.post("/v1/auth/logout", async (request, reply) => {
14428
+ let body = void 0;
14429
+ try {
14430
+ body = LogoutBody.parse(request.body ?? void 0);
14431
+ } catch {
14432
+ return reply.code(400).send({ error: "Invalid request body" });
14433
+ }
14434
+ const id = body?.id ?? request.authIdentity;
14435
+ if (!id || id === "service") {
14436
+ return reply.code(200).send({ revoked: false });
14437
+ }
14438
+ const revoked = await deps.store.revoke(id);
14439
+ return reply.code(200).send({ revoked });
14440
+ });
14441
+ app.get("/v1/auth/verify", async (_request, reply) => {
14442
+ return reply.code(200).send({ ok: true });
14443
+ });
14444
+ app.get("/v1/auth/sessions", async (_request, reply) => {
14445
+ return reply.code(200).send({ sessions: deps.store.list() });
14446
+ });
14447
+ app.delete(
14448
+ "/v1/auth/sessions/:id",
14449
+ async (request, reply) => {
14450
+ const id = request.params.id;
14451
+ const revoked = await deps.store.revoke(id);
14452
+ if (!revoked) {
14453
+ return reply.code(404).send({ error: "Not found" });
14454
+ }
14455
+ return reply.code(204).send();
14456
+ }
14457
+ );
14458
+ }
14459
+ function remoteIp(request) {
14460
+ return request.ip || "unknown";
14461
+ }
14462
+
12628
14463
  // src/daemon/acp-ws.ts
12629
14464
  init_connection();
12630
14465
  init_ws_stream();
@@ -12632,12 +14467,12 @@ init_types();
12632
14467
  import { nanoid as nanoid2 } from "nanoid";
12633
14468
  init_hydra_version();
12634
14469
  function registerAcpWsEndpoint(app, deps) {
12635
- app.get("/acp", { websocket: true }, (socket, request) => {
14470
+ app.get("/acp", { websocket: true }, async (socket, request) => {
12636
14471
  const token = tokenFromUpgradeRequest({
12637
14472
  headers: request.headers,
12638
14473
  url: request.url
12639
14474
  });
12640
- if (!token || !constantTimeEqual(token, deps.config.daemon.authToken)) {
14475
+ if (!token || !await deps.validator.validate(token)) {
12641
14476
  socket.close(4401, "Unauthorized");
12642
14477
  return;
12643
14478
  }
@@ -12688,8 +14523,15 @@ function registerAcpWsEndpoint(app, deps) {
12688
14523
  }
12689
14524
  })();
12690
14525
  });
14526
+ const modesPayload = buildModesPayload(session);
12691
14527
  return {
12692
14528
  sessionId: session.sessionId,
14529
+ // session/new is implicitly an attach; mirror session/attach's
14530
+ // shape by including the clientId so deferred-echo clients
14531
+ // (TUI's queue work) can recognize their own prompt_queue_added
14532
+ // events without an extra round-trip.
14533
+ clientId: client.clientId,
14534
+ ...modesPayload ? { modes: modesPayload } : {},
12693
14535
  _meta: buildResponseMeta(session)
12694
14536
  };
12695
14537
  });
@@ -12750,6 +14592,7 @@ function registerAcpWsEndpoint(app, deps) {
12750
14592
  await connection.notify(note.method, note.params);
12751
14593
  }
12752
14594
  session.replayPendingPermissions(client);
14595
+ const modesPayload = buildModesPayload(session);
12753
14596
  return {
12754
14597
  sessionId: session.sessionId,
12755
14598
  clientId: client.clientId,
@@ -12760,6 +14603,7 @@ function registerAcpWsEndpoint(app, deps) {
12760
14603
  // ran, not what was asked for.
12761
14604
  historyPolicy: appliedPolicy,
12762
14605
  replayed: replay.length,
14606
+ ...modesPayload ? { modes: modesPayload } : {},
12763
14607
  _meta: buildResponseMeta(session)
12764
14608
  };
12765
14609
  });
@@ -12793,7 +14637,29 @@ function registerAcpWsEndpoint(app, deps) {
12793
14637
  err.code = JsonRpcErrorCodes.SessionNotFound;
12794
14638
  throw err;
12795
14639
  }
12796
- const session = deps.manager.require(params.sessionId);
14640
+ let session = deps.manager.get(params.sessionId);
14641
+ if (!session) {
14642
+ const fromDisk = await deps.manager.loadFromDisk(params.sessionId);
14643
+ if (!fromDisk) {
14644
+ const err = new Error(
14645
+ `session ${params.sessionId} not found`
14646
+ );
14647
+ err.code = JsonRpcErrorCodes.SessionNotFound;
14648
+ throw err;
14649
+ }
14650
+ app.log.info(
14651
+ `session/prompt auto-resurrecting cold sessionId=${params.sessionId}`
14652
+ );
14653
+ session = await deps.manager.resurrect(fromDisk);
14654
+ const client = bindClientToSession(
14655
+ connection,
14656
+ session,
14657
+ state,
14658
+ void 0,
14659
+ att.clientId
14660
+ );
14661
+ await session.attach(client, "none");
14662
+ }
12797
14663
  return session.prompt(att.clientId, params);
12798
14664
  });
12799
14665
  const handleCancelParams = (raw) => {
@@ -12825,6 +14691,26 @@ function registerAcpWsEndpoint(app, deps) {
12825
14691
  handleCancelParams(raw);
12826
14692
  return null;
12827
14693
  });
14694
+ connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
14695
+ const params = CancelPromptParams.parse(raw);
14696
+ const session = deps.manager.get(params.sessionId);
14697
+ if (!session) {
14698
+ const err = new Error(`session ${params.sessionId} not found`);
14699
+ err.code = JsonRpcErrorCodes.SessionNotFound;
14700
+ throw err;
14701
+ }
14702
+ return session.cancelQueuedPrompt(params.messageId);
14703
+ });
14704
+ connection.onRequest("hydra-acp/update_prompt", async (raw) => {
14705
+ const params = UpdatePromptParams.parse(raw);
14706
+ const session = deps.manager.get(params.sessionId);
14707
+ if (!session) {
14708
+ const err = new Error(`session ${params.sessionId} not found`);
14709
+ err.code = JsonRpcErrorCodes.SessionNotFound;
14710
+ throw err;
14711
+ }
14712
+ return session.updateQueuedPrompt(params.messageId, params.prompt);
14713
+ });
12828
14714
  connection.onRequest("session/load", async (raw) => {
12829
14715
  const rawObj = raw ?? {};
12830
14716
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -12856,8 +14742,13 @@ function registerAcpWsEndpoint(app, deps) {
12856
14742
  await connection.notify(note.method, note.params);
12857
14743
  }
12858
14744
  session.replayPendingPermissions(client);
14745
+ const modesPayload = buildModesPayload(session);
12859
14746
  return {
12860
14747
  sessionId: session.sessionId,
14748
+ // Same as session/new: include clientId so the deferred-echo
14749
+ // path in queue-aware clients can recognize own broadcasts.
14750
+ clientId: client.clientId,
14751
+ ...modesPayload ? { modes: modesPayload } : {},
12861
14752
  _meta: buildResponseMeta(session)
12862
14753
  };
12863
14754
  });
@@ -12883,6 +14774,26 @@ function registerAcpWsEndpoint(app, deps) {
12883
14774
  });
12884
14775
  });
12885
14776
  }
14777
+ function buildModesPayload(session) {
14778
+ const modes = session.availableModes();
14779
+ if (modes.length === 0) {
14780
+ return void 0;
14781
+ }
14782
+ const availableModes = modes.map((m) => {
14783
+ const out = {
14784
+ id: m.id,
14785
+ // ACP spec requires `name` — fall back to id when the agent didn't
14786
+ // supply one so we never emit an invalid SessionMode.
14787
+ name: m.name ?? m.id
14788
+ };
14789
+ if (m.description !== void 0) {
14790
+ out.description = m.description;
14791
+ }
14792
+ return out;
14793
+ });
14794
+ const currentModeId = session.currentMode ?? modes[0].id;
14795
+ return { currentModeId, availableModes };
14796
+ }
12886
14797
  function buildResponseMeta(session) {
12887
14798
  const ours = {
12888
14799
  upstreamSessionId: session.upstreamSessionId,
@@ -12908,9 +14819,17 @@ function buildResponseMeta(session) {
12908
14819
  if (commands.length > 0) {
12909
14820
  ours.availableCommands = commands;
12910
14821
  }
14822
+ const modes = session.availableModes();
14823
+ if (modes.length > 0) {
14824
+ ours.availableModes = modes;
14825
+ }
12911
14826
  if (session.turnStartedAt !== void 0) {
12912
14827
  ours.turnStartedAt = session.turnStartedAt;
12913
14828
  }
14829
+ const queue = session.queueSnapshot();
14830
+ if (queue.length > 0) {
14831
+ ours.queue = queue;
14832
+ }
12914
14833
  return mergeMeta(session.agentMeta, ours);
12915
14834
  }
12916
14835
  function buildInitializeResult() {
@@ -12941,7 +14860,13 @@ function buildInitializeResult() {
12941
14860
  id: "bearer-token",
12942
14861
  description: "Bearer token presented at WS upgrade"
12943
14862
  }
12944
- ]
14863
+ ],
14864
+ // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
14865
+ // ACP clients ignore the field; capability-aware clients learn here
14866
+ // that hydra accepts concurrent session/prompt requests and emits
14867
+ // prompt_queue_* notifications so they can stop running their own
14868
+ // local queue.
14869
+ _meta: mergeMeta(void 0, { promptQueueing: true })
12945
14870
  };
12946
14871
  }
12947
14872
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
@@ -12955,13 +14880,13 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
12955
14880
  }
12956
14881
 
12957
14882
  // src/daemon/server.ts
12958
- async function startDaemon(config) {
14883
+ async function startDaemon(config, serviceToken) {
12959
14884
  ensureLoopbackOrTls(config);
12960
14885
  const httpsOptions = config.daemon.tls ? {
12961
- key: await fsp4.readFile(config.daemon.tls.key),
12962
- cert: await fsp4.readFile(config.daemon.tls.cert)
14886
+ key: await fsp5.readFile(config.daemon.tls.key),
14887
+ cert: await fsp5.readFile(config.daemon.tls.cert)
12963
14888
  } : void 0;
12964
- await fsp4.mkdir(paths.home(), { recursive: true });
14889
+ await fsp5.mkdir(paths.home(), { recursive: true });
12965
14890
  const { stream: logStream, fileStream } = await buildLogStream(
12966
14891
  config.daemon.logLevel
12967
14892
  );
@@ -12982,7 +14907,13 @@ async function startDaemon(config) {
12982
14907
  setNpmInstallLogger((msg) => {
12983
14908
  app.log.info(msg);
12984
14909
  });
12985
- const auth = bearerAuth({ config });
14910
+ const sessionTokenStore = await SessionTokenStore.load();
14911
+ const authRateLimiter = new AuthRateLimiter();
14912
+ const validator = new CompositeTokenValidator([
14913
+ new StaticTokenValidator(serviceToken),
14914
+ new SessionTokenValidator(sessionTokenStore)
14915
+ ]);
14916
+ const auth = bearerAuth({ validator });
12986
14917
  app.addHook("onRequest", async (request, reply) => {
12987
14918
  if (request.routeOptions.config?.skipAuth) {
12988
14919
  return;
@@ -12992,7 +14923,19 @@ async function startDaemon(config) {
12992
14923
  }
12993
14924
  await auth(request, reply);
12994
14925
  });
12995
- const registry = new Registry(config);
14926
+ const sweepInterval = setInterval(
14927
+ () => {
14928
+ sessionTokenStore.sweepExpired();
14929
+ },
14930
+ 5 * 60 * 1e3
14931
+ );
14932
+ sweepInterval.unref();
14933
+ const registry = new Registry(config, {
14934
+ onFetched: () => {
14935
+ void pruneStaleAgentVersions(registry, manager);
14936
+ }
14937
+ });
14938
+ setAgentPruneLogger((msg) => app.log.info(msg));
12996
14939
  const agentLogger = {
12997
14940
  info: (msg) => app.log.info(msg),
12998
14941
  warn: (msg) => app.log.warn(msg)
@@ -13006,7 +14949,8 @@ async function startDaemon(config) {
13006
14949
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
13007
14950
  defaultModels: config.defaultModels,
13008
14951
  sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries,
13009
- logger: agentLogger
14952
+ logger: agentLogger,
14953
+ npmRegistry: config.npmRegistry
13010
14954
  });
13011
14955
  const extensions = new ExtensionManager(extensionList(config));
13012
14956
  registerHealthRoutes(app, HYDRA_VERSION);
@@ -13020,16 +14964,20 @@ async function startDaemon(config) {
13020
14964
  defaultAgent: config.defaultAgent,
13021
14965
  defaultCwd: config.defaultCwd
13022
14966
  });
14967
+ registerAuthRoutes(app, {
14968
+ store: sessionTokenStore,
14969
+ rateLimiter: authRateLimiter
14970
+ });
13023
14971
  registerAcpWsEndpoint(app, {
13024
- config,
14972
+ validator,
13025
14973
  manager,
13026
14974
  defaultAgent: config.defaultAgent
13027
14975
  });
13028
14976
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
13029
14977
  const address = app.server.address();
13030
14978
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
13031
- await fsp4.mkdir(paths.home(), { recursive: true });
13032
- await fsp4.writeFile(
14979
+ await fsp5.mkdir(paths.home(), { recursive: true });
14980
+ await fsp5.writeFile(
13033
14981
  paths.pidFile(),
13034
14982
  JSON.stringify({
13035
14983
  pid: process.pid,
@@ -13045,20 +14993,28 @@ async function startDaemon(config) {
13045
14993
  daemonUrl: `${scheme}://${config.daemon.host}:${boundPort}`,
13046
14994
  daemonHost: config.daemon.host,
13047
14995
  daemonPort: boundPort,
13048
- daemonToken: config.daemon.authToken,
14996
+ serviceToken,
13049
14997
  daemonWsUrl: `${wsScheme}://${config.daemon.host}:${boundPort}/acp`,
13050
14998
  hydraHome: paths.home()
13051
14999
  });
13052
15000
  await extensions.start();
15001
+ void manager.resurrectPendingQueues().catch((err) => {
15002
+ app.log.warn(
15003
+ `queue replay scan failed: ${err.message}`
15004
+ );
15005
+ });
13053
15006
  const shutdown = async () => {
15007
+ clearInterval(sweepInterval);
15008
+ await sessionTokenStore.flush();
13054
15009
  await extensions.stop();
13055
15010
  await manager.closeAll();
13056
15011
  await manager.flushMetaWrites();
13057
15012
  setBinaryInstallLogger(null);
13058
15013
  setNpmInstallLogger(null);
15014
+ setAgentPruneLogger(null);
13059
15015
  await app.close();
13060
15016
  try {
13061
- fs11.unlinkSync(paths.pidFile());
15017
+ fs15.unlinkSync(paths.pidFile());
13062
15018
  } catch {
13063
15019
  }
13064
15020
  try {
@@ -13097,13 +15053,13 @@ function ensureLoopbackOrTls(config) {
13097
15053
  init_daemon_bootstrap();
13098
15054
 
13099
15055
  // src/cli/commands/log-tail.ts
13100
- import * as fs12 from "fs";
13101
- import * as fsp5 from "fs/promises";
15056
+ import * as fs16 from "fs";
15057
+ import * as fsp6 from "fs/promises";
13102
15058
  async function runLogTail(logPath, argv, notFoundMessage) {
13103
15059
  const opts = parseLogTailFlags(argv);
13104
15060
  let stat4;
13105
15061
  try {
13106
- stat4 = await fsp5.stat(logPath);
15062
+ stat4 = await fsp6.stat(logPath);
13107
15063
  } catch (err) {
13108
15064
  const e = err;
13109
15065
  if (e.code === "ENOENT") {
@@ -13121,7 +15077,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
13121
15077
  process.stdout.write(`-- following ${logPath} --
13122
15078
  `);
13123
15079
  let pending = false;
13124
- const watcher = fs12.watch(logPath, () => {
15080
+ const watcher = fs16.watch(logPath, () => {
13125
15081
  if (pending) {
13126
15082
  return;
13127
15083
  }
@@ -13129,14 +15085,14 @@ async function runLogTail(logPath, argv, notFoundMessage) {
13129
15085
  setImmediate(async () => {
13130
15086
  pending = false;
13131
15087
  try {
13132
- const s = await fsp5.stat(logPath);
15088
+ const s = await fsp6.stat(logPath);
13133
15089
  if (s.size <= position) {
13134
15090
  if (s.size < position) {
13135
15091
  position = s.size;
13136
15092
  }
13137
15093
  return;
13138
15094
  }
13139
- const fd = await fsp5.open(logPath, "r");
15095
+ const fd = await fsp6.open(logPath, "r");
13140
15096
  try {
13141
15097
  const buf = Buffer.alloc(s.size - position);
13142
15098
  await fd.read(buf, 0, buf.length, position);
@@ -13163,7 +15119,7 @@ async function printTail(logPath, fileSize, lines) {
13163
15119
  return fileSize;
13164
15120
  }
13165
15121
  const CHUNK = 64 * 1024;
13166
- const fd = await fsp5.open(logPath, "r");
15122
+ const fd = await fsp6.open(logPath, "r");
13167
15123
  try {
13168
15124
  let position = fileSize;
13169
15125
  let collected = "";
@@ -13221,7 +15177,8 @@ function parseLogTailFlags(argv) {
13221
15177
 
13222
15178
  // src/cli/commands/daemon.ts
13223
15179
  async function runDaemonStart(flags = {}) {
13224
- const config = await ensureConfig();
15180
+ const config = await loadConfig();
15181
+ const serviceToken = await ensureServiceToken();
13225
15182
  if (await pingHealth(config)) {
13226
15183
  const info2 = await readPidFile();
13227
15184
  process.stdout.write(
@@ -13231,7 +15188,7 @@ async function runDaemonStart(flags = {}) {
13231
15188
  return;
13232
15189
  }
13233
15190
  if (flagBool(flags, "foreground")) {
13234
- const handle = await startDaemon(config);
15191
+ const handle = await startDaemon(config, serviceToken);
13235
15192
  process.stdout.write(
13236
15193
  `hydra-acp daemon listening on ${config.daemon.host}:${config.daemon.port}
13237
15194
  `
@@ -13269,7 +15226,8 @@ async function runDaemonStop() {
13269
15226
  }
13270
15227
  }
13271
15228
  async function runDaemonRestart() {
13272
- const config = await ensureConfig();
15229
+ const config = await loadConfig();
15230
+ await ensureServiceToken();
13273
15231
  const info = await readPidFile();
13274
15232
  if (info && isProcessAlive(info.pid)) {
13275
15233
  process.stdout.write(`Stopping daemon pid ${info.pid}...
@@ -13333,7 +15291,7 @@ async function runDaemonStatus() {
13333
15291
  }
13334
15292
  async function readPidFile() {
13335
15293
  try {
13336
- const raw = await fsp6.readFile(paths.pidFile(), "utf8");
15294
+ const raw = await fsp7.readFile(paths.pidFile(), "utf8");
13337
15295
  return JSON.parse(raw);
13338
15296
  } catch (err) {
13339
15297
  const e = err;
@@ -13357,16 +15315,18 @@ init_sessions();
13357
15315
 
13358
15316
  // src/cli/commands/extensions.ts
13359
15317
  init_config();
15318
+ init_service_token();
13360
15319
  init_paths();
13361
- import * as fsp7 from "fs/promises";
15320
+ import * as fsp8 from "fs/promises";
13362
15321
  init_sessions();
13363
15322
  async function runExtensionsList() {
13364
15323
  const config = await loadConfig();
15324
+ const serviceToken = await loadServiceToken();
13365
15325
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13366
15326
  let body;
13367
15327
  try {
13368
15328
  const r = await fetch(`${baseUrl}/v1/extensions`, {
13369
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
15329
+ headers: { Authorization: `Bearer ${serviceToken}` }
13370
15330
  });
13371
15331
  if (!r.ok) {
13372
15332
  process.stderr.write(`Daemon returned HTTP ${r.status}
@@ -13469,13 +15429,14 @@ async function runExtensionsAdd(name, argv) {
13469
15429
  process.stdout.write(`Added extension '${name}' to ${paths.config()}
13470
15430
  `);
13471
15431
  const config = await loadConfig();
15432
+ const serviceToken = await loadServiceToken();
13472
15433
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13473
15434
  const registerBody = { name, ...body };
13474
15435
  try {
13475
15436
  const r = await fetch(`${baseUrl}/v1/extensions`, {
13476
15437
  method: "POST",
13477
15438
  headers: {
13478
- Authorization: `Bearer ${config.daemon.authToken}`,
15439
+ Authorization: `Bearer ${serviceToken}`,
13479
15440
  "Content-Type": "application/json"
13480
15441
  },
13481
15442
  body: JSON.stringify(registerBody)
@@ -13523,11 +15484,12 @@ async function runExtensionsRemove(name) {
13523
15484
  process.stdout.write(`Removed extension '${name}' from ${paths.config()}
13524
15485
  `);
13525
15486
  const config = await loadConfig();
15487
+ const serviceToken = await loadServiceToken();
13526
15488
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13527
15489
  try {
13528
15490
  const r = await fetch(`${baseUrl}/v1/extensions/${encodeURIComponent(name)}`, {
13529
15491
  method: "DELETE",
13530
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
15492
+ headers: { Authorization: `Bearer ${serviceToken}` }
13531
15493
  });
13532
15494
  if (r.status === 204 || r.status === 404) {
13533
15495
  process.stdout.write(`${name}: stopped
@@ -13554,11 +15516,11 @@ async function runExtensionsRemove(name) {
13554
15516
  }
13555
15517
  }
13556
15518
  async function readRawConfig() {
13557
- const raw = await fsp7.readFile(paths.config(), "utf8");
15519
+ const raw = await fsp8.readFile(paths.config(), "utf8");
13558
15520
  return JSON.parse(raw);
13559
15521
  }
13560
15522
  async function writeRawConfig(raw) {
13561
- await fsp7.writeFile(
15523
+ await fsp8.writeFile(
13562
15524
  paths.config(),
13563
15525
  JSON.stringify(raw, null, 2) + "\n",
13564
15526
  { encoding: "utf8", mode: 384 }
@@ -13587,12 +15549,13 @@ async function postLifecycle(name, verb) {
13587
15549
  return;
13588
15550
  }
13589
15551
  const config = await loadConfig();
15552
+ const serviceToken = await loadServiceToken();
13590
15553
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13591
15554
  let r;
13592
15555
  try {
13593
15556
  r = await fetch(`${baseUrl}/v1/extensions/${encodeURIComponent(name)}/${verb}`, {
13594
15557
  method: "POST",
13595
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
15558
+ headers: { Authorization: `Bearer ${serviceToken}` }
13596
15559
  });
13597
15560
  } catch (err) {
13598
15561
  process.stderr.write(
@@ -13623,8 +15586,9 @@ async function postLifecycle(name, verb) {
13623
15586
  }
13624
15587
  async function postLifecycleAll(verb) {
13625
15588
  const config = await loadConfig();
15589
+ const serviceToken = await loadServiceToken();
13626
15590
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13627
- const auth = { Authorization: `Bearer ${config.daemon.authToken}` };
15591
+ const auth = { Authorization: `Bearer ${serviceToken}` };
13628
15592
  let listBody;
13629
15593
  try {
13630
15594
  const r = await fetch(`${baseUrl}/v1/extensions`, { headers: auth });
@@ -13790,14 +15754,16 @@ function maxLen2(headerCell, values) {
13790
15754
 
13791
15755
  // src/cli/commands/agents.ts
13792
15756
  init_config();
15757
+ init_service_token();
13793
15758
  init_sessions();
13794
15759
  async function runAgentsList() {
13795
15760
  const config = await loadConfig();
15761
+ const serviceToken = await loadServiceToken();
13796
15762
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13797
15763
  let body;
13798
15764
  try {
13799
15765
  const r = await fetch(`${baseUrl}/v1/agents`, {
13800
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
15766
+ headers: { Authorization: `Bearer ${serviceToken}` }
13801
15767
  });
13802
15768
  if (!r.ok) {
13803
15769
  process.stderr.write(`Daemon returned HTTP ${r.status}
@@ -13854,12 +15820,13 @@ Registry version: ${body.version}
13854
15820
  }
13855
15821
  async function runAgentsRefresh() {
13856
15822
  const config = await loadConfig();
15823
+ const serviceToken = await loadServiceToken();
13857
15824
  const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
13858
15825
  let body;
13859
15826
  try {
13860
15827
  const r = await fetch(`${baseUrl}/v1/registry/refresh`, {
13861
15828
  method: "POST",
13862
- headers: { Authorization: `Bearer ${config.daemon.authToken}` }
15829
+ headers: { Authorization: `Bearer ${serviceToken}` }
13863
15830
  });
13864
15831
  if (!r.ok) {
13865
15832
  process.stderr.write(`Daemon returned HTTP ${r.status}
@@ -13890,8 +15857,197 @@ function maxLen3(headerCell, values) {
13890
15857
  return max;
13891
15858
  }
13892
15859
 
15860
+ // src/cli/commands/auth.ts
15861
+ init_config();
15862
+ init_service_token();
15863
+ init_sessions();
15864
+ async function promptPassword(prompt) {
15865
+ process.stdout.write(prompt);
15866
+ if (!process.stdin.isTTY) {
15867
+ return readLineFromStdin();
15868
+ }
15869
+ return new Promise((resolve5, reject) => {
15870
+ const stdin = process.stdin;
15871
+ const wasRaw = stdin.isRaw === true;
15872
+ let buffer = "";
15873
+ const cleanup = () => {
15874
+ stdin.removeListener("data", onData);
15875
+ stdin.removeListener("error", onError);
15876
+ if (!wasRaw) {
15877
+ stdin.setRawMode(false);
15878
+ }
15879
+ stdin.pause();
15880
+ };
15881
+ const onData = (chunk) => {
15882
+ for (const byte of chunk) {
15883
+ if (byte === 10 || byte === 13) {
15884
+ process.stdout.write("\n");
15885
+ cleanup();
15886
+ resolve5(buffer);
15887
+ return;
15888
+ }
15889
+ if (byte === 3) {
15890
+ cleanup();
15891
+ reject(new Error("password entry cancelled"));
15892
+ return;
15893
+ }
15894
+ if (byte === 127 || byte === 8) {
15895
+ buffer = buffer.slice(0, -1);
15896
+ continue;
15897
+ }
15898
+ buffer += String.fromCharCode(byte);
15899
+ }
15900
+ };
15901
+ const onError = (err) => {
15902
+ cleanup();
15903
+ reject(err);
15904
+ };
15905
+ stdin.setRawMode(true);
15906
+ stdin.resume();
15907
+ stdin.on("data", onData);
15908
+ stdin.on("error", onError);
15909
+ });
15910
+ }
15911
+ function readLineFromStdin() {
15912
+ return new Promise((resolve5, reject) => {
15913
+ let buffer = "";
15914
+ process.stdin.setEncoding("utf8");
15915
+ const onData = (chunk) => {
15916
+ buffer += chunk;
15917
+ const nl = buffer.indexOf("\n");
15918
+ if (nl !== -1) {
15919
+ process.stdin.removeListener("data", onData);
15920
+ process.stdin.removeListener("error", onError);
15921
+ resolve5(buffer.slice(0, nl).replace(/\r$/, ""));
15922
+ }
15923
+ };
15924
+ const onError = (err) => {
15925
+ process.stdin.removeListener("data", onData);
15926
+ process.stdin.removeListener("error", onError);
15927
+ reject(err);
15928
+ };
15929
+ process.stdin.on("data", onData);
15930
+ process.stdin.on("error", onError);
15931
+ });
15932
+ }
15933
+ async function runAuthPasswordSet(flags) {
15934
+ const force = flagBool(flags, "force");
15935
+ if (await hasPassword() && !force) {
15936
+ const current = await promptPassword("Current password: ");
15937
+ if (!await verifyPassword(current)) {
15938
+ process.stderr.write("Wrong password.\n");
15939
+ process.exit(1);
15940
+ }
15941
+ }
15942
+ const next = await promptPassword("New password: ");
15943
+ if (next.length === 0) {
15944
+ process.stderr.write("Password must not be empty.\n");
15945
+ process.exit(2);
15946
+ }
15947
+ const confirm = await promptPassword("Confirm new password: ");
15948
+ if (next !== confirm) {
15949
+ process.stderr.write("Passwords did not match.\n");
15950
+ process.exit(1);
15951
+ }
15952
+ await setPassword(next);
15953
+ process.stdout.write("Password set.\n");
15954
+ }
15955
+ async function runAuthList() {
15956
+ const config = await loadConfig();
15957
+ const token = await loadServiceToken();
15958
+ const baseUrl = httpBase(
15959
+ config.daemon.host,
15960
+ config.daemon.port,
15961
+ !!config.daemon.tls
15962
+ );
15963
+ const r = await fetch(`${baseUrl}/v1/auth/sessions`, {
15964
+ headers: { Authorization: `Bearer ${token}` }
15965
+ });
15966
+ if (!r.ok) {
15967
+ process.stderr.write(`Daemon returned HTTP ${r.status}
15968
+ `);
15969
+ process.exit(1);
15970
+ }
15971
+ const body = await r.json();
15972
+ if (body.sessions.length === 0) {
15973
+ process.stdout.write("No active session tokens.\n");
15974
+ return;
15975
+ }
15976
+ const header = {
15977
+ id: "ID",
15978
+ label: "LABEL",
15979
+ createdAt: "CREATED",
15980
+ expiresAt: "EXPIRES",
15981
+ lastUsedAt: "LAST USED"
15982
+ };
15983
+ const rows = body.sessions.map((s) => ({
15984
+ id: s.id,
15985
+ label: s.label ?? "-",
15986
+ createdAt: s.createdAt,
15987
+ expiresAt: s.expiresAt,
15988
+ lastUsedAt: s.lastUsedAt
15989
+ }));
15990
+ const widths = {
15991
+ id: maxLen4(header.id, rows.map((r2) => r2.id)),
15992
+ label: maxLen4(header.label, rows.map((r2) => r2.label)),
15993
+ createdAt: maxLen4(header.createdAt, rows.map((r2) => r2.createdAt)),
15994
+ expiresAt: maxLen4(header.expiresAt, rows.map((r2) => r2.expiresAt))
15995
+ };
15996
+ const fmt = (r2) => [
15997
+ r2.id.padEnd(widths.id),
15998
+ r2.label.padEnd(widths.label),
15999
+ r2.createdAt.padEnd(widths.createdAt),
16000
+ r2.expiresAt.padEnd(widths.expiresAt),
16001
+ r2.lastUsedAt
16002
+ ].join(" ");
16003
+ process.stdout.write(fmt(header) + "\n");
16004
+ for (const r2 of rows) {
16005
+ process.stdout.write(fmt(r2) + "\n");
16006
+ }
16007
+ }
16008
+ async function runAuthRevoke(id) {
16009
+ if (!id) {
16010
+ process.stderr.write("Usage: hydra-acp auth revoke <id>\n");
16011
+ process.exit(2);
16012
+ }
16013
+ const config = await loadConfig();
16014
+ const token = await loadServiceToken();
16015
+ const baseUrl = httpBase(
16016
+ config.daemon.host,
16017
+ config.daemon.port,
16018
+ !!config.daemon.tls
16019
+ );
16020
+ const r = await fetch(`${baseUrl}/v1/auth/sessions/${id}`, {
16021
+ method: "DELETE",
16022
+ headers: { Authorization: `Bearer ${token}` }
16023
+ });
16024
+ if (r.status === 204) {
16025
+ process.stdout.write(`Revoked ${id}
16026
+ `);
16027
+ return;
16028
+ }
16029
+ if (r.status === 404) {
16030
+ process.stderr.write(`No session token with id ${id}
16031
+ `);
16032
+ process.exit(1);
16033
+ }
16034
+ process.stderr.write(`Daemon returned HTTP ${r.status}
16035
+ `);
16036
+ process.exit(1);
16037
+ }
16038
+ function maxLen4(headerCell, values) {
16039
+ let max = headerCell.length;
16040
+ for (const v of values) {
16041
+ if (v.length > max) {
16042
+ max = v.length;
16043
+ }
16044
+ }
16045
+ return max;
16046
+ }
16047
+
13893
16048
  // src/shim/proxy.ts
13894
16049
  init_config();
16050
+ init_service_token();
13895
16051
  init_daemon_bootstrap();
13896
16052
  init_resilient_ws();
13897
16053
 
@@ -14066,13 +16222,14 @@ function isResponse2(msg) {
14066
16222
 
14067
16223
  // src/shim/proxy.ts
14068
16224
  async function runShim(opts) {
14069
- const config = await ensureConfig();
16225
+ const config = await loadConfig();
16226
+ const serviceToken = await ensureServiceToken();
14070
16227
  await ensureDaemonReachable(config);
14071
16228
  const tracker = new SessionTracker();
14072
16229
  const downstream = ndjsonStreamFromStdio(process.stdin, process.stdout);
14073
16230
  const protocol = config.daemon.tls ? "wss" : "ws";
14074
16231
  const url = `${protocol}://${config.daemon.host}:${config.daemon.port}/acp`;
14075
- const subprotocols = ["acp.v1", `hydra-acp-token.${config.daemon.authToken}`];
16232
+ const subprotocols = ["acp.v1", `hydra-acp-token.${serviceToken}`];
14076
16233
  const upstream = new ResilientWsStream({
14077
16234
  url,
14078
16235
  subprotocols,
@@ -14413,6 +16570,7 @@ async function main() {
14413
16570
  process.exit(2);
14414
16571
  return;
14415
16572
  }
16573
+ case "session":
14416
16574
  case "sessions": {
14417
16575
  const sub = positional[1];
14418
16576
  if (sub === void 0 || sub === "list") {
@@ -14449,13 +16607,14 @@ async function main() {
14449
16607
  });
14450
16608
  return;
14451
16609
  }
14452
- process.stderr.write(`Unknown sessions subcommand: ${sub}
16610
+ process.stderr.write(`Unknown session subcommand: ${sub}
14453
16611
  `);
14454
16612
  process.exit(2);
14455
16613
  return;
14456
16614
  }
16615
+ case "extension":
14457
16616
  case "extensions": {
14458
- const extIdx = argv.indexOf("extensions");
16617
+ const extIdx = argv.indexOf(subcommand);
14459
16618
  const tail = argv.slice(extIdx + 1);
14460
16619
  const sub = tail[0];
14461
16620
  const name2 = tail[1];
@@ -14488,11 +16647,12 @@ async function main() {
14488
16647
  await runExtensionsLogs(name2, rest);
14489
16648
  return;
14490
16649
  }
14491
- process.stderr.write(`Unknown extensions subcommand: ${sub}
16650
+ process.stderr.write(`Unknown extension subcommand: ${sub}
14492
16651
  `);
14493
16652
  process.exit(2);
14494
16653
  return;
14495
16654
  }
16655
+ case "agent":
14496
16656
  case "agents": {
14497
16657
  const sub = positional[1];
14498
16658
  if (sub === void 0 || sub === "list") {
@@ -14503,7 +16663,33 @@ async function main() {
14503
16663
  await runAgentsRefresh();
14504
16664
  return;
14505
16665
  }
14506
- process.stderr.write(`Unknown agents subcommand: ${sub}
16666
+ process.stderr.write(`Unknown agent subcommand: ${sub}
16667
+ `);
16668
+ process.exit(2);
16669
+ return;
16670
+ }
16671
+ case "auth": {
16672
+ const sub = positional[1];
16673
+ if (sub === "password") {
16674
+ const action = positional[2];
16675
+ if (action === void 0 || action === "set") {
16676
+ await runAuthPasswordSet(flags);
16677
+ return;
16678
+ }
16679
+ process.stderr.write(`Unknown auth password action: ${action}
16680
+ `);
16681
+ process.exit(2);
16682
+ return;
16683
+ }
16684
+ if (sub === void 0 || sub === "list") {
16685
+ await runAuthList();
16686
+ return;
16687
+ }
16688
+ if (sub === "revoke") {
16689
+ await runAuthRevoke(positional[2]);
16690
+ return;
16691
+ }
16692
+ process.stderr.write(`Unknown auth subcommand: ${sub}
14507
16693
  `);
14508
16694
  process.exit(2);
14509
16695
  return;
@@ -14583,23 +16769,26 @@ function printHelp() {
14583
16769
  " hydra-acp daemon start [--foreground] Start daemon (detached by default; --foreground to attach)",
14584
16770
  " hydra-acp daemon stop|restart|status",
14585
16771
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
14586
- " hydra-acp sessions [list] [--all] [--json]",
16772
+ " hydra-acp session [list] [--all] [--json]",
14587
16773
  " List sessions (live + 20 most-recent cold; --all for everything; --json emits the raw daemon response as JSON for scripts)",
14588
- " hydra-acp sessions kill <id> Demote a live session to cold (keeps the on-disk record)",
14589
- " hydra-acp sessions remove <id> Remove a session entirely (live or cold)",
14590
- " hydra-acp sessions export <id> [--out <file>|.]",
16774
+ " hydra-acp session kill <id> Demote a live session to cold (keeps the on-disk record)",
16775
+ " hydra-acp session remove <id> Remove a session entirely (live or cold)",
16776
+ " hydra-acp session export <id> [--out <file>|.]",
14591
16777
  " Write a session bundle to <file>, to a default-named file when --out=., or to stdout",
14592
- " hydra-acp sessions transcript <id>|<file> [--out <file>|.]",
16778
+ " hydra-acp session transcript <id>|<file> [--out <file>|.]",
14593
16779
  " Render a session as a markdown transcript. Accepts a session id (renders via the daemon) or a local .hydra bundle file (rendered in-process). Writes to <file>, to a default-named file when --out=., or to stdout",
14594
- " hydra-acp sessions import <file>|- [--replace] [--cwd <path>] [--info]",
16780
+ " hydra-acp session import <file>|- [--replace] [--cwd <path>] [--info]",
14595
16781
  " Import a bundle from <file> or stdin (-); --replace overwrites a lineage match (kills it if live); --cwd overrides the bundle's recorded working directory; --info prints the bundle's meta without importing",
14596
- " hydra-acp extensions list List configured extensions and live state",
14597
- " hydra-acp extensions add <name> [opts] Add an extension to config",
14598
- " hydra-acp extensions remove <name> Remove an extension from config",
14599
- " hydra-acp extensions start|stop|restart <n>|all Lifecycle on one or all",
14600
- " hydra-acp extensions logs <name> [-f] [-n N]Tail or follow an extension's log",
14601
- " hydra-acp agents [list] List agents in the cached registry",
14602
- " hydra-acp agents refresh Force a registry re-fetch",
16782
+ " hydra-acp extension list List configured extensions and live state",
16783
+ " hydra-acp extension add <name> [opts] Add an extension to config",
16784
+ " hydra-acp extension remove <name> Remove an extension from config",
16785
+ " hydra-acp extension start|stop|restart <n>|all Lifecycle on one or all",
16786
+ " hydra-acp extension logs <name> [-f] [-n N] Tail or follow an extension's log",
16787
+ " hydra-acp agent [list] List agents in the cached registry",
16788
+ " hydra-acp agent refresh Force a registry re-fetch",
16789
+ " hydra-acp auth password [--force] Set the daemon's master password",
16790
+ " hydra-acp auth [list] List active session tokens",
16791
+ " hydra-acp auth revoke <id> Revoke a session token",
14603
16792
  " hydra-acp tui flags: [--resume <id>] [--reattach] [--new] [--agent <id>] [--model <id>] [--cwd <path>] [--name <label>]",
14604
16793
  " --resume <id> attaches to a specific session; --reattach picks the most-recent in cwd.",
14605
16794
  " Smart default (no flags): shows a picker when sessions exist, else new.",