@hydra-acp/cli 0.1.7 → 0.1.9

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
- import * as fs9 from "fs";
3
- import * as fsp3 from "fs/promises";
2
+ import * as fs10 from "fs";
3
+ import * as fsp4 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
6
6
  import pino from "pino";
@@ -43,6 +43,18 @@ var paths = {
43
43
  // machine's binaries cleanly separated. `ls agents/` immediately
44
44
  // shows which platforms have ever installed anything.
45
45
  agentInstallDir: (id, platformKey, version) => path.join(hydraHome(), "agents", platformKey, id, version),
46
+ // npm install cache for npx-distributed agents. The trailing
47
+ // node<ABI> segment keys on process.versions.modules so a Node
48
+ // major bump (different ABI → native modules incompatible) yields
49
+ // a fresh install rather than failing at require() time.
50
+ agentNpmInstallDir: (id, platformKey, version) => path.join(
51
+ hydraHome(),
52
+ "agents",
53
+ platformKey,
54
+ id,
55
+ version,
56
+ `node${process.versions.modules}`
57
+ ),
46
58
  sessionsDir: () => path.join(hydraHome(), "sessions"),
47
59
  // One directory per session id under sessions/. Co-locates the
48
60
  // session record, its transcript, and any future per-session state
@@ -69,7 +81,16 @@ var DaemonConfig = z.object({
69
81
  authToken: z.string().min(16),
70
82
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
71
83
  tls: TlsConfig.optional(),
72
- sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600)
84
+ sessionIdleTimeoutSeconds: z.number().int().nonnegative().default(3600),
85
+ // Cap on entries kept in a session's on-disk replay log (history.jsonl).
86
+ // Compaction trims to this many on a periodic basis; reads also slice
87
+ // to the tail at this length as a defensive measure against older
88
+ // daemons that may have written unbounded files.
89
+ sessionHistoryMaxEntries: z.number().int().positive().default(1e3),
90
+ // Bytes of trailing agent stderr buffered per AgentInstance so the
91
+ // daemon can include it in the diagnostic message when a spawn fails.
92
+ // Bump if your agents emit large tracebacks you want surfaced.
93
+ agentStderrTailBytes: z.number().int().positive().default(4096)
73
94
  });
74
95
  var RegistryConfig = z.object({
75
96
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -92,7 +113,14 @@ var TuiConfig = z.object({
92
113
  // text selection requires shift+drag to bypass mouse reporting. Set
93
114
  // false to disable capture — wheel scrollback stops working, but
94
115
  // plain click-drag selects text via the terminal emulator.
95
- mouse: z.boolean().default(true)
116
+ mouse: z.boolean().default(true),
117
+ // Size at which the TUI's session/update debug log (tui.log) rotates
118
+ // to tui.log.0 and resets. Bounds on-disk use at ~2x this value.
119
+ logMaxBytes: z.number().int().positive().default(5 * 1024 * 1024),
120
+ // Width cap on the cwd column in the `sessions list` output and the
121
+ // TUI picker. Set higher if you keep deeply-nested working directories
122
+ // and want them visible; the elastic title column shrinks to make room.
123
+ cwdColumnMaxWidth: z.number().int().positive().default(24)
96
124
  });
97
125
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
98
126
  var ExtensionBody = z.object({
@@ -128,9 +156,14 @@ var HydraConfig = z.object({
128
156
  tui: TuiConfig.default({
129
157
  repaintThrottleMs: 1e3,
130
158
  maxScrollbackLines: 1e4,
131
- mouse: true
159
+ mouse: true,
160
+ logMaxBytes: 5 * 1024 * 1024,
161
+ cwdColumnMaxWidth: 24
132
162
  })
133
163
  });
164
+ var HydraConfigReadOnly = HydraConfig.extend({
165
+ daemon: DaemonConfig.omit({ authToken: true })
166
+ });
134
167
  function extensionList(config) {
135
168
  return Object.entries(config.extensions).map(([name, body]) => ({
136
169
  name,
@@ -472,9 +505,129 @@ async function fileExists(p) {
472
505
  }
473
506
  }
474
507
 
508
+ // src/core/npm-install.ts
509
+ import * as fsp2 from "fs/promises";
510
+ import * as path3 from "path";
511
+ import { spawn as spawn2 } from "child_process";
512
+ var logSink2 = (msg) => {
513
+ process.stderr.write(msg + "\n");
514
+ };
515
+ function setNpmInstallLogger(log) {
516
+ logSink2 = log ?? ((msg) => process.stderr.write(msg + "\n"));
517
+ }
518
+ async function ensureNpmPackage(args) {
519
+ const platformKey = currentPlatformKey();
520
+ if (!platformKey) {
521
+ throw new Error(
522
+ `Agent ${args.agentId}: cannot determine platform key for ${process.platform}/${process.arch}`
523
+ );
524
+ }
525
+ const installDir = paths.agentNpmInstallDir(
526
+ args.agentId,
527
+ platformKey,
528
+ args.version
529
+ );
530
+ const binPath = path3.join(installDir, "node_modules", ".bin", args.bin);
531
+ if (await fileExists2(binPath)) {
532
+ return binPath;
533
+ }
534
+ await installInto({
535
+ agentId: args.agentId,
536
+ packageSpec: args.packageSpec,
537
+ installDir
538
+ });
539
+ if (!await fileExists2(binPath)) {
540
+ throw new Error(
541
+ `Agent ${args.agentId}: npm install of ${args.packageSpec} did not produce bin ${args.bin} (looked in ${installDir}/node_modules/.bin/)`
542
+ );
543
+ }
544
+ return binPath;
545
+ }
546
+ async function installInto(args) {
547
+ await fsp2.mkdir(path3.dirname(args.installDir), { recursive: true });
548
+ const tempDir = await fsp2.mkdtemp(`${args.installDir}.partial-`);
549
+ try {
550
+ logSink2(
551
+ `hydra-acp: installing ${args.packageSpec} for ${args.agentId} into ${tempDir}`
552
+ );
553
+ await runNpmInstall({
554
+ packageSpec: args.packageSpec,
555
+ cwd: tempDir
556
+ });
557
+ try {
558
+ await fsp2.rename(tempDir, args.installDir);
559
+ } catch (err) {
560
+ const e = err;
561
+ if ((e.code === "EEXIST" || e.code === "ENOTEMPTY") && await fileExists2(args.installDir)) {
562
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
563
+ () => void 0
564
+ );
565
+ return;
566
+ }
567
+ throw err;
568
+ }
569
+ logSink2(`hydra-acp: installed ${args.agentId} to ${args.installDir}`);
570
+ } catch (err) {
571
+ await fsp2.rm(tempDir, { recursive: true, force: true }).catch(
572
+ () => void 0
573
+ );
574
+ throw err;
575
+ }
576
+ }
577
+ function runNpmInstall(args) {
578
+ return new Promise((resolve3, reject) => {
579
+ const child = spawn2(
580
+ "npm",
581
+ ["install", "--no-audit", "--no-fund", "--silent", args.packageSpec],
582
+ {
583
+ cwd: args.cwd,
584
+ stdio: ["ignore", "pipe", "pipe"]
585
+ }
586
+ );
587
+ let stderrTail = "";
588
+ child.stdout?.on("data", (chunk) => {
589
+ void chunk;
590
+ });
591
+ child.stderr?.setEncoding("utf8");
592
+ child.stderr?.on("data", (chunk) => {
593
+ stderrTail = (stderrTail + chunk).slice(-4096);
594
+ });
595
+ child.on("error", (err) => {
596
+ const msg = err.code === "ENOENT" ? `npm not found on PATH (install Node.js / npm, or use a binary-distributed agent)` : err.message;
597
+ reject(new Error(msg));
598
+ });
599
+ child.on("exit", (code, signal) => {
600
+ if (code === 0) {
601
+ resolve3();
602
+ return;
603
+ }
604
+ const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
605
+ const tail = stderrTail.trim();
606
+ reject(
607
+ new Error(
608
+ tail ? `npm install ${args.packageSpec} failed (${reason})
609
+ stderr: ${tail}` : `npm install ${args.packageSpec} failed (${reason})`
610
+ )
611
+ );
612
+ });
613
+ });
614
+ }
615
+ async function fileExists2(p) {
616
+ try {
617
+ await fsp2.access(p);
618
+ return true;
619
+ } catch {
620
+ return false;
621
+ }
622
+ }
623
+
475
624
  // src/core/registry.ts
476
625
  var NpxDistribution = z2.object({
477
626
  package: z2.string(),
627
+ // The bin to invoke after install. Defaults to the package basename
628
+ // (e.g. "claude-code" for "@anthropic-ai/claude-code"). Required when
629
+ // the package exposes a bin name that differs from its basename.
630
+ bin: z2.string().optional(),
478
631
  args: z2.array(z2.string()).optional(),
479
632
  env: z2.record(z2.string()).optional()
480
633
  });
@@ -638,9 +791,23 @@ async function planSpawn(agent, callerArgs = []) {
638
791
  if (agent.distribution.npx) {
639
792
  const npx = agent.distribution.npx;
640
793
  const tail = callerArgs.length > 0 ? callerArgs : npx.args ?? [];
794
+ if (process.env.HYDRA_ACP_SKIP_NPM_PREFETCH) {
795
+ return {
796
+ command: "npx",
797
+ args: ["-y", npx.package, ...tail],
798
+ env: npx.env ?? {}
799
+ };
800
+ }
801
+ const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
802
+ const binPath = await ensureNpmPackage({
803
+ agentId: agent.id,
804
+ version: agent.version ?? "current",
805
+ packageSpec: npx.package,
806
+ bin
807
+ });
641
808
  return {
642
- command: "npx",
643
- args: ["-y", npx.package, ...tail],
809
+ command: binPath,
810
+ args: tail,
644
811
  env: npx.env ?? {}
645
812
  };
646
813
  }
@@ -675,26 +842,34 @@ async function planSpawn(agent, callerArgs = []) {
675
842
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
676
843
  }
677
844
 
678
- // src/core/session-manager.ts
679
- import * as fs7 from "fs/promises";
680
- import { customAlphabet as customAlphabet3 } from "nanoid";
681
-
682
845
  // src/core/agent-instance.ts
683
- import { spawn as spawn2 } from "child_process";
846
+ import { spawn as spawn3 } from "child_process";
684
847
 
685
848
  // src/acp/types.ts
686
849
  import { z as z3 } from "zod";
850
+ var ACP_PROTOCOL_VERSION = 1;
687
851
  var JsonRpcErrorCodes = {
688
852
  ParseError: -32700,
689
853
  InvalidRequest: -32600,
690
854
  MethodNotFound: -32601,
691
855
  InvalidParams: -32602,
692
856
  InternalError: -32603,
857
+ // -32001…-32003 reserved for RFD #533 attach semantics:
858
+ // -32001 Session not found
859
+ // -32002 Not authorised to attach
860
+ // -32003 Session does not support multi-client attach
861
+ // We emit -32001 (matching); the other two are reserved for spec
862
+ // alignment even though we don't currently emit them (we bearer-auth
863
+ // at WS upgrade time and always support multi-client attach).
693
864
  SessionNotFound: -32001,
694
- PermissionDenied: -32002,
695
- AlreadyAttached: -32003,
865
+ NotAuthorisedToAttach: -32002,
866
+ MultiClientNotSupported: -32003,
696
867
  AgentNotInstalled: -32005,
697
- BundleAlreadyImported: -32010
868
+ // Hydra-internal codes — outside the RFD's reserved range so they
869
+ // can't collide with future spec assignments.
870
+ BundleAlreadyImported: -32010,
871
+ PermissionDenied: -32011,
872
+ AlreadyAttached: -32012
698
873
  };
699
874
  var InitializeParams = z3.object({
700
875
  protocolVersion: z3.number().optional(),
@@ -704,7 +879,12 @@ var InitializeParams = z3.object({
704
879
  version: z3.string().optional()
705
880
  }).optional()
706
881
  });
707
- var HistoryPolicy = z3.enum(["full", "pending_only", "none"]);
882
+ var HistoryPolicy = z3.enum([
883
+ "full",
884
+ "pending_only",
885
+ "none",
886
+ "after_message"
887
+ ]);
708
888
  var SessionNewParams = z3.object({
709
889
  cwd: z3.string(),
710
890
  agentId: z3.string().optional(),
@@ -720,6 +900,18 @@ var SessionResumeHints = z3.object({
720
900
  var SessionAttachParams = z3.object({
721
901
  sessionId: z3.string(),
722
902
  historyPolicy: HistoryPolicy.default("full"),
903
+ // Required when historyPolicy is "after_message"; ignored otherwise.
904
+ // The proxy replays history entries strictly after the entry whose
905
+ // messageId matches this value. If the id isn't found in the buffer,
906
+ // the response.historyPolicy field surfaces "full" so the caller
907
+ // knows we fell back. Per RFD #533.
908
+ afterMessageId: z3.string().optional(),
909
+ // Caller-assigned opaque id (e.g. a UUID). When provided, the proxy
910
+ // echoes it in resolvedBy/sentBy and lifecycle events so other
911
+ // clients can disambiguate multiple instances of the same
912
+ // clientInfo.name. When omitted, the proxy assigns one and returns
913
+ // it in the response. Per RFD #533.
914
+ clientId: z3.string().optional(),
723
915
  clientInfo: z3.object({
724
916
  name: z3.string(),
725
917
  version: z3.string().optional()
@@ -1013,6 +1205,13 @@ var JsonRpcConnection = class _JsonRpcConnection {
1013
1205
  }
1014
1206
  await this.stream.close();
1015
1207
  }
1208
+ // Force-close with an error. Rejects all pending requests and fires
1209
+ // close handlers carrying `err`. Used by transports that detect a
1210
+ // failure (e.g. child process crash, spawn ENOENT) the stream itself
1211
+ // can't surface as a stdout/stdin error.
1212
+ fail(err) {
1213
+ this.handleClose(err);
1214
+ }
1016
1215
  handleIncoming(message) {
1017
1216
  if ("method" in message) {
1018
1217
  if ("id" in message && message.id !== void 0) {
@@ -1105,17 +1304,22 @@ var JsonRpcConnection = class _JsonRpcConnection {
1105
1304
  };
1106
1305
 
1107
1306
  // src/core/agent-instance.ts
1307
+ var DEFAULT_STDERR_TAIL_BYTES = 4096;
1108
1308
  var AgentInstance = class _AgentInstance {
1109
1309
  agentId;
1110
1310
  cwd;
1111
1311
  connection;
1112
1312
  child;
1113
1313
  exited = false;
1314
+ killed = false;
1315
+ stderrTail = "";
1316
+ stderrTailBytes;
1114
1317
  exitHandlers = [];
1115
1318
  constructor(opts, child) {
1116
1319
  this.agentId = opts.agentId;
1117
1320
  this.cwd = opts.cwd;
1118
1321
  this.child = child;
1322
+ this.stderrTailBytes = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL_BYTES;
1119
1323
  if (!child.stdout || !child.stdin) {
1120
1324
  throw new Error("agent subprocess missing stdio");
1121
1325
  }
@@ -1123,22 +1327,36 @@ var AgentInstance = class _AgentInstance {
1123
1327
  this.connection = new JsonRpcConnection(stream);
1124
1328
  child.stderr?.setEncoding("utf8");
1125
1329
  child.stderr?.on("data", (chunk) => {
1330
+ this.stderrTail = (this.stderrTail + chunk).slice(-this.stderrTailBytes);
1126
1331
  process.stderr.write(`[${opts.agentId}] ${chunk}`);
1127
1332
  });
1333
+ child.on("error", (err) => {
1334
+ const msg = this.formatFailure(err.message);
1335
+ this.connection.fail(new Error(msg));
1336
+ });
1128
1337
  child.on("exit", (code, signal) => {
1129
1338
  this.exited = true;
1339
+ if (!this.killed) {
1340
+ const reason = `agent ${opts.agentId} exited before responding (code=${code} signal=${signal})`;
1341
+ this.connection.fail(new Error(this.formatFailure(reason)));
1342
+ }
1130
1343
  for (const handler of this.exitHandlers) {
1131
1344
  handler(code, signal);
1132
1345
  }
1133
1346
  });
1134
1347
  }
1348
+ formatFailure(reason) {
1349
+ const tail = this.stderrTail.trim();
1350
+ return tail ? `${reason}
1351
+ stderr: ${tail}` : reason;
1352
+ }
1135
1353
  static spawn(opts) {
1136
1354
  const env = {
1137
1355
  ...process.env,
1138
1356
  ...opts.plan.env,
1139
1357
  ...opts.extraEnv ?? {}
1140
1358
  };
1141
- const child = spawn2(opts.plan.command, opts.plan.args, {
1359
+ const child = spawn3(opts.plan.command, opts.plan.args, {
1142
1360
  cwd: opts.cwd,
1143
1361
  env,
1144
1362
  stdio: ["pipe", "pipe", "pipe"]
@@ -1155,11 +1373,17 @@ var AgentInstance = class _AgentInstance {
1155
1373
  if (this.exited) {
1156
1374
  return;
1157
1375
  }
1376
+ this.killed = true;
1158
1377
  await this.connection.close().catch(() => void 0);
1159
1378
  this.child.kill(signal);
1160
1379
  }
1161
1380
  };
1162
1381
 
1382
+ // src/core/session-manager.ts
1383
+ import * as fs8 from "fs/promises";
1384
+ import * as os2 from "os";
1385
+ import { customAlphabet as customAlphabet3 } from "nanoid";
1386
+
1163
1387
  // src/core/session.ts
1164
1388
  import { customAlphabet } from "nanoid";
1165
1389
 
@@ -1189,8 +1413,10 @@ function hydraCommandsAsAdvertised() {
1189
1413
  var HYDRA_ID_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
1190
1414
  var generateHydraId = customAlphabet(HYDRA_ID_ALPHABET, 16);
1191
1415
  var HYDRA_SESSION_PREFIX = "hydra_session_";
1192
- var MAX_HISTORY_ENTRIES = 1e3;
1193
- var COMPACT_EVERY = 200;
1416
+ function generateMessageId() {
1417
+ return `m_${generateHydraId()}`;
1418
+ }
1419
+ var DEFAULT_HISTORY_MAX_ENTRIES = 1e3;
1194
1420
  var Session = class {
1195
1421
  sessionId;
1196
1422
  cwd;
@@ -1232,11 +1458,13 @@ var Session = class {
1232
1458
  // Bumped by broadcastPromptReceived, cleared by broadcastTurnComplete.
1233
1459
  // Drives the mid-turn elapsed counter delivered to fresh attachers.
1234
1460
  promptStartedAt;
1235
- // Counts appends since the last compaction. When it hits COMPACT_EVERY
1461
+ // Counts appends since the last compaction. When it hits compactEvery
1236
1462
  // we ask the history store to trim the file to the most recent
1237
- // MAX_HISTORY_ENTRIES. Keeps file growth bounded without per-append
1463
+ // historyMaxEntries. Keeps file growth bounded without per-append
1238
1464
  // file-size checks.
1239
1465
  appendCount = 0;
1466
+ historyMaxEntries;
1467
+ compactEvery;
1240
1468
  // Permission requests that have been broadcast to one or more
1241
1469
  // clients but have not yet resolved. Replayed to clients that
1242
1470
  // attach mid-flight so a late joiner sees the prompt instead of an
@@ -1293,6 +1521,8 @@ var Session = class {
1293
1521
  this.firstPromptSeeded = true;
1294
1522
  }
1295
1523
  this.historyStore = init.historyStore;
1524
+ this.historyMaxEntries = init.historyMaxEntries ?? DEFAULT_HISTORY_MAX_ENTRIES;
1525
+ this.compactEvery = Math.max(1, Math.floor(this.historyMaxEntries * 0.2));
1296
1526
  this.updatedAt = Date.now();
1297
1527
  this.createdAt = init.createdAt ?? this.updatedAt;
1298
1528
  this.lastRecordedAt = this.updatedAt;
@@ -1360,6 +1590,30 @@ var Session = class {
1360
1590
  get attachedCount() {
1361
1591
  return this.clients.size;
1362
1592
  }
1593
+ // Roster of currently-attached clients, optionally excluding one
1594
+ // clientId. Used by the daemon to populate connectedClients on the
1595
+ // session/attach response (per RFD #533) — the freshly-attaching
1596
+ // client wants to see who else is on the session but not itself in
1597
+ // the list.
1598
+ connectedClients(excludeClientId) {
1599
+ const out = [];
1600
+ for (const client of this.clients.values()) {
1601
+ if (excludeClientId && client.clientId === excludeClientId) {
1602
+ continue;
1603
+ }
1604
+ const entry = {
1605
+ clientId: client.clientId
1606
+ };
1607
+ if (client.clientInfo?.name) {
1608
+ entry.name = client.clientInfo.name;
1609
+ }
1610
+ if (client.clientInfo?.version) {
1611
+ entry.version = client.clientInfo.version;
1612
+ }
1613
+ out.push(entry);
1614
+ }
1615
+ return out;
1616
+ }
1363
1617
  // Wall-clock when the in-flight agent turn began, or undefined when
1364
1618
  // idle. Tracked in-memory by broadcastPromptReceived/broadcastTurnComplete
1365
1619
  // so the daemon can hand a fresh attacher mid-turn the right elapsed
@@ -1391,10 +1645,12 @@ var Session = class {
1391
1645
  };
1392
1646
  }
1393
1647
  // Register a client and (asynchronously) load the replay slice it
1394
- // should receive. Validation errors throw synchronously so callers
1395
- // can rely on either the registration being in effect or having
1396
- // thrown; the disk-load is the only async work.
1397
- attach(client, historyPolicy) {
1648
+ // should receive. Returns both the slice to replay and the actual
1649
+ // historyPolicy applied (which may differ from the requested one
1650
+ // when after_message falls back to full). Validation errors throw
1651
+ // synchronously so callers can rely on either the registration being
1652
+ // in effect or having thrown; the disk-load is the only async work.
1653
+ attach(client, historyPolicy, opts = {}) {
1398
1654
  if (this.closed) {
1399
1655
  throw withCode(
1400
1656
  new Error("session is closed"),
@@ -1410,9 +1666,20 @@ var Session = class {
1410
1666
  this.clients.set(client.clientId, client);
1411
1667
  this.updatedAt = Date.now();
1412
1668
  if (historyPolicy === "none" || historyPolicy === "pending_only") {
1413
- return Promise.resolve([]);
1669
+ return Promise.resolve({ entries: [], appliedPolicy: historyPolicy });
1414
1670
  }
1415
- return this.getHistorySnapshot();
1671
+ return this.loadReplay(historyPolicy, opts);
1672
+ }
1673
+ async loadReplay(historyPolicy, opts) {
1674
+ const all = await this.getHistorySnapshot();
1675
+ if (historyPolicy === "after_message") {
1676
+ const cutoff = opts.afterMessageId ? findMessageIdIndex(all, opts.afterMessageId) : -1;
1677
+ if (cutoff < 0) {
1678
+ return { entries: all, appliedPolicy: "full" };
1679
+ }
1680
+ return { entries: all.slice(cutoff + 1), appliedPolicy: "after_message" };
1681
+ }
1682
+ return { entries: all, appliedPolicy: "full" };
1416
1683
  }
1417
1684
  // Dispatch in-flight permission requests to a freshly-attached
1418
1685
  // client. Called by the daemon's WS handler *after* it finishes
@@ -1424,8 +1691,39 @@ var Session = class {
1424
1691
  }
1425
1692
  }
1426
1693
  detach(clientId) {
1427
- if (this.clients.delete(clientId)) {
1428
- this.updatedAt = Date.now();
1694
+ const leaving = this.clients.get(clientId);
1695
+ if (!leaving) {
1696
+ return;
1697
+ }
1698
+ this.clients.delete(clientId);
1699
+ this.updatedAt = Date.now();
1700
+ this.broadcastClientDisconnected(leaving);
1701
+ }
1702
+ // Notify remaining attached clients that a peer just left, per
1703
+ // RFD #533. Fires for both explicit session/detach and ws-close
1704
+ // teardown (acp-ws calls Session.detach() in both paths). The
1705
+ // notification is broadcast (not recorded) — peer presence is
1706
+ // transient, not part of conversation history.
1707
+ broadcastClientDisconnected(client) {
1708
+ const info = {
1709
+ clientId: client.clientId
1710
+ };
1711
+ if (client.clientInfo?.name) {
1712
+ info.name = client.clientInfo.name;
1713
+ }
1714
+ if (client.clientInfo?.version) {
1715
+ info.version = client.clientInfo.version;
1716
+ }
1717
+ const update = {
1718
+ sessionUpdate: "client_disconnected",
1719
+ client: info,
1720
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1721
+ };
1722
+ for (const peer of this.clients.values()) {
1723
+ void peer.connection.notify("session/update", {
1724
+ sessionId: this.sessionId,
1725
+ update
1726
+ }).catch(() => void 0);
1429
1727
  }
1430
1728
  }
1431
1729
  async prompt(clientId, params) {
@@ -1478,6 +1776,7 @@ var Session = class {
1478
1776
  sessionId: this.sessionId,
1479
1777
  update: {
1480
1778
  sessionUpdate: "prompt_received",
1779
+ messageId: generateMessageId(),
1481
1780
  prompt: promptParams.prompt,
1482
1781
  sentBy
1483
1782
  }
@@ -1503,7 +1802,8 @@ var Session = class {
1503
1802
  broadcastTurnComplete(originatorClientId, response) {
1504
1803
  const stopReason = response && typeof response === "object" && "stopReason" in response && typeof response.stopReason === "string" ? response.stopReason : void 0;
1505
1804
  const update = {
1506
- sessionUpdate: "turn_complete"
1805
+ sessionUpdate: "turn_complete",
1806
+ messageId: generateMessageId()
1507
1807
  };
1508
1808
  if (stopReason !== void 0) {
1509
1809
  update.stopReason = stopReason;
@@ -2120,10 +2420,11 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2120
2420
  recordAndBroadcast(method, params, excludeClientId) {
2121
2421
  const rewritten = this.rewriteForClient(params);
2122
2422
  const recordable = !isStateUpdate(method, rewritten);
2423
+ const broadcast = recordable ? ensureMessageIdOnUpdate(method, rewritten) : rewritten;
2123
2424
  if (recordable) {
2124
2425
  const entry = {
2125
2426
  method,
2126
- params: rewritten,
2427
+ params: broadcast,
2127
2428
  recordedAt: Date.now()
2128
2429
  };
2129
2430
  this.lastRecordedAt = entry.recordedAt;
@@ -2131,9 +2432,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2131
2432
  if (this.historyStore) {
2132
2433
  const store = this.historyStore;
2133
2434
  void store.append(this.sessionId, entry).catch(() => void 0);
2134
- if (this.appendCount >= COMPACT_EVERY) {
2435
+ if (this.appendCount >= this.compactEvery) {
2135
2436
  this.appendCount = 0;
2136
- void store.compact(this.sessionId, MAX_HISTORY_ENTRIES).catch(
2437
+ void store.compact(this.sessionId, this.historyMaxEntries).catch(
2137
2438
  () => void 0
2138
2439
  );
2139
2440
  }
@@ -2151,7 +2452,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2151
2452
  if (excludeClientId && client.clientId === excludeClientId) {
2152
2453
  continue;
2153
2454
  }
2154
- void client.connection.notify(method, rewritten).catch(() => void 0);
2455
+ void client.connection.notify(method, broadcast).catch(() => void 0);
2155
2456
  }
2156
2457
  }
2157
2458
  async handlePermissionRequest(params) {
@@ -2163,11 +2464,13 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2163
2464
  );
2164
2465
  }
2165
2466
  const clientParams = this.rewriteForClient(params);
2467
+ const toolCallId = extractToolCallId(clientParams);
2166
2468
  return new Promise((resolve3, reject) => {
2167
2469
  let settled = false;
2168
2470
  const outbound = [];
2169
2471
  const entry = { addClient: sendTo };
2170
2472
  this.inFlightPermissions.add(entry);
2473
+ const sessionId = this.sessionId;
2171
2474
  const settle = (fn) => {
2172
2475
  if (settled) {
2173
2476
  return;
@@ -2180,22 +2483,25 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2180
2483
  if (settled) {
2181
2484
  return;
2182
2485
  }
2183
- const { id, response } = client.connection.requestWithId(
2486
+ const response = client.connection.request(
2184
2487
  "session/request_permission",
2185
2488
  clientParams
2186
2489
  );
2187
- outbound.push({ client, id });
2490
+ outbound.push({ client });
2188
2491
  void response.then((result) => {
2189
2492
  settle(() => {
2493
+ const update = buildPermissionResolvedUpdate({
2494
+ toolCallId,
2495
+ result,
2496
+ resolver: client
2497
+ });
2190
2498
  for (const o of outbound) {
2191
2499
  if (o.client.clientId === client.clientId) {
2192
2500
  continue;
2193
2501
  }
2194
- void o.client.connection.notify("session/permission_resolved", {
2195
- ...clientParams,
2196
- requestId: o.id,
2197
- resolvedBy: client.clientId,
2198
- result
2502
+ void o.client.connection.notify("session/update", {
2503
+ sessionId,
2504
+ update
2199
2505
  }).catch(() => void 0);
2200
2506
  }
2201
2507
  resolve3(result);
@@ -2308,6 +2614,97 @@ function extractAdvertisedCommands(params) {
2308
2614
  }
2309
2615
  return out;
2310
2616
  }
2617
+ function ensureMessageIdOnUpdate(method, params) {
2618
+ if (method !== "session/update" || !params || typeof params !== "object") {
2619
+ return params;
2620
+ }
2621
+ const p = params;
2622
+ if (!p.update || typeof p.update !== "object" || Array.isArray(p.update)) {
2623
+ return params;
2624
+ }
2625
+ const u = p.update;
2626
+ if (typeof u.messageId === "string") {
2627
+ return params;
2628
+ }
2629
+ return {
2630
+ ...params,
2631
+ update: { ...p.update, messageId: generateMessageId() }
2632
+ };
2633
+ }
2634
+ function findMessageIdIndex(history, target) {
2635
+ for (let i = 0; i < history.length; i++) {
2636
+ const entry = history[i];
2637
+ if (!entry || entry.method !== "session/update") {
2638
+ continue;
2639
+ }
2640
+ const params = entry.params;
2641
+ if (params?.update?.messageId === target) {
2642
+ return i;
2643
+ }
2644
+ }
2645
+ return -1;
2646
+ }
2647
+ function extractToolCallId(params) {
2648
+ if (!params || typeof params !== "object") {
2649
+ return void 0;
2650
+ }
2651
+ const toolCall = params.toolCall;
2652
+ if (!toolCall || typeof toolCall !== "object") {
2653
+ return void 0;
2654
+ }
2655
+ const id = toolCall.toolCallId;
2656
+ return typeof id === "string" ? id : void 0;
2657
+ }
2658
+ function buildPermissionResolvedUpdate(args) {
2659
+ const outcome = extractOutcome(args.result);
2660
+ const update = {
2661
+ sessionUpdate: "permission_resolved"
2662
+ };
2663
+ if (args.toolCallId !== void 0) {
2664
+ update.toolCallId = args.toolCallId;
2665
+ }
2666
+ if (outcome) {
2667
+ update.outcome = outcome;
2668
+ if (outcome.kind === "selected" && typeof outcome.optionId === "string") {
2669
+ update.chosenOptionId = outcome.optionId;
2670
+ }
2671
+ }
2672
+ update.resolvedBy = buildResolvedBy(args.resolver);
2673
+ return update;
2674
+ }
2675
+ function extractOutcome(result) {
2676
+ if (!result || typeof result !== "object") {
2677
+ return void 0;
2678
+ }
2679
+ const raw = result.outcome;
2680
+ if (!raw || typeof raw !== "object") {
2681
+ return void 0;
2682
+ }
2683
+ const kind = raw.kind;
2684
+ if (typeof kind !== "string") {
2685
+ return void 0;
2686
+ }
2687
+ const out = { kind };
2688
+ const optionId = raw.optionId;
2689
+ if (typeof optionId === "string") {
2690
+ out.optionId = optionId;
2691
+ }
2692
+ const reason = raw.reason;
2693
+ if (typeof reason === "string") {
2694
+ out.reason = reason;
2695
+ }
2696
+ return out;
2697
+ }
2698
+ function buildResolvedBy(client) {
2699
+ const out = { clientId: client.clientId };
2700
+ if (client.clientInfo?.name) {
2701
+ out.name = client.clientInfo.name;
2702
+ }
2703
+ if (client.clientInfo?.version) {
2704
+ out.version = client.clientInfo.version;
2705
+ }
2706
+ return out;
2707
+ }
2311
2708
  function extractPromptText(prompt) {
2312
2709
  if (typeof prompt === "string") {
2313
2710
  return prompt;
@@ -2335,7 +2732,7 @@ function firstLine(text, max) {
2335
2732
 
2336
2733
  // src/core/session-store.ts
2337
2734
  import * as fs4 from "fs/promises";
2338
- import * as path3 from "path";
2735
+ import * as path4 from "path";
2339
2736
  import { customAlphabet as customAlphabet2 } from "nanoid";
2340
2737
  import { z as z4 } from "zod";
2341
2738
  var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -2507,12 +2904,16 @@ function recordFromMemorySession(args) {
2507
2904
  // src/core/history-store.ts
2508
2905
  import * as fs5 from "fs/promises";
2509
2906
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
2510
- var MAX_ENTRIES = 1e3;
2907
+ var DEFAULT_MAX_ENTRIES = 1e3;
2511
2908
  var HistoryStore = class {
2512
2909
  // Serialize writes per session id so appends and rewrites don't
2513
2910
  // interleave JSONL lines on disk. The chain swallows errors so one
2514
2911
  // failed append doesn't poison every subsequent write.
2515
2912
  writeQueues = /* @__PURE__ */ new Map();
2913
+ maxEntries;
2914
+ constructor(options = {}) {
2915
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
2916
+ }
2516
2917
  async append(sessionId, entry) {
2517
2918
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
2518
2919
  return;
@@ -2614,8 +3015,8 @@ var HistoryStore = class {
2614
3015
  recordedAt: obj.recordedAt
2615
3016
  });
2616
3017
  }
2617
- if (out.length > MAX_ENTRIES) {
2618
- return out.slice(-MAX_ENTRIES);
3018
+ if (out.length > this.maxEntries) {
3019
+ return out.slice(-this.maxEntries);
2619
3020
  }
2620
3021
  return out;
2621
3022
  }
@@ -2658,13 +3059,40 @@ var HistoryStore = class {
2658
3059
 
2659
3060
  // src/tui/history.ts
2660
3061
  import { promises as fs6 } from "fs";
2661
- import * as path4 from "path";
3062
+ import * as path5 from "path";
2662
3063
  async function saveHistory(file, history) {
2663
- await fs6.mkdir(path4.dirname(file), { recursive: true });
3064
+ await fs6.mkdir(path5.dirname(file), { recursive: true });
2664
3065
  const lines = history.map((entry) => JSON.stringify(entry));
2665
3066
  await fs6.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
2666
3067
  }
2667
3068
 
3069
+ // src/core/hydra-version.ts
3070
+ import { fileURLToPath } from "url";
3071
+ import * as path6 from "path";
3072
+ import * as fs7 from "fs";
3073
+ function resolveVersion() {
3074
+ try {
3075
+ let dir = path6.dirname(fileURLToPath(import.meta.url));
3076
+ for (let i = 0; i < 8; i += 1) {
3077
+ const candidate = path6.join(dir, "package.json");
3078
+ if (fs7.existsSync(candidate)) {
3079
+ const pkg = JSON.parse(fs7.readFileSync(candidate, "utf8"));
3080
+ if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
3081
+ return pkg.version;
3082
+ }
3083
+ }
3084
+ const parent = path6.dirname(dir);
3085
+ if (parent === dir) {
3086
+ break;
3087
+ }
3088
+ dir = parent;
3089
+ }
3090
+ } catch {
3091
+ }
3092
+ return "0.0.0";
3093
+ }
3094
+ var HYDRA_VERSION = resolveVersion();
3095
+
2668
3096
  // src/core/session-manager.ts
2669
3097
  var HYDRA_ID_ALPHABET3 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
2670
3098
  var generateRawSessionId = customAlphabet3(HYDRA_ID_ALPHABET3, 16);
@@ -2673,7 +3101,8 @@ var SessionManager = class {
2673
3101
  this.registry = registry;
2674
3102
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
2675
3103
  this.store = store ?? new SessionStore();
2676
- this.histories = new HistoryStore();
3104
+ this.sessionHistoryMaxEntries = options.sessionHistoryMaxEntries ?? 1e3;
3105
+ this.histories = new HistoryStore({ maxEntries: this.sessionHistoryMaxEntries });
2677
3106
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
2678
3107
  this.defaultModels = options.defaultModels ?? {};
2679
3108
  }
@@ -2685,6 +3114,7 @@ var SessionManager = class {
2685
3114
  histories;
2686
3115
  idleTimeoutMs;
2687
3116
  defaultModels;
3117
+ sessionHistoryMaxEntries;
2688
3118
  // Serialize meta.json read-modify-write operations per session id so
2689
3119
  // concurrent snapshot updates (e.g. an agent emitting model + mode
2690
3120
  // back-to-back) don't lose writes via interleaved reads.
@@ -2708,6 +3138,7 @@ var SessionManager = class {
2708
3138
  idleTimeoutMs: this.idleTimeoutMs,
2709
3139
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2710
3140
  historyStore: this.histories,
3141
+ historyMaxEntries: this.sessionHistoryMaxEntries,
2711
3142
  currentModel: fresh.initialModel
2712
3143
  });
2713
3144
  await this.attachManagerHooks(session);
@@ -2759,11 +3190,16 @@ var SessionManager = class {
2759
3190
  cwd: params.cwd,
2760
3191
  plan
2761
3192
  });
2762
- await agent.connection.request("initialize", {
2763
- protocolVersion: 1,
2764
- clientCapabilities: {},
2765
- clientInfo: { name: "hydra", version: "0.1.0" }
2766
- });
3193
+ try {
3194
+ await agent.connection.request("initialize", {
3195
+ protocolVersion: ACP_PROTOCOL_VERSION,
3196
+ clientCapabilities: {},
3197
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
3198
+ });
3199
+ } catch (err) {
3200
+ await agent.kill().catch(() => void 0);
3201
+ throw err;
3202
+ }
2767
3203
  let loadResult;
2768
3204
  try {
2769
3205
  loadResult = await agent.connection.request(
@@ -2775,10 +3211,12 @@ var SessionManager = class {
2775
3211
  }
2776
3212
  );
2777
3213
  } catch (err) {
2778
- await agent.kill().catch(() => void 0);
2779
- throw new Error(
2780
- `agent ${params.agentId} failed to load upstream session ${params.upstreamSessionId}: ${err.message}`
3214
+ process.stderr.write(
3215
+ `session/load failed for upstream ${params.upstreamSessionId} on ${params.agentId} (${err.message}); recovering via import-reseed
3216
+ `
2781
3217
  );
3218
+ await agent.kill().catch(() => void 0);
3219
+ return this.doResurrectFromImport(params);
2782
3220
  }
2783
3221
  const session = new Session({
2784
3222
  sessionId: params.hydraSessionId,
@@ -2792,6 +3230,7 @@ var SessionManager = class {
2792
3230
  idleTimeoutMs: this.idleTimeoutMs,
2793
3231
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2794
3232
  historyStore: this.histories,
3233
+ historyMaxEntries: this.sessionHistoryMaxEntries,
2795
3234
  // Prefer what we previously stored from a current_model_update; if
2796
3235
  // we never captured one (e.g. old opencode sessions on disk before
2797
3236
  // this fix), fall back to the model the agent ships in its
@@ -2818,15 +3257,16 @@ var SessionManager = class {
2818
3257
  // so subsequent resurrects of this session use the normal session/load
2819
3258
  // path.
2820
3259
  async doResurrectFromImport(params) {
3260
+ const cwd = await this.resolveImportCwd(params.cwd);
2821
3261
  const fresh = await this.bootstrapAgent({
2822
3262
  agentId: params.agentId,
2823
- cwd: params.cwd,
3263
+ cwd,
2824
3264
  agentArgs: params.agentArgs,
2825
3265
  mcpServers: []
2826
3266
  });
2827
3267
  const session = new Session({
2828
3268
  sessionId: params.hydraSessionId,
2829
- cwd: params.cwd,
3269
+ cwd,
2830
3270
  agentId: params.agentId,
2831
3271
  agent: fresh.agent,
2832
3272
  upstreamSessionId: fresh.upstreamSessionId,
@@ -2836,6 +3276,7 @@ var SessionManager = class {
2836
3276
  idleTimeoutMs: this.idleTimeoutMs,
2837
3277
  spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2838
3278
  historyStore: this.histories,
3279
+ historyMaxEntries: this.sessionHistoryMaxEntries,
2839
3280
  // Prefer the stored value (set by a previous current_model_update);
2840
3281
  // fall back to whatever the agent ships in its session/new response.
2841
3282
  currentModel: params.currentModel ?? fresh.initialModel,
@@ -2849,6 +3290,16 @@ var SessionManager = class {
2849
3290
  void session.seedFromImport().catch(() => void 0);
2850
3291
  return session;
2851
3292
  }
3293
+ async resolveImportCwd(cwd) {
3294
+ try {
3295
+ const stat2 = await fs8.stat(cwd);
3296
+ if (stat2.isDirectory()) {
3297
+ return cwd;
3298
+ }
3299
+ } catch {
3300
+ }
3301
+ return os2.homedir();
3302
+ }
2852
3303
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
2853
3304
  // → session/new. Shared by create() and the /hydra agent path so both
2854
3305
  // go through the same env / capabilities / error-handling.
@@ -2869,9 +3320,9 @@ var SessionManager = class {
2869
3320
  });
2870
3321
  try {
2871
3322
  await agent.connection.request("initialize", {
2872
- protocolVersion: 1,
3323
+ protocolVersion: ACP_PROTOCOL_VERSION,
2873
3324
  clientCapabilities: {},
2874
- clientInfo: { name: "hydra", version: "0.1.0" }
3325
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
2875
3326
  });
2876
3327
  const newResult = await agent.connection.request(
2877
3328
  "session/new",
@@ -3151,7 +3602,8 @@ var SessionManager = class {
3151
3602
  await this.writeImportedRecord({
3152
3603
  sessionId: existing.sessionId,
3153
3604
  bundle,
3154
- preservedCreatedAt: existing.createdAt
3605
+ preservedCreatedAt: existing.createdAt,
3606
+ cwd: opts.cwd
3155
3607
  });
3156
3608
  return {
3157
3609
  sessionId: existing.sessionId,
@@ -3160,7 +3612,11 @@ var SessionManager = class {
3160
3612
  };
3161
3613
  }
3162
3614
  const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
3163
- await this.writeImportedRecord({ sessionId: newId, bundle });
3615
+ await this.writeImportedRecord({
3616
+ sessionId: newId,
3617
+ bundle,
3618
+ cwd: opts.cwd
3619
+ });
3164
3620
  return {
3165
3621
  sessionId: newId,
3166
3622
  importedFromSessionId: bundle.session.sessionId,
@@ -3190,7 +3646,7 @@ var SessionManager = class {
3190
3646
  upstreamSessionId: "",
3191
3647
  importedFromSessionId: args.bundle.session.sessionId,
3192
3648
  agentId: args.bundle.session.agentId,
3193
- cwd: args.bundle.session.cwd,
3649
+ cwd: args.cwd ?? args.bundle.session.cwd,
3194
3650
  title: args.bundle.session.title,
3195
3651
  currentModel: args.bundle.session.currentModel,
3196
3652
  currentMode: args.bundle.session.currentMode,
@@ -3383,7 +3839,7 @@ function asString(value) {
3383
3839
  }
3384
3840
  async function loadPromptHistorySafely(sessionId) {
3385
3841
  try {
3386
- const raw = await fs7.readFile(paths.tuiHistoryFile(sessionId), "utf8");
3842
+ const raw = await fs8.readFile(paths.tuiHistoryFile(sessionId), "utf8");
3387
3843
  const out = [];
3388
3844
  for (const line of raw.split("\n")) {
3389
3845
  if (line.length === 0) {
@@ -3404,7 +3860,7 @@ async function loadPromptHistorySafely(sessionId) {
3404
3860
  }
3405
3861
  async function historyMtimeIso(sessionId) {
3406
3862
  try {
3407
- const st = await fs7.stat(paths.historyFile(sessionId));
3863
+ const st = await fs8.stat(paths.historyFile(sessionId));
3408
3864
  return new Date(st.mtimeMs).toISOString();
3409
3865
  } catch {
3410
3866
  return void 0;
@@ -3412,10 +3868,10 @@ async function historyMtimeIso(sessionId) {
3412
3868
  }
3413
3869
 
3414
3870
  // src/core/extensions.ts
3415
- import { spawn as spawn3 } from "child_process";
3416
- import * as fs8 from "fs";
3417
- import * as fsp2 from "fs/promises";
3418
- import * as path5 from "path";
3871
+ import { spawn as spawn4 } from "child_process";
3872
+ import * as fs9 from "fs";
3873
+ import * as fsp3 from "fs/promises";
3874
+ import * as path7 from "path";
3419
3875
  var RESTART_BASE_MS = 1e3;
3420
3876
  var RESTART_CAP_MS = 6e4;
3421
3877
  var STOP_GRACE_MS = 3e3;
@@ -3436,7 +3892,7 @@ var ExtensionManager = class {
3436
3892
  if (!this.context) {
3437
3893
  throw new Error("ExtensionManager: setContext must be called before start");
3438
3894
  }
3439
- await fsp2.mkdir(paths.extensionsDir(), { recursive: true });
3895
+ await fsp3.mkdir(paths.extensionsDir(), { recursive: true });
3440
3896
  await this.reapOrphans();
3441
3897
  for (const entry of this.entries.values()) {
3442
3898
  if (!entry.config.enabled) {
@@ -3645,7 +4101,7 @@ var ExtensionManager = class {
3645
4101
  async reapOrphans() {
3646
4102
  let entries;
3647
4103
  try {
3648
- entries = await fsp2.readdir(paths.extensionsDir());
4104
+ entries = await fsp3.readdir(paths.extensionsDir());
3649
4105
  } catch (err) {
3650
4106
  const e = err;
3651
4107
  if (e.code === "ENOENT") {
@@ -3657,10 +4113,10 @@ var ExtensionManager = class {
3657
4113
  if (!entry.endsWith(".pid")) {
3658
4114
  continue;
3659
4115
  }
3660
- const pidPath = path5.join(paths.extensionsDir(), entry);
4116
+ const pidPath = path7.join(paths.extensionsDir(), entry);
3661
4117
  let pid;
3662
4118
  try {
3663
- const raw = await fsp2.readFile(pidPath, "utf8");
4119
+ const raw = await fsp3.readFile(pidPath, "utf8");
3664
4120
  const parsed = Number.parseInt(raw.trim(), 10);
3665
4121
  if (Number.isInteger(parsed) && parsed > 0) {
3666
4122
  pid = parsed;
@@ -3683,7 +4139,7 @@ var ExtensionManager = class {
3683
4139
  }
3684
4140
  }
3685
4141
  }
3686
- await fsp2.unlink(pidPath).catch(() => void 0);
4142
+ await fsp3.unlink(pidPath).catch(() => void 0);
3687
4143
  }
3688
4144
  }
3689
4145
  spawn(entry, attempt) {
@@ -3696,7 +4152,7 @@ var ExtensionManager = class {
3696
4152
  }
3697
4153
  const ext = entry.config;
3698
4154
  const command = ext.command.length > 0 ? ext.command : [ext.name];
3699
- const logStream = fs8.createWriteStream(paths.extensionLogFile(ext.name), {
4155
+ const logStream = fs9.createWriteStream(paths.extensionLogFile(ext.name), {
3700
4156
  flags: "a"
3701
4157
  });
3702
4158
  logStream.write(
@@ -3724,7 +4180,7 @@ var ExtensionManager = class {
3724
4180
  const args = [...baseArgs, ...ext.args];
3725
4181
  let child;
3726
4182
  try {
3727
- child = spawn3(cmd, args, {
4183
+ child = spawn4(cmd, args, {
3728
4184
  env,
3729
4185
  stdio: ["ignore", "pipe", "pipe"],
3730
4186
  detached: false
@@ -3746,7 +4202,7 @@ var ExtensionManager = class {
3746
4202
  }
3747
4203
  if (typeof child.pid === "number") {
3748
4204
  try {
3749
- fs8.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
4205
+ fs9.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
3750
4206
  `, {
3751
4207
  encoding: "utf8",
3752
4208
  mode: 384
@@ -3771,7 +4227,7 @@ var ExtensionManager = class {
3771
4227
  });
3772
4228
  child.on("exit", (code, signal) => {
3773
4229
  try {
3774
- fs8.unlinkSync(paths.extensionPidFile(ext.name));
4230
+ fs9.unlinkSync(paths.extensionPidFile(ext.name));
3775
4231
  } catch {
3776
4232
  }
3777
4233
  logStream.write(
@@ -3880,7 +4336,7 @@ function constantTimeEqual(a, b) {
3880
4336
  }
3881
4337
 
3882
4338
  // src/daemon/routes/sessions.ts
3883
- import * as os2 from "os";
4339
+ import * as os3 from "os";
3884
4340
 
3885
4341
  // src/core/bundle.ts
3886
4342
  import { z as z5 } from "zod";
@@ -3950,7 +4406,6 @@ function decodeBundle(raw) {
3950
4406
  }
3951
4407
 
3952
4408
  // src/daemon/routes/sessions.ts
3953
- var HYDRA_VERSION = "0.1.0";
3954
4409
  function registerSessionRoutes(app, manager, defaults) {
3955
4410
  app.get("/v1/sessions", async (request) => {
3956
4411
  const query = request.query;
@@ -4021,12 +4476,12 @@ function registerSessionRoutes(app, manager, defaults) {
4021
4476
  history: exported.history,
4022
4477
  promptHistory: exported.promptHistory.length > 0 ? exported.promptHistory : void 0,
4023
4478
  hydraVersion: HYDRA_VERSION,
4024
- machine: os2.hostname()
4479
+ machine: os3.hostname()
4025
4480
  });
4026
4481
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
4027
4482
  reply.header(
4028
4483
  "Content-Disposition",
4029
- `attachment; filename="hydra-${id}-${stamp}.hydra"`
4484
+ `attachment; filename="${id}-${stamp}.hydra"`
4030
4485
  );
4031
4486
  reply.code(200).send(bundle);
4032
4487
  });
@@ -4036,6 +4491,14 @@ function registerSessionRoutes(app, manager, defaults) {
4036
4491
  reply.code(400).send({ error: "missing bundle" });
4037
4492
  return;
4038
4493
  }
4494
+ let cwdOverride;
4495
+ if (body.cwd !== void 0) {
4496
+ if (typeof body.cwd !== "string" || body.cwd.length === 0) {
4497
+ reply.code(400).send({ error: "cwd must be a non-empty string" });
4498
+ return;
4499
+ }
4500
+ cwdOverride = body.cwd;
4501
+ }
4039
4502
  let bundle;
4040
4503
  try {
4041
4504
  bundle = decodeBundle(body.bundle);
@@ -4048,7 +4511,8 @@ function registerSessionRoutes(app, manager, defaults) {
4048
4511
  }
4049
4512
  try {
4050
4513
  const result = await manager.importBundle(bundle, {
4051
- replace: body.replace === true
4514
+ replace: body.replace === true,
4515
+ ...cwdOverride !== void 0 ? { cwd: cwdOverride } : {}
4052
4516
  });
4053
4517
  reply.code(201).send(result);
4054
4518
  } catch (err) {
@@ -4359,8 +4823,6 @@ function wsToMessageStream(ws) {
4359
4823
  }
4360
4824
 
4361
4825
  // src/daemon/acp-ws.ts
4362
- var HYDRA_VERSION2 = "0.1.0";
4363
- var HYDRA_PROTOCOL_VERSION = 1;
4364
4826
  function registerAcpWsEndpoint(app, deps) {
4365
4827
  app.get("/acp", { websocket: true }, (socket, request) => {
4366
4828
  const token = tokenFromUpgradeRequest({
@@ -4454,15 +4916,20 @@ function registerAcpWsEndpoint(app, deps) {
4454
4916
  connection,
4455
4917
  session,
4456
4918
  state,
4457
- params.clientInfo
4919
+ params.clientInfo,
4920
+ params.clientId
4921
+ );
4922
+ const { entries: replay, appliedPolicy } = await session.attach(
4923
+ client,
4924
+ params.historyPolicy,
4925
+ { afterMessageId: params.afterMessageId }
4458
4926
  );
4459
- const replay = await session.attach(client, params.historyPolicy);
4460
4927
  state.attached.set(session.sessionId, {
4461
4928
  sessionId: session.sessionId,
4462
4929
  clientId: client.clientId
4463
4930
  });
4464
4931
  app.log.info(
4465
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} replayed=${replay.length}`
4932
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
4466
4933
  );
4467
4934
  for (const note of replay) {
4468
4935
  await connection.notify(note.method, note.params);
@@ -4470,6 +4937,13 @@ function registerAcpWsEndpoint(app, deps) {
4470
4937
  session.replayPendingPermissions(client);
4471
4938
  return {
4472
4939
  sessionId: session.sessionId,
4940
+ clientId: client.clientId,
4941
+ connectedClients: session.connectedClients(client.clientId),
4942
+ // appliedPolicy surfaces whether after_message fell back to full
4943
+ // (because afterMessageId wasn't found in history) — RFD #533
4944
+ // says the response.historyPolicy should reflect what actually
4945
+ // ran, not what was asked for.
4946
+ historyPolicy: appliedPolicy,
4473
4947
  replayed: replay.length,
4474
4948
  _meta: buildResponseMeta(session)
4475
4949
  };
@@ -4485,7 +4959,7 @@ function registerAcpWsEndpoint(app, deps) {
4485
4959
  const session = deps.manager.get(params.sessionId);
4486
4960
  session?.detach(att.clientId);
4487
4961
  state.attached.delete(params.sessionId);
4488
- return { detached: true };
4962
+ return { sessionId: params.sessionId, status: "detached" };
4489
4963
  });
4490
4964
  connection.onRequest("session/list", async (raw) => {
4491
4965
  const params = SessionListParams.parse(raw ?? {});
@@ -4558,7 +5032,7 @@ function registerAcpWsEndpoint(app, deps) {
4558
5032
  session = await deps.manager.resurrect(fromDisk);
4559
5033
  }
4560
5034
  const client = bindClientToSession(connection, session, state);
4561
- const replay = await session.attach(client, "pending_only");
5035
+ const { entries: replay } = await session.attach(client, "pending_only");
4562
5036
  state.attached.set(session.sessionId, {
4563
5037
  sessionId: session.sessionId,
4564
5038
  clientId: client.clientId
@@ -4623,8 +5097,8 @@ function buildResponseMeta(session) {
4623
5097
  }
4624
5098
  function buildInitializeResult() {
4625
5099
  return {
4626
- protocolVersion: HYDRA_PROTOCOL_VERSION,
4627
- agentInfo: { name: "hydra", version: HYDRA_VERSION2 },
5100
+ protocolVersion: ACP_PROTOCOL_VERSION,
5101
+ agentInfo: { name: "hydra", version: HYDRA_VERSION },
4628
5102
  agentCapabilities: {
4629
5103
  // hydra is a transparent proxy: prompt blocks and MCP server configs are
4630
5104
  // forwarded to the underlying agent unchanged. We claim the union of
@@ -4652,25 +5126,24 @@ function buildInitializeResult() {
4652
5126
  ]
4653
5127
  };
4654
5128
  }
4655
- function bindClientToSession(connection, session, state, clientInfo) {
5129
+ function bindClientToSession(connection, session, state, clientInfo, callerClientId) {
4656
5130
  void state;
4657
5131
  void session;
4658
5132
  return {
4659
- clientId: `cli_${nanoid2(8)}`,
5133
+ clientId: callerClientId ?? `cli_${nanoid2(8)}`,
4660
5134
  connection,
4661
5135
  clientInfo
4662
5136
  };
4663
5137
  }
4664
5138
 
4665
5139
  // src/daemon/server.ts
4666
- var HYDRA_VERSION3 = "0.1.0";
4667
5140
  async function startDaemon(config) {
4668
5141
  ensureLoopbackOrTls(config);
4669
5142
  const httpsOptions = config.daemon.tls ? {
4670
- key: await fsp3.readFile(config.daemon.tls.key),
4671
- cert: await fsp3.readFile(config.daemon.tls.cert)
5143
+ key: await fsp4.readFile(config.daemon.tls.key),
5144
+ cert: await fsp4.readFile(config.daemon.tls.cert)
4672
5145
  } : void 0;
4673
- await fsp3.mkdir(paths.home(), { recursive: true });
5146
+ await fsp4.mkdir(paths.home(), { recursive: true });
4674
5147
  const { stream: logStream, fileStream } = await buildLogStream(
4675
5148
  config.daemon.logLevel
4676
5149
  );
@@ -4679,12 +5152,18 @@ async function startDaemon(config) {
4679
5152
  level: config.daemon.logLevel,
4680
5153
  stream: logStream
4681
5154
  },
4682
- https: httpsOptions ?? null
5155
+ https: httpsOptions ?? null,
5156
+ // Session bundles can be large (full history + tool output);
5157
+ // the 1MB Fastify default rejects ordinary imports.
5158
+ bodyLimit: 256 * 1024 * 1024
4683
5159
  });
4684
5160
  await app.register(websocketPlugin);
4685
5161
  setBinaryInstallLogger((msg) => {
4686
5162
  app.log.info(msg);
4687
5163
  });
5164
+ setNpmInstallLogger((msg) => {
5165
+ app.log.info(msg);
5166
+ });
4688
5167
  const auth = bearerAuth({ config });
4689
5168
  app.addHook("onRequest", async (request, reply) => {
4690
5169
  if (request.routeOptions.config?.skipAuth) {
@@ -4696,12 +5175,14 @@ async function startDaemon(config) {
4696
5175
  await auth(request, reply);
4697
5176
  });
4698
5177
  const registry = new Registry(config);
4699
- const manager = new SessionManager(registry, void 0, void 0, {
5178
+ const spawner = (opts) => AgentInstance.spawn({ ...opts, stderrTailBytes: config.daemon.agentStderrTailBytes });
5179
+ const manager = new SessionManager(registry, spawner, void 0, {
4700
5180
  idleTimeoutMs: config.daemon.sessionIdleTimeoutSeconds * 1e3,
4701
- defaultModels: config.defaultModels
5181
+ defaultModels: config.defaultModels,
5182
+ sessionHistoryMaxEntries: config.daemon.sessionHistoryMaxEntries
4702
5183
  });
4703
5184
  const extensions = new ExtensionManager(extensionList(config));
4704
- registerHealthRoutes(app, HYDRA_VERSION3);
5185
+ registerHealthRoutes(app, HYDRA_VERSION);
4705
5186
  registerSessionRoutes(app, manager, {
4706
5187
  agentId: config.defaultAgent,
4707
5188
  cwd: config.defaultCwd
@@ -4720,8 +5201,8 @@ async function startDaemon(config) {
4720
5201
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
4721
5202
  const address = app.server.address();
4722
5203
  const boundPort = address && typeof address === "object" ? address.port : config.daemon.port;
4723
- await fsp3.mkdir(paths.home(), { recursive: true });
4724
- await fsp3.writeFile(
5204
+ await fsp4.mkdir(paths.home(), { recursive: true });
5205
+ await fsp4.writeFile(
4725
5206
  paths.pidFile(),
4726
5207
  JSON.stringify({
4727
5208
  pid: process.pid,
@@ -4747,9 +5228,10 @@ async function startDaemon(config) {
4747
5228
  await manager.closeAll();
4748
5229
  await manager.flushMetaWrites();
4749
5230
  setBinaryInstallLogger(null);
5231
+ setNpmInstallLogger(null);
4750
5232
  await app.close();
4751
5233
  try {
4752
- fs9.unlinkSync(paths.pidFile());
5234
+ fs10.unlinkSync(paths.pidFile());
4753
5235
  } catch {
4754
5236
  }
4755
5237
  try {