@hydra-acp/cli 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/daemon/server.ts
2
2
  import * as fs14 from "fs";
3
- import * as fsp4 from "fs/promises";
3
+ import * as fsp5 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
6
6
  import pino from "pino";
@@ -186,7 +186,15 @@ var TuiConfig = z.object({
186
186
  // on Windows Terminal, dock badge on KDE/Konsole, etc.) while a turn is
187
187
  // running. Set false if your terminal renders this obnoxiously or you
188
188
  // just don't want it.
189
- progressIndicator: z.boolean().default(true)
189
+ progressIndicator: z.boolean().default(true),
190
+ // What the unmodified Enter key does in the prompt composer.
191
+ // "enqueue" (default) — Enter enqueues the prompt (sends immediately
192
+ // when idle, queues behind an in-flight turn); Shift+Enter amends
193
+ // the in-flight turn.
194
+ // "amend" — flips the two: Enter amends the in-flight turn,
195
+ // Shift+Enter enqueues. With no turn in flight either key just
196
+ // enqueues, since there's nothing to amend.
197
+ defaultEnterAction: z.enum(["enqueue", "amend"]).default("enqueue")
190
198
  });
191
199
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
192
200
  var ExtensionBody = z.object({
@@ -230,7 +238,8 @@ var HydraConfig = z.object({
230
238
  mouse: true,
231
239
  logMaxBytes: 5 * 1024 * 1024,
232
240
  cwdColumnMaxWidth: 24,
233
- progressIndicator: true
241
+ progressIndicator: true,
242
+ defaultEnterAction: "enqueue"
234
243
  })
235
244
  });
236
245
  function extensionList(config) {
@@ -693,10 +702,12 @@ var RegistryDocument = z2.object({
693
702
  extensions: z2.array(z2.unknown()).optional()
694
703
  });
695
704
  var Registry = class {
696
- constructor(config) {
705
+ constructor(config, options = {}) {
697
706
  this.config = config;
707
+ this.options = options;
698
708
  }
699
709
  config;
710
+ options;
700
711
  cache;
701
712
  async load() {
702
713
  if (this.cache && this.isFresh(this.cache.fetchedAt)) {
@@ -746,7 +757,12 @@ var Registry = class {
746
757
  }
747
758
  const raw = await response.json();
748
759
  const data = RegistryDocument.parse(raw);
749
- return { fetchedAt: Date.now(), raw, data };
760
+ const cached = { fetchedAt: Date.now(), raw, data };
761
+ const hook = this.options.onFetched;
762
+ if (hook) {
763
+ void Promise.resolve().then(() => hook(data)).catch(() => void 0);
764
+ }
765
+ return cached;
750
766
  }
751
767
  async readDiskCache() {
752
768
  let text;
@@ -808,6 +824,7 @@ function npxPackageBasename(agent) {
808
824
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
809
825
  }
810
826
  async function planSpawn(agent, callerArgs = [], options = {}) {
827
+ const version = agent.version ?? "current";
811
828
  if (agent.distribution.npx) {
812
829
  const npx = agent.distribution.npx;
813
830
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
@@ -815,13 +832,14 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
815
832
  return {
816
833
  command: "npx",
817
834
  args: ["-y", npx.package, ...tail],
818
- env: npx.env ?? {}
835
+ env: npx.env ?? {},
836
+ version
819
837
  };
820
838
  }
821
839
  const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
822
840
  const binPath = await ensureNpmPackage({
823
841
  agentId: agent.id,
824
- version: agent.version ?? "current",
842
+ version,
825
843
  packageSpec: npx.package,
826
844
  bin,
827
845
  registry: options.npmRegistry
@@ -829,7 +847,8 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
829
847
  return {
830
848
  command: binPath,
831
849
  args: tail,
832
- env: npx.env ?? {}
850
+ env: npx.env ?? {},
851
+ version
833
852
  };
834
853
  }
835
854
  if (agent.distribution.binary) {
@@ -841,14 +860,15 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
841
860
  }
842
861
  const cmdPath = await ensureBinary({
843
862
  agentId: agent.id,
844
- version: agent.version ?? "current",
863
+ version,
845
864
  target
846
865
  });
847
866
  const tail = callerArgs.length > 0 ? callerArgs : target.args ?? [];
848
867
  return {
849
868
  command: cmdPath,
850
869
  args: tail,
851
- env: target.env ?? {}
870
+ env: target.env ?? {},
871
+ version
852
872
  };
853
873
  }
854
874
  if (agent.distribution.uvx) {
@@ -857,7 +877,8 @@ async function planSpawn(agent, callerArgs = [], options = {}) {
857
877
  return {
858
878
  command: "uvx",
859
879
  args: [uvx.package, ...tail],
860
- env: uvx.env ?? {}
880
+ env: uvx.env ?? {},
881
+ version
861
882
  };
862
883
  }
863
884
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
@@ -1012,6 +1033,18 @@ function extractHydraMeta(meta) {
1012
1033
  if (typeof obj.promptQueueing === "boolean") {
1013
1034
  out.promptQueueing = obj.promptQueueing;
1014
1035
  }
1036
+ if (typeof obj.promptCancelling === "boolean") {
1037
+ out.promptCancelling = obj.promptCancelling;
1038
+ }
1039
+ if (typeof obj.promptUpdating === "boolean") {
1040
+ out.promptUpdating = obj.promptUpdating;
1041
+ }
1042
+ if (typeof obj.promptAmending === "boolean") {
1043
+ out.promptAmending = obj.promptAmending;
1044
+ }
1045
+ if (typeof obj.promptPipelining === "boolean") {
1046
+ out.promptPipelining = obj.promptPipelining;
1047
+ }
1015
1048
  if (Array.isArray(obj.queue)) {
1016
1049
  const entries = [];
1017
1050
  for (const raw of obj.queue) {
@@ -1156,6 +1189,34 @@ var UpdatePromptResult = z3.object({
1156
1189
  updated: z3.boolean(),
1157
1190
  reason: z3.enum(["ok", "not_found", "already_running"])
1158
1191
  });
1192
+ var AmendPromptParams = z3.object({
1193
+ sessionId: z3.string(),
1194
+ targetMessageId: z3.string(),
1195
+ prompt: z3.array(z3.unknown()),
1196
+ replaceQueue: z3.boolean().optional(),
1197
+ onTargetCompleted: z3.enum(["reject", "send_anyway"]).optional()
1198
+ });
1199
+ var AmendPromptResult = z3.object({
1200
+ amended: z3.boolean(),
1201
+ reason: z3.enum([
1202
+ "ok",
1203
+ "target_completed",
1204
+ "target_cancelled",
1205
+ "target_not_found"
1206
+ ]),
1207
+ // Present when a prompt was sent or replaced: the amendment's id on
1208
+ // success, or the regular follow-up's id when onTargetCompleted is
1209
+ // "send_anyway" and the daemon forwarded the prompt anyway.
1210
+ messageId: z3.string().optional()
1211
+ });
1212
+ var PromptAmendedParams = z3.object({
1213
+ sessionId: z3.string(),
1214
+ cancelledMessageId: z3.string(),
1215
+ newMessageId: z3.string(),
1216
+ prompt: z3.array(z3.unknown()),
1217
+ originator: PromptOriginatorSchema,
1218
+ amendedAt: z3.number()
1219
+ });
1159
1220
  var ProxyInitializeParams = z3.object({
1160
1221
  protocolVersion: z3.number().optional(),
1161
1222
  proxyInfo: z3.object({
@@ -1443,6 +1504,9 @@ var JsonRpcConnection = class _JsonRpcConnection {
1443
1504
  var DEFAULT_STDERR_TAIL_BYTES = 4096;
1444
1505
  var AgentInstance = class _AgentInstance {
1445
1506
  agentId;
1507
+ // Version this process was spawned from — used by the registry-fetch
1508
+ // prune sweep to skip install dirs belonging to a live agent.
1509
+ version;
1446
1510
  cwd;
1447
1511
  connection;
1448
1512
  child;
@@ -1454,6 +1518,7 @@ var AgentInstance = class _AgentInstance {
1454
1518
  exitHandlers = [];
1455
1519
  constructor(opts, child) {
1456
1520
  this.agentId = opts.agentId;
1521
+ this.version = opts.plan.version;
1457
1522
  this.cwd = opts.cwd;
1458
1523
  this.child = child;
1459
1524
  this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
@@ -1630,6 +1695,7 @@ function stripHydraSessionPrefix(id) {
1630
1695
  return id.startsWith(HYDRA_SESSION_PREFIX) ? id.slice(HYDRA_SESSION_PREFIX.length) : id;
1631
1696
  }
1632
1697
  var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
1698
+ var RECENTLY_TERMINAL_LIMIT = 64;
1633
1699
  var Session = class {
1634
1700
  sessionId;
1635
1701
  cwd;
@@ -1729,6 +1795,20 @@ var Session = class {
1729
1795
  modelHandlers = [];
1730
1796
  modeHandlers = [];
1731
1797
  usageHandlers = [];
1798
+ // Set by amendPrompt at the start of a cancel-and-resubmit dance.
1799
+ // broadcastTurnComplete reads it to attach the _meta.amended marker
1800
+ // to the cancelled turn's turn_complete notification, and to fire the
1801
+ // dedicated prompt_amended notification. Cleared when the cancelled
1802
+ // turn's task completes (runQueueEntry) OR if the amendment is
1803
+ // cancelled mid-window via cancel_prompt(M2) before drainQueue picks
1804
+ // it up.
1805
+ amendInProgress;
1806
+ // LRU of recently-terminal messageIds → stopReason. Used by
1807
+ // amendPrompt to resolve targets that completed/cancelled before
1808
+ // the amend arrived. Capped at RECENTLY_TERMINAL_LIMIT entries;
1809
+ // older entries fall out and resolve to target_not_found, which is
1810
+ // the correct behavior.
1811
+ recentlyTerminal = /* @__PURE__ */ new Map();
1732
1812
  constructor(init) {
1733
1813
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
1734
1814
  this.cwd = init.cwd;
@@ -2158,7 +2238,7 @@ var Session = class {
2158
2238
  );
2159
2239
  }
2160
2240
  }
2161
- broadcastTurnComplete(originatorClientId, response) {
2241
+ broadcastTurnComplete(originatorClientId, response, promptMessageId, wasAmend) {
2162
2242
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
2163
2243
  const update = {
2164
2244
  sessionUpdate: "turn_complete",
@@ -2167,15 +2247,83 @@ var Session = class {
2167
2247
  if (stopReason !== void 0) {
2168
2248
  update.stopReason = stopReason;
2169
2249
  }
2250
+ const amend = this.amendInProgress;
2251
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
2252
+ update._meta = {
2253
+ "hydra-acp": {
2254
+ amended: {
2255
+ cancelledMessageId: amend.cancelledMessageId,
2256
+ newMessageId: amend.newMessageId
2257
+ }
2258
+ }
2259
+ };
2260
+ }
2170
2261
  this.promptStartedAt = void 0;
2262
+ if (promptMessageId !== void 0 && stopReason !== void 0) {
2263
+ this.recordTerminal(promptMessageId, stopReason);
2264
+ }
2171
2265
  this.recordAndBroadcast(
2172
2266
  "session/update",
2173
2267
  {
2174
2268
  sessionId: this.sessionId,
2175
2269
  update
2176
2270
  },
2177
- originatorClientId
2271
+ wasAmend ? void 0 : originatorClientId
2178
2272
  );
2273
+ if (amend && promptMessageId !== void 0 && amend.cancelledMessageId === promptMessageId) {
2274
+ this.broadcastPromptAmended(amend);
2275
+ }
2276
+ }
2277
+ // Record that a prompt's turn has ended, with its terminal stopReason.
2278
+ // Used by amendPrompt to resolve targetMessageIds that completed/cancelled
2279
+ // before the amend arrived. LRU-trimmed at RECENTLY_TERMINAL_LIMIT.
2280
+ recordTerminal(messageId, stopReason) {
2281
+ this.recentlyTerminal.set(messageId, {
2282
+ stopReason,
2283
+ terminatedAt: Date.now()
2284
+ });
2285
+ while (this.recentlyTerminal.size > RECENTLY_TERMINAL_LIMIT) {
2286
+ const oldest = this.recentlyTerminal.keys().next().value;
2287
+ if (oldest === void 0) {
2288
+ break;
2289
+ }
2290
+ this.recentlyTerminal.delete(oldest);
2291
+ }
2292
+ }
2293
+ // Fire hydra-acp/prompt_amended for the M1→M2 linkage. The amendment's
2294
+ // current content is read live from the queue entry so any update_prompt
2295
+ // calls during the amend window are reflected. Best-effort: if M2 has
2296
+ // already been cancelled out of the queue by the time we get here, we
2297
+ // skip — the amendInProgress clearing in cancelQueuedPrompt should have
2298
+ // prevented this code path from running in that case.
2299
+ broadcastPromptAmended(amend) {
2300
+ const entry = this.findUserEntry(amend.newMessageId);
2301
+ if (!entry) {
2302
+ return;
2303
+ }
2304
+ const params = {
2305
+ sessionId: this.sessionId,
2306
+ cancelledMessageId: amend.cancelledMessageId,
2307
+ newMessageId: amend.newMessageId,
2308
+ prompt: entry.prompt,
2309
+ originator: entry.originator,
2310
+ amendedAt: Date.now()
2311
+ };
2312
+ this.broadcastQueueNotification(
2313
+ "hydra-acp/prompt_amended",
2314
+ params
2315
+ );
2316
+ }
2317
+ // Look up a user-prompt queue entry by messageId, searching both the
2318
+ // currentEntry slot and the waiting queue.
2319
+ findUserEntry(messageId) {
2320
+ if (this.currentEntry?.messageId === messageId && this.currentEntry.kind === "user") {
2321
+ return this.currentEntry;
2322
+ }
2323
+ const queued = this.promptQueue.find(
2324
+ (e) => e.messageId === messageId && e.kind === "user"
2325
+ );
2326
+ return queued?.kind === "user" ? queued : void 0;
2179
2327
  }
2180
2328
  // Total visible-or-running entries: the in-flight head (if any) plus
2181
2329
  // the queue's user-visible waiting entries. Internal entries don't
@@ -2188,9 +2336,9 @@ var Session = class {
2188
2336
  }
2189
2337
  return count;
2190
2338
  }
2191
- broadcastQueueAdded(entry) {
2339
+ broadcastQueueAdded(entry, options) {
2192
2340
  const depth = this.visibleQueueDepth();
2193
- const position = Math.max(0, depth - 1);
2341
+ const position = options?.position ?? Math.max(0, depth - 1);
2194
2342
  const params = {
2195
2343
  sessionId: this.sessionId,
2196
2344
  messageId: entry.messageId,
@@ -2200,6 +2348,11 @@ var Session = class {
2200
2348
  queueDepth: depth,
2201
2349
  enqueuedAt: entry.enqueuedAt
2202
2350
  };
2351
+ if (options?.amending !== void 0) {
2352
+ params._meta = {
2353
+ "hydra-acp": { amending: options.amending }
2354
+ };
2355
+ }
2203
2356
  this.broadcastQueueNotification("hydra-acp/prompt_queue_added", params);
2204
2357
  }
2205
2358
  broadcastQueueUpdated(messageId, prompt) {
@@ -2322,6 +2475,9 @@ var Session = class {
2322
2475
  this.broadcastQueueRemoved(messageId, "cancelled");
2323
2476
  this.persistRewrite();
2324
2477
  }
2478
+ if (this.amendInProgress?.newMessageId === messageId) {
2479
+ this.amendInProgress = void 0;
2480
+ }
2325
2481
  entry.resolve({ stopReason: "cancelled" });
2326
2482
  return { cancelled: true, reason: "ok" };
2327
2483
  }
@@ -2343,6 +2499,143 @@ var Session = class {
2343
2499
  this.persistRewrite();
2344
2500
  return { updated: true, reason: "ok" };
2345
2501
  }
2502
+ // Amend the head prompt: cancel the in-flight turn and submit a
2503
+ // replacement that sits at the head of the queue. Resolves the
2504
+ // request immediately (the caller doesn't wait on cancel-settle).
2505
+ // Honours race outcomes — if the target finished or was cancelled
2506
+ // before this arrived, the request resolves with an outcome explaining
2507
+ // why and (depending on onTargetCompleted) optionally forwards as a
2508
+ // plain prompt. Queued targets are edited in place (same machinery
2509
+ // as updateQueuedPrompt).
2510
+ amendPrompt(clientId, params) {
2511
+ const client = this.clients.get(clientId);
2512
+ if (!client) {
2513
+ throw withCode(
2514
+ new Error("client not attached"),
2515
+ JsonRpcErrorCodes.SessionNotFound
2516
+ );
2517
+ }
2518
+ const { targetMessageId, prompt, replaceQueue, onTargetCompleted } = params;
2519
+ if (this.currentEntry?.messageId === targetMessageId && this.currentEntry.kind === "user" && !this.currentEntry.cancelled && this.amendInProgress === void 0) {
2520
+ return this.amendOnHead(client, prompt, targetMessageId, replaceQueue);
2521
+ }
2522
+ const queuedEntry = this.promptQueue.find(
2523
+ (e) => e.messageId === targetMessageId && e.kind === "user"
2524
+ );
2525
+ if (queuedEntry && queuedEntry.kind === "user" && !queuedEntry.cancelled) {
2526
+ queuedEntry.prompt = prompt;
2527
+ this.broadcastQueueUpdated(targetMessageId, prompt);
2528
+ this.persistRewrite();
2529
+ return { amended: true, reason: "ok", messageId: targetMessageId };
2530
+ }
2531
+ const terminal = this.recentlyTerminal.get(targetMessageId);
2532
+ if (terminal) {
2533
+ if (terminal.stopReason === "cancelled") {
2534
+ return { amended: false, reason: "target_cancelled" };
2535
+ }
2536
+ if (onTargetCompleted === "send_anyway") {
2537
+ const newMessageId = this.enqueueAmendmentAsFollowUp(client, prompt);
2538
+ return {
2539
+ amended: false,
2540
+ reason: "target_completed",
2541
+ messageId: newMessageId
2542
+ };
2543
+ }
2544
+ return { amended: false, reason: "target_completed" };
2545
+ }
2546
+ return { amended: false, reason: "target_not_found" };
2547
+ }
2548
+ // Head-of-queue amendment: splice M2 in front of any waiting entries,
2549
+ // broadcast the amend window's queue_added with the amending hint,
2550
+ // mark amendInProgress so the cancelled turn's broadcastTurnComplete
2551
+ // attaches the _meta marker and fires prompt_amended, then fire the
2552
+ // upstream session/cancel without awaiting it. drainQueue is already
2553
+ // running on the head; when its session/prompt returns, it advances
2554
+ // to M2 in the normal way.
2555
+ amendOnHead(client, prompt, targetMessageId, replaceQueue) {
2556
+ const newMessageId = generateMessageId();
2557
+ const originator = { clientId: client.clientId };
2558
+ if (client.clientInfo?.name) {
2559
+ originator.name = client.clientInfo.name;
2560
+ }
2561
+ if (client.clientInfo?.version) {
2562
+ originator.version = client.clientInfo.version;
2563
+ }
2564
+ if (replaceQueue) {
2565
+ const survivors = [];
2566
+ for (const entry2 of this.promptQueue) {
2567
+ if (entry2.kind === "user" && !entry2.cancelled) {
2568
+ entry2.cancelled = true;
2569
+ this.broadcastQueueRemoved(entry2.messageId, "cancelled");
2570
+ entry2.resolve({ stopReason: "cancelled" });
2571
+ continue;
2572
+ }
2573
+ survivors.push(entry2);
2574
+ }
2575
+ this.promptQueue = survivors;
2576
+ }
2577
+ const entry = {
2578
+ kind: "user",
2579
+ messageId: newMessageId,
2580
+ originator,
2581
+ clientId: client.clientId,
2582
+ prompt,
2583
+ enqueuedAt: Date.now(),
2584
+ cancelled: false,
2585
+ wasAmend: true,
2586
+ // No-op resolve/reject: there's no client request awaiting M2's
2587
+ // session/prompt response. The amend_prompt request has already
2588
+ // returned by this point. drainQueue calls these unconditionally
2589
+ // when runQueueEntry settles; making them no-ops is safe.
2590
+ resolve: () => void 0,
2591
+ reject: () => void 0
2592
+ };
2593
+ this.promptQueue.unshift(entry);
2594
+ this.persistRewrite();
2595
+ this.broadcastQueueAdded(entry, {
2596
+ amending: targetMessageId,
2597
+ position: 1
2598
+ });
2599
+ this.amendInProgress = {
2600
+ cancelledMessageId: targetMessageId,
2601
+ newMessageId
2602
+ };
2603
+ void this.agent.connection.notify("session/cancel", { sessionId: this.upstreamSessionId }).catch(() => void 0);
2604
+ return {
2605
+ amended: true,
2606
+ reason: "ok",
2607
+ messageId: newMessageId
2608
+ };
2609
+ }
2610
+ // Send the amendment as a plain follow-up prompt — used when the
2611
+ // target already completed and the caller opted in to send_anyway.
2612
+ // Returns the new prompt's messageId so the result can surface it.
2613
+ enqueueAmendmentAsFollowUp(client, prompt) {
2614
+ const messageId = generateMessageId();
2615
+ const originator = { clientId: client.clientId };
2616
+ if (client.clientInfo?.name) {
2617
+ originator.name = client.clientInfo.name;
2618
+ }
2619
+ if (client.clientInfo?.version) {
2620
+ originator.version = client.clientInfo.version;
2621
+ }
2622
+ const entry = {
2623
+ kind: "user",
2624
+ messageId,
2625
+ originator,
2626
+ clientId: client.clientId,
2627
+ prompt,
2628
+ enqueuedAt: Date.now(),
2629
+ cancelled: false,
2630
+ resolve: () => void 0,
2631
+ reject: () => void 0
2632
+ };
2633
+ this.promptQueue.push(entry);
2634
+ this.persistRewrite();
2635
+ this.broadcastQueueAdded(entry);
2636
+ void this.drainQueue();
2637
+ return messageId;
2638
+ }
2346
2639
  async cancel(clientId) {
2347
2640
  const client = this.clients.get(clientId);
2348
2641
  if (!client) {
@@ -3201,6 +3494,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3201
3494
  try {
3202
3495
  const result = await this.runQueueEntry(next);
3203
3496
  next.resolve(result);
3497
+ await Promise.resolve();
3204
3498
  } catch (err) {
3205
3499
  next.reject(err);
3206
3500
  } finally {
@@ -3237,12 +3531,33 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3237
3531
  }
3238
3532
  );
3239
3533
  } catch (err) {
3240
- this.broadcastTurnComplete(entry.clientId, { stopReason: "error" });
3534
+ this.broadcastTurnComplete(
3535
+ entry.clientId,
3536
+ { stopReason: "error" },
3537
+ entry.messageId,
3538
+ entry.wasAmend
3539
+ );
3540
+ this.clearAmendIfMatches(entry.messageId);
3241
3541
  throw err;
3242
3542
  }
3243
- this.broadcastTurnComplete(entry.clientId, response);
3543
+ this.broadcastTurnComplete(
3544
+ entry.clientId,
3545
+ response,
3546
+ entry.messageId,
3547
+ entry.wasAmend
3548
+ );
3549
+ this.clearAmendIfMatches(entry.messageId);
3244
3550
  return response;
3245
3551
  }
3552
+ // Clear amendInProgress once the cancelled turn's task has fully
3553
+ // settled. broadcastTurnComplete needs the marker still set when it
3554
+ // fires, so the clear must happen *after*. Called from runQueueEntry's
3555
+ // settle path for both success and error.
3556
+ clearAmendIfMatches(messageId) {
3557
+ if (this.amendInProgress?.cancelledMessageId === messageId) {
3558
+ this.amendInProgress = void 0;
3559
+ }
3560
+ }
3246
3561
  };
3247
3562
  function withCode(err, code) {
3248
3563
  err.code = code;
@@ -4257,6 +4572,23 @@ var SessionManager = class {
4257
4572
  get(sessionId) {
4258
4573
  return this.sessions.get(sessionId);
4259
4574
  }
4575
+ // Snapshot of which agent versions are currently in use by live
4576
+ // sessions, keyed by agentId. Read by the registry-fetch prune sweep
4577
+ // so it can skip install dirs that still back a running process.
4578
+ activeAgentVersions() {
4579
+ const out = /* @__PURE__ */ new Map();
4580
+ for (const session of this.sessions.values()) {
4581
+ const id = session.agent.agentId;
4582
+ const version = session.agent.version;
4583
+ let set = out.get(id);
4584
+ if (!set) {
4585
+ set = /* @__PURE__ */ new Set();
4586
+ out.set(id, set);
4587
+ }
4588
+ set.add(version);
4589
+ }
4590
+ return out;
4591
+ }
4260
4592
  // Resolve a user-typed session id (which may have the hydra_session_
4261
4593
  // prefix stripped — that's what `sessions list` and the picker show) to
4262
4594
  // the canonical form that actually exists. Tries the input as-given
@@ -5230,9 +5562,85 @@ function withCode2(err, code) {
5230
5562
  return err;
5231
5563
  }
5232
5564
 
5565
+ // src/core/agent-prune.ts
5566
+ import * as fsp4 from "fs/promises";
5567
+ import * as path8 from "path";
5568
+ var logSink3 = (msg) => {
5569
+ process.stderr.write(msg + "\n");
5570
+ };
5571
+ function setAgentPruneLogger(log) {
5572
+ logSink3 = log ?? ((msg) => process.stderr.write(msg + "\n"));
5573
+ }
5574
+ async function pruneStaleAgentVersions(registry, sessionManager) {
5575
+ const platformKey = currentPlatformKey();
5576
+ if (!platformKey) {
5577
+ return;
5578
+ }
5579
+ const doc = await registry.load();
5580
+ const desiredByAgent = /* @__PURE__ */ new Map();
5581
+ for (const a of doc.agents) {
5582
+ desiredByAgent.set(a.id, a.version ?? "current");
5583
+ }
5584
+ const activeByAgent = sessionManager.activeAgentVersions();
5585
+ const platformDir = path8.join(paths.agentsDir(), platformKey);
5586
+ let agentEntries;
5587
+ try {
5588
+ agentEntries = await fsp4.readdir(platformDir, { withFileTypes: true });
5589
+ } catch (err) {
5590
+ const e = err;
5591
+ if (e.code === "ENOENT") {
5592
+ return;
5593
+ }
5594
+ logSink3(`hydra-acp: prune: failed to read ${platformDir}: ${e.message}`);
5595
+ return;
5596
+ }
5597
+ for (const agentEntry of agentEntries) {
5598
+ if (!agentEntry.isDirectory()) {
5599
+ continue;
5600
+ }
5601
+ const agentId = agentEntry.name;
5602
+ const desired = desiredByAgent.get(agentId);
5603
+ if (desired === void 0) {
5604
+ continue;
5605
+ }
5606
+ const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
5607
+ const agentDir = path8.join(platformDir, agentId);
5608
+ let versionEntries;
5609
+ try {
5610
+ versionEntries = await fsp4.readdir(agentDir, { withFileTypes: true });
5611
+ } catch (err) {
5612
+ logSink3(
5613
+ `hydra-acp: prune: failed to read ${agentDir}: ${err.message}`
5614
+ );
5615
+ continue;
5616
+ }
5617
+ for (const versionEntry of versionEntries) {
5618
+ if (!versionEntry.isDirectory()) {
5619
+ continue;
5620
+ }
5621
+ const version = versionEntry.name;
5622
+ if (version === desired) {
5623
+ continue;
5624
+ }
5625
+ if (activeVersions.has(version)) {
5626
+ continue;
5627
+ }
5628
+ const versionDir = path8.join(agentDir, version);
5629
+ try {
5630
+ await fsp4.rm(versionDir, { recursive: true, force: true });
5631
+ logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
5632
+ } catch (err) {
5633
+ logSink3(
5634
+ `hydra-acp: prune: failed to remove ${versionDir}: ${err.message}`
5635
+ );
5636
+ }
5637
+ }
5638
+ }
5639
+ }
5640
+
5233
5641
  // src/core/session-tokens.ts
5234
5642
  import * as fs12 from "fs/promises";
5235
- import * as path8 from "path";
5643
+ import * as path9 from "path";
5236
5644
  import { createHash, randomBytes, timingSafeEqual } from "crypto";
5237
5645
  var TOKEN_PREFIX = "hydra_session_";
5238
5646
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
@@ -5240,7 +5648,7 @@ var ID_LENGTH = 12;
5240
5648
  var TOKEN_BYTES = 32;
5241
5649
  var WRITE_DEBOUNCE_MS = 50;
5242
5650
  function tokensFilePath() {
5243
- return path8.join(paths.home(), "session-tokens.json");
5651
+ return path9.join(paths.home(), "session-tokens.json");
5244
5652
  }
5245
5653
  function sha256Hex(input) {
5246
5654
  return createHash("sha256").update(input).digest("hex");
@@ -5883,7 +6291,16 @@ function mapModel(u) {
5883
6291
  }
5884
6292
  function mapTurnComplete(u) {
5885
6293
  const stopReason = readString(u, "stopReason");
5886
- return stopReason !== void 0 ? { kind: "turn-complete", stopReason } : { kind: "turn-complete" };
6294
+ const meta = u._meta;
6295
+ const amended = meta?.["hydra-acp"]?.amended !== void 0 && meta["hydra-acp"].amended !== null;
6296
+ const out = { kind: "turn-complete" };
6297
+ if (stopReason !== void 0) {
6298
+ out.stopReason = stopReason;
6299
+ }
6300
+ if (amended) {
6301
+ out.amended = true;
6302
+ }
6303
+ return out;
5887
6304
  }
5888
6305
  function extractContentText(content) {
5889
6306
  if (typeof content === "string") {
@@ -6536,12 +6953,12 @@ import { z as z6 } from "zod";
6536
6953
 
6537
6954
  // src/core/password.ts
6538
6955
  import * as fs13 from "fs/promises";
6539
- import * as path9 from "path";
6956
+ import * as path10 from "path";
6540
6957
  import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
6541
6958
  import { promisify } from "util";
6542
6959
  var scryptAsync = promisify(scrypt);
6543
6960
  function passwordHashPath() {
6544
- return path9.join(paths.home(), "password-hash");
6961
+ return path10.join(paths.home(), "password-hash");
6545
6962
  }
6546
6963
  var DEFAULT_N = 1 << 15;
6547
6964
  var MAX_MEM = 128 * 1024 * 1024;
@@ -7002,6 +7419,22 @@ function registerAcpWsEndpoint(app, deps) {
7002
7419
  }
7003
7420
  return session.updateQueuedPrompt(params.messageId, params.prompt);
7004
7421
  });
7422
+ connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
7423
+ const params = AmendPromptParams.parse(raw);
7424
+ const att = state.attached.get(params.sessionId);
7425
+ if (!att) {
7426
+ const err = new Error("not attached to session");
7427
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7428
+ throw err;
7429
+ }
7430
+ const session = deps.manager.get(params.sessionId);
7431
+ if (!session) {
7432
+ const err = new Error(`session ${params.sessionId} not found`);
7433
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7434
+ throw err;
7435
+ }
7436
+ return session.amendPrompt(att.clientId, params);
7437
+ });
7005
7438
  connection.onRequest("session/load", async (raw) => {
7006
7439
  const rawObj = raw ?? {};
7007
7440
  const rawSessionId = typeof rawObj.sessionId === "string" ? rawObj.sessionId : void 0;
@@ -7154,10 +7587,17 @@ function buildInitializeResult() {
7154
7587
  ],
7155
7588
  // Advertise hydra-only capabilities via _meta["hydra-acp"]. Generic
7156
7589
  // ACP clients ignore the field; capability-aware clients learn here
7157
- // that hydra accepts concurrent session/prompt requests and emits
7158
- // prompt_queue_* notifications so they can stop running their own
7159
- // local queue.
7160
- _meta: mergeMeta(void 0, { promptQueueing: true })
7590
+ // which hydra-acp extensions the daemon supports so they can gate
7591
+ // UI surface accordingly. promptPipelining is false until the
7592
+ // streaming-input probe lands (Option A in the steering brief);
7593
+ // the others are unconditional method-availability flags.
7594
+ _meta: mergeMeta(void 0, {
7595
+ promptQueueing: true,
7596
+ promptCancelling: true,
7597
+ promptUpdating: true,
7598
+ promptAmending: true,
7599
+ promptPipelining: false
7600
+ })
7161
7601
  };
7162
7602
  }
7163
7603
  function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
@@ -7174,10 +7614,10 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
7174
7614
  async function startDaemon(config, serviceToken) {
7175
7615
  ensureLoopbackOrTls(config);
7176
7616
  const httpsOptions = config.daemon.tls ? {
7177
- key: await fsp4.readFile(config.daemon.tls.key),
7178
- cert: await fsp4.readFile(config.daemon.tls.cert)
7617
+ key: await fsp5.readFile(config.daemon.tls.key),
7618
+ cert: await fsp5.readFile(config.daemon.tls.cert)
7179
7619
  } : void 0;
7180
- await fsp4.mkdir(paths.home(), { recursive: true });
7620
+ await fsp5.mkdir(paths.home(), { recursive: true });
7181
7621
  const { stream: logStream, fileStream } = await buildLogStream(
7182
7622
  config.daemon.logLevel
7183
7623
  );
@@ -7221,7 +7661,12 @@ async function startDaemon(config, serviceToken) {
7221
7661
  5 * 60 * 1e3
7222
7662
  );
7223
7663
  sweepInterval.unref();
7224
- const registry = new Registry(config);
7664
+ const registry = new Registry(config, {
7665
+ onFetched: () => {
7666
+ void pruneStaleAgentVersions(registry, manager);
7667
+ }
7668
+ });
7669
+ setAgentPruneLogger((msg) => app.log.info(msg));
7225
7670
  const agentLogger = {
7226
7671
  info: (msg) => app.log.info(msg),
7227
7672
  warn: (msg) => app.log.warn(msg)
@@ -7262,8 +7707,8 @@ async function startDaemon(config, serviceToken) {
7262
7707
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
7263
7708
  const address = app.server.address();
7264
7709
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
7265
- await fsp4.mkdir(paths.home(), { recursive: true });
7266
- await fsp4.writeFile(
7710
+ await fsp5.mkdir(paths.home(), { recursive: true });
7711
+ await fsp5.writeFile(
7267
7712
  paths.pidFile(),
7268
7713
  JSON.stringify({
7269
7714
  pid: process.pid,
@@ -7297,6 +7742,7 @@ async function startDaemon(config, serviceToken) {
7297
7742
  await manager.flushMetaWrites();
7298
7743
  setBinaryInstallLogger(null);
7299
7744
  setNpmInstallLogger(null);
7745
+ setAgentPruneLogger(null);
7300
7746
  await app.close();
7301
7747
  try {
7302
7748
  fs14.unlinkSync(paths.pidFile());