@hydra-acp/cli 0.1.51 → 0.1.53

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
@@ -18,7 +18,7 @@ import pino from "pino";
18
18
  import createPinoRoll from "pino-roll";
19
19
 
20
20
  // src/core/config.ts
21
- import * as fs2 from "fs/promises";
21
+ import * as fs3 from "fs/promises";
22
22
  import { homedir as homedir2 } from "os";
23
23
  import { z } from "zod";
24
24
 
@@ -156,6 +156,70 @@ async function ensureServiceToken() {
156
156
  return token;
157
157
  }
158
158
 
159
+ // src/core/json-store.ts
160
+ import * as fs2 from "fs/promises";
161
+ import * as fsSync from "fs";
162
+ import { randomBytes } from "crypto";
163
+ async function writeJsonAtomic(filePath, data, opts = {}) {
164
+ const pretty = opts.pretty ?? true;
165
+ const body = (pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data)) + "\n";
166
+ await writeFileAtomic(filePath, body, opts);
167
+ }
168
+ async function writeFileAtomic(filePath, body, opts = {}) {
169
+ const dir = dirname(filePath);
170
+ await fs2.mkdir(dir, { recursive: true });
171
+ const tmp = `${filePath}.tmp-${process.pid}-${randSuffix()}`;
172
+ try {
173
+ const writeOpts = {
174
+ encoding: "utf8"
175
+ };
176
+ if (opts.mode !== void 0) {
177
+ writeOpts.mode = opts.mode;
178
+ }
179
+ await fs2.writeFile(tmp, body, writeOpts);
180
+ await fs2.rename(tmp, filePath);
181
+ } catch (err) {
182
+ await fs2.unlink(tmp).catch(() => void 0);
183
+ throw err;
184
+ }
185
+ if (opts.mode !== void 0) {
186
+ try {
187
+ fsSync.chmodSync(filePath, opts.mode);
188
+ } catch {
189
+ }
190
+ }
191
+ }
192
+ async function readJsonSafe(filePath) {
193
+ let raw;
194
+ try {
195
+ raw = await fs2.readFile(filePath, "utf8");
196
+ } catch (err) {
197
+ const e = err;
198
+ if (e.code === "ENOENT") {
199
+ return void 0;
200
+ }
201
+ throw err;
202
+ }
203
+ if (raw.trim().length === 0) {
204
+ return void 0;
205
+ }
206
+ try {
207
+ return JSON.parse(raw);
208
+ } catch {
209
+ return void 0;
210
+ }
211
+ }
212
+ function dirname(p) {
213
+ const slash = p.lastIndexOf("/");
214
+ if (slash <= 0) {
215
+ return ".";
216
+ }
217
+ return p.slice(0, slash);
218
+ }
219
+ function randSuffix() {
220
+ return randomBytes(4).toString("hex");
221
+ }
222
+
159
223
  // src/core/config.ts
160
224
  var REGISTRY_URL_DEFAULT = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json";
161
225
  var TlsConfig = z.object({
@@ -184,7 +248,13 @@ var DaemonConfig = z.object({
184
248
  // tunnel (ngrok) or VPN (Tailscale) under a different name. The
185
249
  // `--host` flag on `share` overrides this; omitting both falls
186
250
  // back to `daemon.host`, then to "127.0.0.1" with a stderr warning.
187
- publicHost: z.string().optional()
251
+ publicHost: z.string().optional(),
252
+ // How often (minutes) the daemon runs `agent sync` against every
253
+ // installed (non-uvx) agent in the background, picking up sessions
254
+ // created outside hydra so the picker can resurrect them. Spawns
255
+ // are staggered across the window — N agents on a 60-minute interval
256
+ // mean one agent spawn every 60/N minutes. Set 0 to disable entirely.
257
+ agentSyncIntervalMinutes: z.number().nonnegative().default(60)
188
258
  });
189
259
  var RegistryConfig = z.object({
190
260
  url: z.string().url().default(REGISTRY_URL_DEFAULT),
@@ -239,7 +309,22 @@ var TuiConfig = z.object({
239
309
  // shared across all sessions; it's append-only on disk, so long-lived
240
310
  // installs can grow past this — it's enforced at load time and per
241
311
  // append in memory.
242
- promptHistoryMaxEntries: z.number().int().positive().default(2e3)
312
+ promptHistoryMaxEntries: z.number().int().positive().default(2e3),
313
+ // How edit-style tool calls (Edit, Write, str_replace) render in
314
+ // scrollback, *in addition to* the normal tool row inside the tools
315
+ // block.
316
+ // "none" — nothing extra; the collapsed tool row is the only signal.
317
+ // "edit" (default) — a one-line scrollback mark naming the file
318
+ // that was touched, so the user can scroll back and see which
319
+ // files moved without expanding the tools block. Suppressed on
320
+ // tool-only turns (no agent prose) since the marks would only
321
+ // duplicate the still-visible tool rows.
322
+ // "diff" — same mark plus a syntax-highlighted unified diff body,
323
+ // Claude Code's Update(file) look.
324
+ // The diff payload is extracted from the ACP wire (content[]
325
+ // type:"diff" entries, falling back to rawInput shapes), so any agent
326
+ // that emits one of those shapes gets the treatment.
327
+ showFileUpdates: z.enum(["none", "edit", "diff"]).default("edit")
243
328
  });
244
329
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
245
330
  var ExtensionBody = z.object({
@@ -294,7 +379,8 @@ var HydraConfig = z.object({
294
379
  progressIndicator: true,
295
380
  defaultEnterAction: "amend",
296
381
  showThoughts: true,
297
- promptHistoryMaxEntries: 2e3
382
+ promptHistoryMaxEntries: 2e3,
383
+ showFileUpdates: "edit"
298
384
  })
299
385
  });
300
386
  function extensionList(config) {
@@ -310,17 +396,8 @@ function transformerList(config) {
310
396
  }));
311
397
  }
312
398
  async function readConfigFile() {
313
- let raw;
314
- try {
315
- raw = await fs2.readFile(paths.config(), "utf8");
316
- } catch (err) {
317
- const e = err;
318
- if (e.code === "ENOENT") {
319
- return {};
320
- }
321
- throw err;
322
- }
323
- return JSON.parse(raw);
399
+ const parsed = await readJsonSafe(paths.config());
400
+ return parsed ?? {};
324
401
  }
325
402
  async function migrateLegacyAuthToken() {
326
403
  const raw = await readConfigFile();
@@ -331,7 +408,7 @@ async function migrateLegacyAuthToken() {
331
408
  }
332
409
  let tokenFileExists = false;
333
410
  try {
334
- await fs2.access(paths.authToken());
411
+ await fs3.access(paths.authToken());
335
412
  tokenFileExists = true;
336
413
  } catch (err) {
337
414
  const e = err;
@@ -349,10 +426,7 @@ async function migrateLegacyAuthToken() {
349
426
  if (Object.keys(daemon).length === 0) {
350
427
  delete raw.daemon;
351
428
  }
352
- await fs2.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
353
- encoding: "utf8",
354
- mode: 384
355
- });
429
+ await writeJsonAtomic(paths.config(), raw, { mode: 384 });
356
430
  process.stderr.write(
357
431
  `hydra-acp: migrated auth token from ${paths.config()} to ${paths.authToken()}.
358
432
  `
@@ -363,11 +437,7 @@ async function loadConfig() {
363
437
  return HydraConfig.parse(await readConfigFile());
364
438
  }
365
439
  async function writeConfig(config) {
366
- await fs2.mkdir(paths.home(), { recursive: true });
367
- await fs2.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
368
- encoding: "utf8",
369
- mode: 384
370
- });
440
+ await writeJsonAtomic(paths.config(), config, { mode: 384 });
371
441
  }
372
442
  function defaultConfig() {
373
443
  return HydraConfig.parse({});
@@ -386,11 +456,12 @@ function expandHome(p) {
386
456
  }
387
457
 
388
458
  // src/core/registry.ts
389
- import * as fs4 from "fs/promises";
459
+ import * as fs5 from "fs/promises";
460
+ import * as path4 from "path";
390
461
  import { z as z2 } from "zod";
391
462
 
392
463
  // src/core/binary-install.ts
393
- import * as fs3 from "fs";
464
+ import * as fs4 from "fs";
394
465
  import * as fsp from "fs/promises";
395
466
  import * as path2 from "path";
396
467
  import { spawn } from "child_process";
@@ -523,7 +594,7 @@ async function downloadTo(args) {
523
594
  );
524
595
  }
525
596
  const total = Number(response.headers.get("content-length") ?? "0");
526
- const out = fs3.createWriteStream(dest);
597
+ const out = fs4.createWriteStream(dest);
527
598
  const nodeStream = Readable.fromWeb(response.body);
528
599
  safeEmit(args.onProgress, {
529
600
  phase: "download_start",
@@ -554,10 +625,10 @@ async function downloadTo(args) {
554
625
  logSink(formatProgress(args.agentId, received, total));
555
626
  }
556
627
  });
557
- await new Promise((resolve3, reject) => {
628
+ await new Promise((resolve4, reject) => {
558
629
  nodeStream.on("error", reject);
559
630
  out.on("error", reject);
560
- out.on("finish", () => resolve3());
631
+ out.on("finish", () => resolve4());
561
632
  nodeStream.pipe(out);
562
633
  });
563
634
  logSink(formatProgress(
@@ -609,14 +680,14 @@ async function extract(archivePath, dest) {
609
680
  throw new Error(`Unsupported archive format: ${archivePath}`);
610
681
  }
611
682
  function run(cmd, args) {
612
- return new Promise((resolve3, reject) => {
683
+ return new Promise((resolve4, reject) => {
613
684
  const child = spawn(cmd, args, {
614
685
  stdio: ["ignore", "ignore", "inherit"]
615
686
  });
616
687
  child.on("error", reject);
617
688
  child.on("exit", (code, signal) => {
618
689
  if (code === 0) {
619
- resolve3();
690
+ resolve4();
620
691
  return;
621
692
  }
622
693
  reject(
@@ -628,11 +699,11 @@ function run(cmd, args) {
628
699
  });
629
700
  }
630
701
  async function hasCommand(name) {
631
- return new Promise((resolve3) => {
702
+ return new Promise((resolve4) => {
632
703
  const finder = process.platform === "win32" ? "where" : "which";
633
704
  const child = spawn(finder, [name], { stdio: "ignore" });
634
- child.on("error", () => resolve3(false));
635
- child.on("exit", (code) => resolve3(code === 0));
705
+ child.on("error", () => resolve4(false));
706
+ child.on("exit", (code) => resolve4(code === 0));
636
707
  });
637
708
  }
638
709
  async function fileExists(p) {
@@ -759,7 +830,7 @@ function runNpmInstall(args) {
759
830
  }
760
831
  async function runNpmInstallOnce(args, attempt) {
761
832
  try {
762
- await new Promise((resolve3, reject) => {
833
+ await new Promise((resolve4, reject) => {
763
834
  const registryArgs = args.registry ? ["--registry", args.registry] : [];
764
835
  let child;
765
836
  try {
@@ -801,7 +872,7 @@ async function runNpmInstallOnce(args, attempt) {
801
872
  });
802
873
  child.on("exit", (code, signal) => {
803
874
  if (code === 0) {
804
- resolve3();
875
+ resolve4();
805
876
  return;
806
877
  }
807
878
  const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
@@ -988,6 +1059,13 @@ var Registry = class {
988
1059
  await this.writeDiskCache(fresh);
989
1060
  return fresh.data;
990
1061
  }
1062
+ // Epoch ms of the last successful registry fetch (in-memory or
1063
+ // disk). Returns undefined before load()/refresh() has populated the
1064
+ // cache. Used by `/v1/agents` to surface "synced N minutes ago" in
1065
+ // the CLI without exposing the full cache shape.
1066
+ lastFetchedAt() {
1067
+ return this.cache?.fetchedAt;
1068
+ }
991
1069
  async getAgent(id) {
992
1070
  const doc = await this.load();
993
1071
  const exact = doc.agents.find((a) => a.id === id);
@@ -1016,54 +1094,26 @@ var Registry = class {
1016
1094
  return cached;
1017
1095
  }
1018
1096
  async readDiskCache() {
1019
- let text;
1020
- try {
1021
- text = await fs4.readFile(paths.registryCache(), "utf8");
1022
- } catch (err) {
1023
- const e = err;
1024
- if (e.code === "ENOENT") {
1025
- return void 0;
1026
- }
1027
- throw err;
1097
+ const parsed = await readJsonSafe(
1098
+ paths.registryCache()
1099
+ );
1100
+ if (!parsed || typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
1101
+ return void 0;
1028
1102
  }
1029
1103
  try {
1030
- const parsed = JSON.parse(text);
1031
- if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
1032
- return void 0;
1033
- }
1034
1104
  const data = RegistryDocument.parse(parsed.data);
1035
1105
  return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
1036
1106
  } catch {
1037
1107
  return void 0;
1038
1108
  }
1039
1109
  }
1040
- // Atomic write: dump to a sibling temp path, then rename onto the
1041
- // target. POSIX rename is atomic within a filesystem, so readers
1042
- // either see the old file or the fully-written new file — never a
1043
- // truncated middle. This also makes simultaneous writers safe
1044
- // without a lock file: the loser of the rename race just gets its
1045
- // version replaced by the winner's.
1046
1110
  async writeDiskCache(cache) {
1047
- await fs4.mkdir(paths.home(), { recursive: true });
1048
- const final = paths.registryCache();
1049
- const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
1050
- const body = JSON.stringify(
1051
- { fetchedAt: cache.fetchedAt, data: cache.raw },
1052
- null,
1053
- 2
1054
- ) + "\n";
1055
- try {
1056
- await fs4.writeFile(tmp, body, "utf8");
1057
- await fs4.rename(tmp, final);
1058
- } catch (err) {
1059
- await fs4.unlink(tmp).catch(() => void 0);
1060
- throw err;
1061
- }
1111
+ await writeJsonAtomic(paths.registryCache(), {
1112
+ fetchedAt: cache.fetchedAt,
1113
+ data: cache.raw
1114
+ });
1062
1115
  }
1063
1116
  };
1064
- function randSuffix() {
1065
- return Math.random().toString(36).slice(2, 10);
1066
- }
1067
1117
  function npxPackageBasename(agent) {
1068
1118
  const pkg = agent.distribution.npx?.package;
1069
1119
  if (!pkg) {
@@ -1074,6 +1124,46 @@ function npxPackageBasename(agent) {
1074
1124
  const atIdx = afterSlash.lastIndexOf("@");
1075
1125
  return atIdx <= 0 ? afterSlash : afterSlash.slice(0, atIdx);
1076
1126
  }
1127
+ async function agentInstallState(agent) {
1128
+ const platformKey = currentPlatformKey();
1129
+ if (!platformKey) {
1130
+ return "no";
1131
+ }
1132
+ const version = agent.version ?? "current";
1133
+ if (agent.distribution.binary) {
1134
+ const target = pickBinaryTarget(agent.distribution.binary, platformKey);
1135
+ if (target?.cmd) {
1136
+ const cmdPath = path4.resolve(
1137
+ paths.agentInstallDir(agent.id, platformKey, version),
1138
+ target.cmd
1139
+ );
1140
+ if (await fileExists3(cmdPath)) {
1141
+ return "yes";
1142
+ }
1143
+ }
1144
+ }
1145
+ if (agent.distribution.npx) {
1146
+ const npx = agent.distribution.npx;
1147
+ const bin = npx.bin ?? npxPackageBasename(agent) ?? npx.package;
1148
+ const installDir = paths.agentNpmInstallDir(agent.id, platformKey, version);
1149
+ const binPath = path4.join(installDir, "node_modules", ".bin", bin);
1150
+ if (await fileExists3(binPath)) {
1151
+ return "yes";
1152
+ }
1153
+ }
1154
+ if (!agent.distribution.npx && !agent.distribution.binary && agent.distribution.uvx) {
1155
+ return "lazy";
1156
+ }
1157
+ return "no";
1158
+ }
1159
+ async function fileExists3(p) {
1160
+ try {
1161
+ await fs5.access(p);
1162
+ return true;
1163
+ } catch {
1164
+ return false;
1165
+ }
1166
+ }
1077
1167
  async function planSpawn(agent, callerArgs = [], options = {}) {
1078
1168
  const version = agent.version ?? "current";
1079
1169
  if (agent.distribution.npx) {
@@ -1422,6 +1512,11 @@ var SessionListEntry = z3.object({
1422
1512
  importedFromUpstreamSessionId: z3.string().optional(),
1423
1513
  // Set when this session was spawned as a child by a transformer.
1424
1514
  parentSessionId: z3.string().optional(),
1515
+ // Local-fork breadcrumbs set by hydra-acp/fork_session. Distinct from
1516
+ // the imported* family above: a fork is a local branch off another
1517
+ // local session, an import is a cross-machine takeover.
1518
+ forkedFromSessionId: z3.string().optional(),
1519
+ forkedFromMessageId: z3.string().optional(),
1425
1520
  // clientInfo from the process that issued session/new. Lets list views
1426
1521
  // hide cat-style ancillary sessions by default while letting an
1427
1522
  // override flag surface them.
@@ -1689,13 +1784,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
1689
1784
  throw new Error("stream is closed");
1690
1785
  }
1691
1786
  const line = JSON.stringify(message) + "\n";
1692
- await new Promise((resolve3, reject) => {
1787
+ await new Promise((resolve4, reject) => {
1693
1788
  stdin.write(line, (err) => {
1694
1789
  if (err) {
1695
1790
  reject(err);
1696
1791
  return;
1697
1792
  }
1698
- resolve3();
1793
+ resolve4();
1699
1794
  });
1700
1795
  });
1701
1796
  },
@@ -1787,9 +1882,9 @@ var JsonRpcConnection = class _JsonRpcConnection {
1787
1882
  }
1788
1883
  const id = nanoid();
1789
1884
  const message = { jsonrpc: "2.0", id, method, params };
1790
- const response = new Promise((resolve3, reject) => {
1885
+ const response = new Promise((resolve4, reject) => {
1791
1886
  this.pending.set(id, {
1792
- resolve: (result) => resolve3(result),
1887
+ resolve: (result) => resolve4(result),
1793
1888
  reject
1794
1889
  });
1795
1890
  this.stream.send(message).catch((err) => {
@@ -2019,7 +2114,7 @@ stderr: ${tail}` : reason;
2019
2114
  };
2020
2115
 
2021
2116
  // src/core/session-manager.ts
2022
- import * as fs10 from "fs/promises";
2117
+ import * as fs11 from "fs/promises";
2023
2118
  import * as os2 from "os";
2024
2119
  import { customAlphabet as customAlphabet3 } from "nanoid";
2025
2120
 
@@ -2216,14 +2311,14 @@ var SessionStreamBuffer = class {
2216
2311
  if (cap === 0) {
2217
2312
  return Promise.resolve("timeout");
2218
2313
  }
2219
- return new Promise((resolve3) => {
2314
+ return new Promise((resolve4) => {
2220
2315
  const waiter = {
2221
2316
  resolve: (outcome) => {
2222
2317
  if (waiter.timer !== void 0) {
2223
2318
  clearTimeout(waiter.timer);
2224
2319
  waiter.timer = void 0;
2225
2320
  }
2226
- resolve3(outcome);
2321
+ resolve4(outcome);
2227
2322
  },
2228
2323
  timer: setTimeout(() => {
2229
2324
  const idx = this.waiters.indexOf(waiter);
@@ -2231,7 +2326,7 @@ var SessionStreamBuffer = class {
2231
2326
  this.waiters.splice(idx, 1);
2232
2327
  }
2233
2328
  waiter.timer = void 0;
2234
- resolve3("timeout");
2329
+ resolve4("timeout");
2235
2330
  }, cap)
2236
2331
  };
2237
2332
  this.waiters.push(waiter);
@@ -2434,8 +2529,8 @@ var SessionStreamBuffer = class {
2434
2529
  return out;
2435
2530
  }
2436
2531
  scheduleFileWrite(chunk) {
2437
- const path13 = this.filePath;
2438
- if (path13 === void 0) {
2532
+ const path14 = this.filePath;
2533
+ if (path14 === void 0) {
2439
2534
  return;
2440
2535
  }
2441
2536
  if (this.fileCapReached) {
@@ -2450,7 +2545,7 @@ var SessionStreamBuffer = class {
2450
2545
  const slice = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
2451
2546
  this.fileBytesWritten += slice.length;
2452
2547
  const willHitCap = this.fileBytesWritten >= this.fileCapBytes;
2453
- this.fileWriteChain = this.fileWriteChain.then(() => fsp3.appendFile(path13, slice)).catch((err) => {
2548
+ this.fileWriteChain = this.fileWriteChain.then(() => fsp3.appendFile(path14, slice)).catch((err) => {
2454
2549
  this.logWriteError?.(err);
2455
2550
  });
2456
2551
  if (willHitCap && !this.fileCapReached) {
@@ -2499,22 +2594,22 @@ function hydraCommandsAsAdvertised() {
2499
2594
  }
2500
2595
 
2501
2596
  // src/core/queue-store.ts
2502
- import * as fs5 from "fs/promises";
2597
+ import * as fs6 from "fs/promises";
2503
2598
  async function rewriteQueue(sessionId, entries) {
2504
2599
  const file = paths.queueFile(sessionId);
2505
2600
  if (entries.length === 0) {
2506
- await fs5.unlink(file).catch(() => void 0);
2601
+ await fs6.unlink(file).catch(() => void 0);
2507
2602
  return;
2508
2603
  }
2509
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2604
+ await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
2510
2605
  const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
2511
- await fs5.writeFile(file, body, "utf8");
2606
+ await fs6.writeFile(file, body, "utf8");
2512
2607
  }
2513
2608
  async function loadQueue(sessionId) {
2514
2609
  const file = paths.queueFile(sessionId);
2515
2610
  let text;
2516
2611
  try {
2517
- text = await fs5.readFile(file, "utf8");
2612
+ text = await fs6.readFile(file, "utf8");
2518
2613
  } catch (err) {
2519
2614
  if (err.code === "ENOENT") {
2520
2615
  return [];
@@ -2536,7 +2631,7 @@ async function loadQueue(sessionId) {
2536
2631
  }
2537
2632
  async function deleteQueue(sessionId) {
2538
2633
  const file = paths.queueFile(sessionId);
2539
- await fs5.unlink(file).catch(() => void 0);
2634
+ await fs6.unlink(file).catch(() => void 0);
2540
2635
  }
2541
2636
 
2542
2637
  // src/core/session.ts
@@ -2568,6 +2663,8 @@ var Session = class {
2568
2663
  agentCapabilities;
2569
2664
  agentArgs;
2570
2665
  parentSessionId;
2666
+ forkedFromSessionId;
2667
+ forkedFromMessageId;
2571
2668
  originatingClient;
2572
2669
  title;
2573
2670
  // Snapshot state delivered to attaching clients via the attach
@@ -2596,6 +2693,13 @@ var Session = class {
2596
2693
  // enqueue) and leave the file out of sync with in-memory state.
2597
2694
  queueWriteChain = Promise.resolve();
2598
2695
  closed = false;
2696
+ // Set true at the start of close() / markClosed before any await yields.
2697
+ // drainQueue checks this between iterations and bails out, so a queued
2698
+ // entry can't be promoted to currentEntry (with its prompt_received and
2699
+ // synthesized turn_complete(interrupted)) while the session is tearing
2700
+ // down. markClosed sweeps the remaining queue with the normal abandoned
2701
+ // / cancelled handling.
2702
+ closing = false;
2599
2703
  closeHandlers = [];
2600
2704
  titleHandlers = [];
2601
2705
  // Subscribers notified after every entry that's actually persisted to
@@ -2723,6 +2827,8 @@ var Session = class {
2723
2827
  this.agentCapabilities = init.agentCapabilities;
2724
2828
  this.agentArgs = init.agentArgs;
2725
2829
  this.parentSessionId = init.parentSessionId;
2830
+ this.forkedFromSessionId = init.forkedFromSessionId;
2831
+ this.forkedFromMessageId = init.forkedFromMessageId;
2726
2832
  this.originatingClient = init.originatingClient;
2727
2833
  this.title = init.title;
2728
2834
  this.currentModel = init.currentModel;
@@ -2863,7 +2969,7 @@ var Session = class {
2863
2969
  const claimIdx = i;
2864
2970
  const claimEnvelope = envelope;
2865
2971
  const claimOriginatedBy = new Set(originatedBy);
2866
- await new Promise((resolve3) => {
2972
+ await new Promise((resolve4) => {
2867
2973
  const timer = setTimeout(() => {
2868
2974
  if (this.pendingClaims.delete(token)) {
2869
2975
  this.broadcastQueueNotification(
@@ -2874,14 +2980,14 @@ var Session = class {
2874
2980
  claimEnvelope,
2875
2981
  /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
2876
2982
  claimIdx + 1
2877
- ).then(resolve3);
2983
+ ).then(resolve4);
2878
2984
  }
2879
2985
  }, TRANSFORMER_CLAIM_TIMEOUT_MS);
2880
2986
  if (typeof timer.unref === "function") {
2881
2987
  timer.unref();
2882
2988
  }
2883
2989
  this.pendingClaims.set(token, {
2884
- resolve: () => resolve3(),
2990
+ resolve: () => resolve4(),
2885
2991
  timer,
2886
2992
  transformerName: t.name,
2887
2993
  method: "session/update",
@@ -3721,7 +3827,7 @@ var Session = class {
3721
3827
  const claimIdx = i;
3722
3828
  const claimEnvelope = envelope;
3723
3829
  const claimOriginatedBy = new Set(originatedBy);
3724
- return new Promise((resolve3) => {
3830
+ return new Promise((resolve4) => {
3725
3831
  const timer = setTimeout(() => {
3726
3832
  if (this.pendingClaims.delete(token)) {
3727
3833
  this.broadcastQueueNotification(
@@ -3733,14 +3839,14 @@ var Session = class {
3733
3839
  claimEnvelope,
3734
3840
  /* @__PURE__ */ new Set([...claimOriginatedBy, t.name]),
3735
3841
  claimIdx + 1
3736
- ).then(resolve3).catch(() => resolve3(defaultStopPayload(method)));
3842
+ ).then(resolve4).catch(() => resolve4(defaultStopPayload(method)));
3737
3843
  }
3738
3844
  }, TRANSFORMER_CLAIM_TIMEOUT_MS);
3739
3845
  if (typeof timer.unref === "function") {
3740
3846
  timer.unref();
3741
3847
  }
3742
3848
  this.pendingClaims.set(token, {
3743
- resolve: resolve3,
3849
+ resolve: resolve4,
3744
3850
  timer,
3745
3851
  transformerName: t.name,
3746
3852
  method,
@@ -3831,6 +3937,7 @@ var Session = class {
3831
3937
  if (this.closed) {
3832
3938
  return;
3833
3939
  }
3940
+ this.closing = true;
3834
3941
  this.logger?.info(
3835
3942
  `session ${this.sessionId} closing deleteRecord=${opts.deleteRecord ?? false} regenTitle=${opts.regenTitle ?? false}`
3836
3943
  );
@@ -4493,12 +4600,12 @@ ${text}
4493
4600
  } else {
4494
4601
  const inList = current ? models.some((m) => m.modelId === current) : true;
4495
4602
  const lines = models.map((m) => {
4496
- const marker = m.modelId === current ? " \u25C0" : "";
4603
+ const marker = m.modelId === current ? "\u25B6 " : " ";
4497
4604
  const desc = m.name && m.name !== m.modelId ? ` ${m.name}` : "";
4498
- return `${m.modelId}${marker}${desc}`;
4605
+ return `${marker}${m.modelId}${desc}`;
4499
4606
  });
4500
4607
  if (!inList && current) {
4501
- lines.unshift(`${current} \u25C0`);
4608
+ lines.unshift(`\u25B6 ${current}`);
4502
4609
  }
4503
4610
  body = lines.join("\n");
4504
4611
  }
@@ -4962,21 +5069,22 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4962
5069
  if (this.closed) {
4963
5070
  return;
4964
5071
  }
5072
+ this.closing = true;
4965
5073
  this.closed = true;
4966
5074
  this.cancelIdleTimer();
4967
5075
  if (this.extensionCommandsUnsub) {
4968
5076
  this.extensionCommandsUnsub();
4969
5077
  this.extensionCommandsUnsub = void 0;
4970
5078
  }
4971
- if (this.currentEntry?.kind === "user") {
5079
+ if (this.currentEntry?.kind === "user" && !this.recentlyTerminal.has(this.currentEntry.messageId)) {
4972
5080
  this.broadcastTurnComplete(
4973
5081
  this.currentEntry.clientId,
4974
5082
  { stopReason: "interrupted" },
4975
5083
  this.currentEntry.messageId,
4976
5084
  this.currentEntry.wasAmend
4977
5085
  );
4978
- this.currentEntry = void 0;
4979
5086
  }
5087
+ this.currentEntry = void 0;
4980
5088
  const stranded = this.promptQueue;
4981
5089
  this.promptQueue = [];
4982
5090
  for (const entry of stranded) {
@@ -4998,12 +5106,12 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
4998
5106
  this.clients.clear();
4999
5107
  if (this.streamBuffer !== void 0) {
5000
5108
  const buf = this.streamBuffer;
5001
- const path13 = this.streamFilePath;
5109
+ const path14 = this.streamFilePath;
5002
5110
  this.streamBuffer = void 0;
5003
5111
  this.streamFilePath = void 0;
5004
5112
  buf.close();
5005
- if (path13 !== void 0) {
5006
- void buf.drainFileWrites().then(() => fsp4.unlink(path13).catch(() => void 0));
5113
+ if (path14 !== void 0) {
5114
+ void buf.drainFileWrites().then(() => fsp4.unlink(path14).catch(() => void 0));
5007
5115
  }
5008
5116
  }
5009
5117
  for (const handler of this.closeHandlers) {
@@ -5165,7 +5273,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5165
5273
  }
5166
5274
  const clientParams = this.rewriteForClient(params);
5167
5275
  const toolCallId = extractToolCallId(clientParams);
5168
- return new Promise((resolve3, reject) => {
5276
+ return new Promise((resolve4, reject) => {
5169
5277
  let settled = false;
5170
5278
  const outbound = [];
5171
5279
  const entry = { addClient: sendTo };
@@ -5204,7 +5312,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5204
5312
  update
5205
5313
  }).catch(() => void 0);
5206
5314
  }
5207
- resolve3(result);
5315
+ resolve4(result);
5208
5316
  });
5209
5317
  }).catch((err) => {
5210
5318
  settle(() => reject(err));
@@ -5220,14 +5328,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5220
5328
  // in flight, but doesn't emit prompt_queue_* broadcasts — clients
5221
5329
  // shouldn't see hydra's housekeeping in their chip list.
5222
5330
  async enqueuePrompt(task) {
5223
- return new Promise((resolve3, reject) => {
5331
+ return new Promise((resolve4, reject) => {
5224
5332
  const entry = {
5225
5333
  kind: "internal",
5226
5334
  messageId: generateMessageId(),
5227
5335
  enqueuedAt: Date.now(),
5228
5336
  cancelled: false,
5229
5337
  task,
5230
- resolve: resolve3,
5338
+ resolve: resolve4,
5231
5339
  reject
5232
5340
  };
5233
5341
  this.promptQueue.push(entry);
@@ -5246,7 +5354,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5246
5354
  if (client.clientInfo?.name) originator.name = client.clientInfo.name;
5247
5355
  if (client.clientInfo?.version)
5248
5356
  originator.version = client.clientInfo.version;
5249
- return new Promise((resolve3, reject) => {
5357
+ return new Promise((resolve4, reject) => {
5250
5358
  const entry = {
5251
5359
  kind: "user",
5252
5360
  messageId,
@@ -5255,7 +5363,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5255
5363
  prompt: promptArray,
5256
5364
  enqueuedAt: Date.now(),
5257
5365
  cancelled: false,
5258
- resolve: resolve3,
5366
+ resolve: resolve4,
5259
5367
  reject
5260
5368
  };
5261
5369
  this.promptQueue.push(entry);
@@ -5305,6 +5413,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5305
5413
  await new Promise((r) => setImmediate(r));
5306
5414
  try {
5307
5415
  while (this.promptQueue.length > 0) {
5416
+ if (this.closing) {
5417
+ break;
5418
+ }
5308
5419
  const next = this.promptQueue.shift();
5309
5420
  if (!next) {
5310
5421
  break;
@@ -5676,8 +5787,8 @@ function firstLine(text, max) {
5676
5787
  }
5677
5788
 
5678
5789
  // src/core/session-store.ts
5679
- import * as fs6 from "fs/promises";
5680
- import * as path4 from "path";
5790
+ import * as fs7 from "fs/promises";
5791
+ import * as path5 from "path";
5681
5792
  import { customAlphabet as customAlphabet2 } from "nanoid";
5682
5793
  import { z as z4 } from "zod";
5683
5794
  var HYDRA_ID_ALPHABET2 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -5761,6 +5872,12 @@ var SessionRecord = z4.object({
5761
5872
  // Set when this session was spawned as a child by a transformer via
5762
5873
  // hydra-acp/spawn_child_session. Points to the spawning session's id.
5763
5874
  parentSessionId: z4.string().optional(),
5875
+ // Set when this session was created by hydra-acp/fork_session.
5876
+ // forkedFromSessionId points to the local source session; forkedFromMessageId
5877
+ // is the resolved forkAt — the messageId of the turn_complete the slice
5878
+ // ended at. Kept so future UI can show "branched from turn N of session X".
5879
+ forkedFromSessionId: z4.string().optional(),
5880
+ forkedFromMessageId: z4.string().optional(),
5764
5881
  // clientInfo from the process that issued session/new. Picker and
5765
5882
  // `sessions list` use this to hide cat-style ancillary sessions by
5766
5883
  // default; carried in meta.json so cold sessions filter the same way.
@@ -5777,30 +5894,21 @@ function assertSafeId(id) {
5777
5894
  var SessionStore = class {
5778
5895
  async write(record) {
5779
5896
  assertSafeId(record.sessionId);
5780
- await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
5781
5897
  const full = { version: 1, ...record };
5782
- await fs6.writeFile(
5783
- paths.sessionFile(record.sessionId),
5784
- JSON.stringify(full, null, 2) + "\n",
5785
- { encoding: "utf8", mode: 384 }
5786
- );
5898
+ await writeJsonAtomic(paths.sessionFile(record.sessionId), full, {
5899
+ mode: 384
5900
+ });
5787
5901
  }
5788
5902
  async read(sessionId) {
5789
5903
  if (!SESSION_ID_PATTERN.test(sessionId)) {
5790
5904
  return void 0;
5791
5905
  }
5792
- let raw;
5793
- try {
5794
- raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
5795
- } catch (err) {
5796
- const e = err;
5797
- if (e.code === "ENOENT") {
5798
- return void 0;
5799
- }
5800
- throw err;
5906
+ const parsed = await readJsonSafe(paths.sessionFile(sessionId));
5907
+ if (parsed === void 0) {
5908
+ return void 0;
5801
5909
  }
5802
5910
  try {
5803
- return SessionRecord.parse(JSON.parse(raw));
5911
+ return SessionRecord.parse(parsed);
5804
5912
  } catch {
5805
5913
  return void 0;
5806
5914
  }
@@ -5810,7 +5918,7 @@ var SessionStore = class {
5810
5918
  return;
5811
5919
  }
5812
5920
  try {
5813
- await fs6.unlink(paths.sessionFile(sessionId));
5921
+ await fs7.unlink(paths.sessionFile(sessionId));
5814
5922
  } catch (err) {
5815
5923
  const e = err;
5816
5924
  if (e.code !== "ENOENT") {
@@ -5818,7 +5926,7 @@ var SessionStore = class {
5818
5926
  }
5819
5927
  }
5820
5928
  try {
5821
- await fs6.rmdir(paths.sessionDir(sessionId));
5929
+ await fs7.rmdir(paths.sessionDir(sessionId));
5822
5930
  } catch (err) {
5823
5931
  const e = err;
5824
5932
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -5848,7 +5956,7 @@ var SessionStore = class {
5848
5956
  async list() {
5849
5957
  let entries;
5850
5958
  try {
5851
- entries = await fs6.readdir(paths.sessionsDir());
5959
+ entries = await fs7.readdir(paths.sessionsDir());
5852
5960
  } catch (err) {
5853
5961
  const e = err;
5854
5962
  if (e.code === "ENOENT") {
@@ -5887,6 +5995,8 @@ function recordFromMemorySession(args) {
5887
5995
  agentModels: args.agentModels,
5888
5996
  pendingHistorySync: args.pendingHistorySync,
5889
5997
  parentSessionId: args.parentSessionId,
5998
+ forkedFromSessionId: args.forkedFromSessionId,
5999
+ forkedFromMessageId: args.forkedFromMessageId,
5890
6000
  originatingClient: args.originatingClient,
5891
6001
  createdAt: args.createdAt ?? now,
5892
6002
  updatedAt: args.updatedAt ?? now
@@ -5894,7 +6004,7 @@ function recordFromMemorySession(args) {
5894
6004
  }
5895
6005
 
5896
6006
  // src/core/history-store.ts
5897
- import * as fs7 from "fs/promises";
6007
+ import * as fs8 from "fs/promises";
5898
6008
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
5899
6009
  var DEFAULT_MAX_ENTRIES = 1e3;
5900
6010
  var HistoryStore = class {
@@ -5911,9 +6021,9 @@ var HistoryStore = class {
5911
6021
  return;
5912
6022
  }
5913
6023
  return this.enqueue(sessionId, async () => {
5914
- await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
6024
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
5915
6025
  const line = JSON.stringify(entry) + "\n";
5916
- await fs7.appendFile(paths.historyFile(sessionId), line, {
6026
+ await fs8.appendFile(paths.historyFile(sessionId), line, {
5917
6027
  encoding: "utf8",
5918
6028
  mode: 384
5919
6029
  });
@@ -5924,9 +6034,9 @@ var HistoryStore = class {
5924
6034
  return;
5925
6035
  }
5926
6036
  return this.enqueue(sessionId, async () => {
5927
- await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
6037
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
5928
6038
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
5929
- await fs7.writeFile(paths.historyFile(sessionId), body, {
6039
+ await fs8.writeFile(paths.historyFile(sessionId), body, {
5930
6040
  encoding: "utf8",
5931
6041
  mode: 384
5932
6042
  });
@@ -5943,7 +6053,7 @@ var HistoryStore = class {
5943
6053
  return this.enqueue(sessionId, async () => {
5944
6054
  let raw;
5945
6055
  try {
5946
- raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
6056
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
5947
6057
  } catch (err) {
5948
6058
  const e = err;
5949
6059
  if (e.code === "ENOENT") {
@@ -5956,7 +6066,7 @@ var HistoryStore = class {
5956
6066
  return;
5957
6067
  }
5958
6068
  const trimmed = lines.slice(-maxEntries);
5959
- await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
6069
+ await fs8.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
5960
6070
  encoding: "utf8",
5961
6071
  mode: 384
5962
6072
  });
@@ -5972,7 +6082,7 @@ var HistoryStore = class {
5972
6082
  }
5973
6083
  let raw;
5974
6084
  try {
5975
- raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
6085
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
5976
6086
  } catch (err) {
5977
6087
  const e = err;
5978
6088
  if (e.code === "ENOENT") {
@@ -6012,13 +6122,26 @@ var HistoryStore = class {
6012
6122
  }
6013
6123
  return out;
6014
6124
  }
6125
+ // Wait for every pending append/rewrite/compact across all sessions to
6126
+ // settle. Daemon shutdown calls this after closing sessions so the final
6127
+ // turn_complete(interrupted) emitted by markClosed reaches disk before
6128
+ // the process exits — without this, history-replay attaches after a
6129
+ // restart see an unmatched prompt_received and leak pendingTurns on
6130
+ // every client.
6131
+ async flushAll() {
6132
+ const pending = [...this.writeQueues.values()];
6133
+ if (pending.length === 0) {
6134
+ return;
6135
+ }
6136
+ await Promise.allSettled(pending);
6137
+ }
6015
6138
  async delete(sessionId) {
6016
6139
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
6017
6140
  return;
6018
6141
  }
6019
6142
  return this.enqueue(sessionId, async () => {
6020
6143
  try {
6021
- await fs7.unlink(paths.historyFile(sessionId));
6144
+ await fs8.unlink(paths.historyFile(sessionId));
6022
6145
  } catch (err) {
6023
6146
  const e = err;
6024
6147
  if (e.code !== "ENOENT") {
@@ -6026,7 +6149,7 @@ var HistoryStore = class {
6026
6149
  }
6027
6150
  }
6028
6151
  try {
6029
- await fs7.rmdir(paths.sessionDir(sessionId));
6152
+ await fs8.rmdir(paths.sessionDir(sessionId));
6030
6153
  } catch (err) {
6031
6154
  const e = err;
6032
6155
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -6050,30 +6173,113 @@ var HistoryStore = class {
6050
6173
  };
6051
6174
 
6052
6175
  // src/tui/history.ts
6053
- import { promises as fs8 } from "fs";
6054
- import * as path5 from "path";
6176
+ import { promises as fs9 } from "fs";
6177
+ import * as path6 from "path";
6055
6178
  async function saveHistory(file, history) {
6056
- await fs8.mkdir(path5.dirname(file), { recursive: true });
6179
+ await fs9.mkdir(path6.dirname(file), { recursive: true });
6057
6180
  const lines = history.map((entry) => JSON.stringify(entry));
6058
- await fs8.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
6181
+ await fs9.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
6182
+ }
6183
+
6184
+ // src/core/bundle.ts
6185
+ import { z as z5 } from "zod";
6186
+ var HistoryEntrySchema = z5.object({
6187
+ method: z5.string(),
6188
+ params: z5.unknown(),
6189
+ recordedAt: z5.number()
6190
+ });
6191
+ var BundleSession = z5.object({
6192
+ // The exporter's local id. Regenerated fresh on import (sessionId is
6193
+ // the local namespace; lineageId is what survives across hops).
6194
+ sessionId: z5.string(),
6195
+ // Required on bundles — the export path backfills if the source
6196
+ // record was written before lineageId existed.
6197
+ lineageId: z5.string(),
6198
+ // The exporter's agent-side session id at export time. Carried so
6199
+ // importers can persist it as a breadcrumb (and, eventually, as the
6200
+ // handle a "connect back to origin" feature would need). Omitted on
6201
+ // bundles whose source record never bound to an agent (e.g. a
6202
+ // re-export of an imported, not-yet-attached session).
6203
+ upstreamSessionId: z5.string().optional(),
6204
+ agentId: z5.string(),
6205
+ cwd: z5.string(),
6206
+ title: z5.string().optional(),
6207
+ currentModel: z5.string().optional(),
6208
+ currentMode: z5.string().optional(),
6209
+ currentUsage: PersistedUsage.optional(),
6210
+ agentCommands: z5.array(PersistedAgentCommand).optional(),
6211
+ agentModes: z5.array(PersistedAgentMode).optional(),
6212
+ createdAt: z5.string(),
6213
+ updatedAt: z5.string()
6214
+ });
6215
+ var Bundle = z5.object({
6216
+ version: z5.literal(1),
6217
+ exportedAt: z5.string(),
6218
+ exportedFrom: z5.object({
6219
+ hydraVersion: z5.string(),
6220
+ machine: z5.string(),
6221
+ // Externally-reachable name (and optional ":port") for the exporting
6222
+ // daemon, sourced from config.daemon.publicHost (or daemon.host when
6223
+ // non-loopback). Carried so an importer can construct a hydra:// URL
6224
+ // that dials back to the origin — e.g. over Tailscale. Omitted when
6225
+ // the exporter has no routable address; never falls back to loopback.
6226
+ hydraHost: z5.string().optional()
6227
+ }),
6228
+ session: BundleSession,
6229
+ history: z5.array(HistoryEntrySchema),
6230
+ promptHistory: z5.array(z5.string()).optional()
6231
+ });
6232
+ function encodeBundle(params) {
6233
+ const bundle = {
6234
+ version: 1,
6235
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
6236
+ exportedFrom: {
6237
+ hydraVersion: params.hydraVersion,
6238
+ machine: params.machine,
6239
+ ...params.hydraHost !== void 0 && params.hydraHost.length > 0 ? { hydraHost: params.hydraHost } : {}
6240
+ },
6241
+ session: {
6242
+ sessionId: params.record.sessionId,
6243
+ lineageId: params.record.lineageId,
6244
+ ...params.record.upstreamSessionId ? { upstreamSessionId: params.record.upstreamSessionId } : {},
6245
+ agentId: params.record.agentId,
6246
+ cwd: params.record.cwd,
6247
+ ...params.record.title !== void 0 ? { title: params.record.title } : {},
6248
+ ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
6249
+ ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
6250
+ ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
6251
+ ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
6252
+ ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
6253
+ createdAt: params.record.createdAt,
6254
+ updatedAt: params.record.updatedAt
6255
+ },
6256
+ history: params.history
6257
+ };
6258
+ if (params.promptHistory !== void 0) {
6259
+ bundle.promptHistory = params.promptHistory;
6260
+ }
6261
+ return bundle;
6262
+ }
6263
+ function decodeBundle(raw) {
6264
+ return Bundle.parse(raw);
6059
6265
  }
6060
6266
 
6061
6267
  // src/core/hydra-version.ts
6062
6268
  import { fileURLToPath } from "url";
6063
- import * as path6 from "path";
6064
- import * as fs9 from "fs";
6269
+ import * as path7 from "path";
6270
+ import * as fs10 from "fs";
6065
6271
  function resolveVersion() {
6066
6272
  try {
6067
- let dir = path6.dirname(fileURLToPath(import.meta.url));
6273
+ let dir = path7.dirname(fileURLToPath(import.meta.url));
6068
6274
  for (let i = 0; i < 8; i += 1) {
6069
- const candidate = path6.join(dir, "package.json");
6070
- if (fs9.existsSync(candidate)) {
6071
- const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
6275
+ const candidate = path7.join(dir, "package.json");
6276
+ if (fs10.existsSync(candidate)) {
6277
+ const pkg = JSON.parse(fs10.readFileSync(candidate, "utf8"));
6072
6278
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
6073
6279
  return pkg.version;
6074
6280
  }
6075
6281
  }
6076
- const parent = path6.dirname(dir);
6282
+ const parent = path7.dirname(dir);
6077
6283
  if (parent === dir) {
6078
6284
  break;
6079
6285
  }
@@ -6349,6 +6555,8 @@ var SessionManager = class {
6349
6555
  firstPromptSeeded: !!params.title,
6350
6556
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6351
6557
  originatingClient: params.originatingClient,
6558
+ forkedFromSessionId: params.forkedFromSessionId,
6559
+ forkedFromMessageId: params.forkedFromMessageId,
6352
6560
  extensionCommands: this.extensionCommands
6353
6561
  });
6354
6562
  await this.attachManagerHooks(session);
@@ -6417,6 +6625,8 @@ var SessionManager = class {
6417
6625
  firstPromptSeeded: !!params.title,
6418
6626
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6419
6627
  originatingClient: params.originatingClient,
6628
+ forkedFromSessionId: params.forkedFromSessionId,
6629
+ forkedFromMessageId: params.forkedFromMessageId,
6420
6630
  extensionCommands: this.extensionCommands
6421
6631
  });
6422
6632
  await this.attachManagerHooks(session);
@@ -6425,7 +6635,7 @@ var SessionManager = class {
6425
6635
  }
6426
6636
  async resolveImportCwd(cwd) {
6427
6637
  try {
6428
- const stat2 = await fs10.stat(cwd);
6638
+ const stat2 = await fs11.stat(cwd);
6429
6639
  if (stat2.isDirectory()) {
6430
6640
  return cwd;
6431
6641
  }
@@ -6767,7 +6977,9 @@ var SessionManager = class {
6767
6977
  agentModels: record.agentModels,
6768
6978
  createdAt: record.createdAt,
6769
6979
  pendingHistorySync: record.pendingHistorySync,
6770
- originatingClient: record.originatingClient
6980
+ originatingClient: record.originatingClient,
6981
+ forkedFromSessionId: record.forkedFromSessionId,
6982
+ forkedFromMessageId: record.forkedFromMessageId
6771
6983
  };
6772
6984
  }
6773
6985
  async clearPendingHistorySync(sessionId) {
@@ -6868,6 +7080,8 @@ var SessionManager = class {
6868
7080
  currentModel: session.currentModel,
6869
7081
  currentUsage: session.currentUsage,
6870
7082
  parentSessionId: session.parentSessionId,
7083
+ forkedFromSessionId: session.forkedFromSessionId,
7084
+ forkedFromMessageId: session.forkedFromMessageId,
6871
7085
  originatingClient: session.originatingClient,
6872
7086
  updatedAt: used,
6873
7087
  attachedClients: session.attachedCount,
@@ -6898,6 +7112,8 @@ var SessionManager = class {
6898
7112
  importedFromMachine: r.importedFromMachine,
6899
7113
  importedFromUpstreamSessionId: r.importedFromUpstreamSessionId,
6900
7114
  parentSessionId: r.parentSessionId,
7115
+ forkedFromSessionId: r.forkedFromSessionId,
7116
+ forkedFromMessageId: r.forkedFromMessageId,
6901
7117
  originatingClient: r.originatingClient,
6902
7118
  updatedAt: used,
6903
7119
  attachedClients: 0,
@@ -6985,10 +7201,114 @@ var SessionManager = class {
6985
7201
  replaced: false
6986
7202
  };
6987
7203
  }
6988
- // Write the imported bundle's history.jsonl, prompt-history (if
6989
- // present), and meta.json. upstreamSessionId is left empty as the
7204
+ // Branch an existing local session into a new one that shares context
7205
+ // up to the chosen turn boundary and diverges from there. Composes the
7206
+ // import pipeline: synthesizes a Bundle from the source's record and
7207
+ // sliced history, mints a fresh lineageId, then writes the new record
7208
+ // via writeImportedRecord with forked* breadcrumbs instead of
7209
+ // imported*. The fork carries upstreamSessionId="" so the first attach
7210
+ // triggers seedFromImport — same wire shape as an imported session.
7211
+ //
7212
+ // forkAt defaults to the messageId of the source's most recent
7213
+ // turn_complete; explicit forkAt must reference a session/update
7214
+ // entry that's present in the source's history.jsonl. Cutting at a
7215
+ // completed turn excludes any in-flight prompt by construction
7216
+ // (history.jsonl is appended serially per session), so no locking
7217
+ // against the live source is needed.
7218
+ //
7219
+ // agentId defaults to the source's agent. Overriding to a different
7220
+ // agent scrubs agent-specific state from the fork (model, mode,
7221
+ // usage, agent-emitted commands/modes/models) so the new agent boots
7222
+ // clean — title and conversation transcript are agent-agnostic and
7223
+ // are kept.
7224
+ async forkSession(sourceSessionId, opts = {}) {
7225
+ const sourceRecord = await this.store.read(sourceSessionId);
7226
+ if (!sourceRecord) {
7227
+ const err = new Error(`source session not found: ${sourceSessionId}`);
7228
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7229
+ throw err;
7230
+ }
7231
+ const targetAgentId = opts.agentId ?? sourceRecord.agentId;
7232
+ const crossAgent = targetAgentId !== sourceRecord.agentId;
7233
+ if (crossAgent) {
7234
+ const def = await this.registry.getAgent(targetAgentId);
7235
+ if (!def) {
7236
+ const err = new Error(
7237
+ `agent ${targetAgentId} not found in registry`
7238
+ );
7239
+ err.code = JsonRpcErrorCodes.AgentNotInstalled;
7240
+ throw err;
7241
+ }
7242
+ }
7243
+ const sourceHistory = await this.histories.load(sourceSessionId).catch(() => []);
7244
+ let cutoffIndex;
7245
+ let forkedAt;
7246
+ if (opts.forkAt !== void 0) {
7247
+ cutoffIndex = findMessageIdIndex(sourceHistory, opts.forkAt);
7248
+ if (cutoffIndex < 0) {
7249
+ const err = new Error(
7250
+ `forkAt messageId not found in source history: ${opts.forkAt}`
7251
+ );
7252
+ err.code = JsonRpcErrorCodes.InvalidParams;
7253
+ throw err;
7254
+ }
7255
+ forkedAt = opts.forkAt;
7256
+ } else {
7257
+ const found = findLastTurnComplete(sourceHistory);
7258
+ if (!found) {
7259
+ const err = new Error(
7260
+ `source session ${sourceSessionId} has no completed turns to fork from`
7261
+ );
7262
+ err.code = JsonRpcErrorCodes.InvalidParams;
7263
+ throw err;
7264
+ }
7265
+ cutoffIndex = found.index;
7266
+ forkedAt = found.messageId;
7267
+ }
7268
+ const slicedHistory = sourceHistory.slice(0, cutoffIndex + 1);
7269
+ const promptHistory = await loadPromptHistorySafely(sourceSessionId);
7270
+ const recordForBundle = {
7271
+ ...sourceRecord,
7272
+ lineageId: generateLineageId(),
7273
+ agentId: targetAgentId,
7274
+ ...crossAgent ? {
7275
+ currentModel: void 0,
7276
+ currentMode: void 0,
7277
+ currentUsage: void 0,
7278
+ agentCommands: void 0,
7279
+ agentModes: void 0,
7280
+ agentModels: void 0
7281
+ } : {}
7282
+ };
7283
+ const bundle = encodeBundle({
7284
+ record: recordForBundle,
7285
+ history: slicedHistory,
7286
+ promptHistory: promptHistory.length > 0 ? promptHistory : void 0,
7287
+ hydraVersion: HYDRA_VERSION,
7288
+ machine: os2.hostname()
7289
+ });
7290
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
7291
+ await this.writeImportedRecord({
7292
+ sessionId: newId,
7293
+ bundle,
7294
+ cwd: opts.cwd,
7295
+ forkedFromSessionId: sourceSessionId,
7296
+ forkedFromMessageId: forkedAt
7297
+ });
7298
+ return {
7299
+ sessionId: newId,
7300
+ forkedFromSessionId: sourceSessionId,
7301
+ forkedAt
7302
+ };
7303
+ }
7304
+ // Write the imported (or forked) bundle's history.jsonl, prompt-history
7305
+ // (if present), and meta.json. upstreamSessionId is left empty as the
6990
7306
  // marker that the first attach should bootstrap a fresh agent and
6991
- // run seedFromImport rather than calling session/load.
7307
+ // run seedFromImport rather than calling session/load. When
7308
+ // forkedFromSessionId is set, the record is marked as a local fork
7309
+ // (forked* fields populated) instead of a cross-machine import
7310
+ // (imported* fields populated) — both share the seed-on-first-attach
7311
+ // wire shape but trace differently in list views.
6992
7312
  async writeImportedRecord(args) {
6993
7313
  await this.histories.rewrite(
6994
7314
  args.sessionId,
@@ -6996,7 +7316,7 @@ var SessionManager = class {
6996
7316
  );
6997
7317
  const sourceMtime = new Date(args.bundle.session.updatedAt);
6998
7318
  if (!Number.isNaN(sourceMtime.getTime())) {
6999
- await fs10.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
7319
+ await fs11.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
7000
7320
  }
7001
7321
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
7002
7322
  await saveHistory(
@@ -7005,14 +7325,20 @@ var SessionManager = class {
7005
7325
  ).catch(() => void 0);
7006
7326
  }
7007
7327
  const now = (/* @__PURE__ */ new Date()).toISOString();
7328
+ const isFork = args.forkedFromSessionId !== void 0;
7008
7329
  await this.enqueueMetaWrite(args.sessionId, async () => {
7009
7330
  await this.store.write({
7010
7331
  sessionId: args.sessionId,
7011
7332
  lineageId: args.bundle.session.lineageId,
7012
7333
  upstreamSessionId: "",
7013
- importedFromSessionId: args.bundle.session.sessionId,
7014
- importedFromUpstreamSessionId: args.bundle.session.upstreamSessionId,
7015
- importedFromMachine: args.bundle.exportedFrom.machine,
7334
+ ...isFork ? {
7335
+ forkedFromSessionId: args.forkedFromSessionId,
7336
+ forkedFromMessageId: args.forkedFromMessageId
7337
+ } : {
7338
+ importedFromSessionId: args.bundle.session.sessionId,
7339
+ importedFromUpstreamSessionId: args.bundle.session.upstreamSessionId,
7340
+ importedFromMachine: args.bundle.exportedFrom.machine
7341
+ },
7016
7342
  agentId: args.bundle.session.agentId,
7017
7343
  cwd: args.cwd ?? args.bundle.session.cwd,
7018
7344
  title: args.bundle.session.title,
@@ -7146,6 +7472,14 @@ var SessionManager = class {
7146
7472
  }
7147
7473
  await Promise.allSettled(pending);
7148
7474
  }
7475
+ // Wait for every pending history.jsonl write to settle. markClosed
7476
+ // broadcasts turn_complete(interrupted) for the in-flight turn via a
7477
+ // fire-and-forget store.append; without flushing, a SIGTERM can exit
7478
+ // before that append hits disk, leaving an unmatched prompt_received
7479
+ // in history that leaks pendingTurns on every client that replays it.
7480
+ async flushHistoryWrites() {
7481
+ await this.histories.flushAll();
7482
+ }
7149
7483
  // Startup hook: scan persisted sessions for non-empty queue files,
7150
7484
  // apply the TTL, resurrect anything with surviving entries, and
7151
7485
  // replay them through the normal queue path. Called from the daemon
@@ -7244,6 +7578,8 @@ function mergeForPersistence(session, existing) {
7244
7578
  agentModes,
7245
7579
  agentModels,
7246
7580
  parentSessionId: session.parentSessionId ?? existing?.parentSessionId,
7581
+ forkedFromSessionId: session.forkedFromSessionId ?? existing?.forkedFromSessionId,
7582
+ forkedFromMessageId: session.forkedFromMessageId ?? existing?.forkedFromMessageId,
7247
7583
  originatingClient: session.originatingClient ?? existing?.originatingClient,
7248
7584
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
7249
7585
  });
@@ -7513,9 +7849,26 @@ function parseModesList(list) {
7513
7849
  }
7514
7850
  return out;
7515
7851
  }
7852
+ function findLastTurnComplete(history) {
7853
+ for (let i = history.length - 1; i >= 0; i--) {
7854
+ const entry = history[i];
7855
+ if (!entry || entry.method !== "session/update") {
7856
+ continue;
7857
+ }
7858
+ const update = entry.params?.update;
7859
+ if (update?.sessionUpdate !== "turn_complete") {
7860
+ continue;
7861
+ }
7862
+ if (typeof update.messageId !== "string" || update.messageId.length === 0) {
7863
+ continue;
7864
+ }
7865
+ return { index: i, messageId: update.messageId };
7866
+ }
7867
+ return void 0;
7868
+ }
7516
7869
  async function loadPromptHistorySafely(sessionId) {
7517
7870
  try {
7518
- const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7871
+ const raw = await fs11.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7519
7872
  const out = [];
7520
7873
  for (const line of raw.split("\n")) {
7521
7874
  if (line.length === 0) {
@@ -7536,7 +7889,7 @@ async function loadPromptHistorySafely(sessionId) {
7536
7889
  }
7537
7890
  async function historyMtimeIso(sessionId) {
7538
7891
  try {
7539
- const st = await fs10.stat(paths.historyFile(sessionId));
7892
+ const st = await fs11.stat(paths.historyFile(sessionId));
7540
7893
  return new Date(st.mtimeMs).toISOString();
7541
7894
  } catch {
7542
7895
  return void 0;
@@ -7545,9 +7898,9 @@ async function historyMtimeIso(sessionId) {
7545
7898
 
7546
7899
  // src/core/extensions.ts
7547
7900
  import { spawn as spawn4 } from "child_process";
7548
- import * as fs11 from "fs";
7901
+ import * as fs12 from "fs";
7549
7902
  import * as fsp5 from "fs/promises";
7550
- import * as path7 from "path";
7903
+ import * as path8 from "path";
7551
7904
  var RESTART_BASE_MS = 1e3;
7552
7905
  var RESTART_CAP_MS = 6e4;
7553
7906
  var STOP_GRACE_MS = 3e3;
@@ -7604,9 +7957,9 @@ var ExtensionManager = class {
7604
7957
  } catch {
7605
7958
  }
7606
7959
  tasks.push(
7607
- new Promise((resolve3) => {
7960
+ new Promise((resolve4) => {
7608
7961
  if (child.exitCode !== null || child.signalCode !== null) {
7609
- resolve3();
7962
+ resolve4();
7610
7963
  return;
7611
7964
  }
7612
7965
  const timer = setTimeout(() => {
@@ -7614,11 +7967,11 @@ var ExtensionManager = class {
7614
7967
  child.kill("SIGKILL");
7615
7968
  } catch {
7616
7969
  }
7617
- resolve3();
7970
+ resolve4();
7618
7971
  }, STOP_GRACE_MS);
7619
7972
  child.on("exit", () => {
7620
7973
  clearTimeout(timer);
7621
- resolve3();
7974
+ resolve4();
7622
7975
  });
7623
7976
  })
7624
7977
  );
@@ -7726,8 +8079,8 @@ var ExtensionManager = class {
7726
8079
  if (child.exitCode !== null || child.signalCode !== null) {
7727
8080
  return;
7728
8081
  }
7729
- const exited = new Promise((resolve3) => {
7730
- entry.exitWaiters.push(resolve3);
8082
+ const exited = new Promise((resolve4) => {
8083
+ entry.exitWaiters.push(resolve4);
7731
8084
  });
7732
8085
  try {
7733
8086
  child.kill("SIGTERM");
@@ -7802,7 +8155,7 @@ var ExtensionManager = class {
7802
8155
  if (!entry.endsWith(".pid")) {
7803
8156
  continue;
7804
8157
  }
7805
- const pidPath = path7.join(paths.extensionsDir(), entry);
8158
+ const pidPath = path8.join(paths.extensionsDir(), entry);
7806
8159
  let pid;
7807
8160
  try {
7808
8161
  const raw = await fsp5.readFile(pidPath, "utf8");
@@ -7841,7 +8194,7 @@ var ExtensionManager = class {
7841
8194
  }
7842
8195
  const ext = entry.config;
7843
8196
  const command = ext.command.length > 0 ? ext.command : [ext.name];
7844
- const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
8197
+ const logStream = fs12.createWriteStream(paths.extensionLogFile(ext.name), {
7845
8198
  flags: "a"
7846
8199
  });
7847
8200
  logStream.write(
@@ -7894,7 +8247,7 @@ var ExtensionManager = class {
7894
8247
  }
7895
8248
  if (typeof child.pid === "number") {
7896
8249
  try {
7897
- fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8250
+ fs12.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7898
8251
  `, {
7899
8252
  encoding: "utf8",
7900
8253
  mode: 384
@@ -7919,7 +8272,7 @@ var ExtensionManager = class {
7919
8272
  });
7920
8273
  child.on("exit", (code, signal) => {
7921
8274
  try {
7922
- fs11.unlinkSync(paths.extensionPidFile(ext.name));
8275
+ fs12.unlinkSync(paths.extensionPidFile(ext.name));
7923
8276
  } catch {
7924
8277
  }
7925
8278
  logStream.write(
@@ -7934,8 +8287,8 @@ var ExtensionManager = class {
7934
8287
  entry.processToken = void 0;
7935
8288
  }
7936
8289
  const waiters = entry.exitWaiters.splice(0);
7937
- for (const resolve3 of waiters) {
7938
- resolve3();
8290
+ for (const resolve4 of waiters) {
8291
+ resolve4();
7939
8292
  }
7940
8293
  if (this.stopping || entry.manuallyStopped) {
7941
8294
  try {
@@ -7981,9 +8334,9 @@ function withCode2(err, code) {
7981
8334
 
7982
8335
  // src/core/transformer-manager.ts
7983
8336
  import { spawn as spawn5 } from "child_process";
7984
- import * as fs12 from "fs";
8337
+ import * as fs13 from "fs";
7985
8338
  import * as fsp6 from "fs/promises";
7986
- import * as path8 from "path";
8339
+ import * as path9 from "path";
7987
8340
  var RESTART_BASE_MS2 = 1e3;
7988
8341
  var RESTART_CAP_MS2 = 6e4;
7989
8342
  var STOP_GRACE_MS2 = 3e3;
@@ -8067,9 +8420,9 @@ var TransformerManager = class {
8067
8420
  } catch {
8068
8421
  }
8069
8422
  tasks.push(
8070
- new Promise((resolve3) => {
8423
+ new Promise((resolve4) => {
8071
8424
  if (child.exitCode !== null || child.signalCode !== null) {
8072
- resolve3();
8425
+ resolve4();
8073
8426
  return;
8074
8427
  }
8075
8428
  const timer = setTimeout(() => {
@@ -8077,11 +8430,11 @@ var TransformerManager = class {
8077
8430
  child.kill("SIGKILL");
8078
8431
  } catch {
8079
8432
  }
8080
- resolve3();
8433
+ resolve4();
8081
8434
  }, STOP_GRACE_MS2);
8082
8435
  child.on("exit", () => {
8083
8436
  clearTimeout(timer);
8084
- resolve3();
8437
+ resolve4();
8085
8438
  });
8086
8439
  })
8087
8440
  );
@@ -8186,8 +8539,8 @@ var TransformerManager = class {
8186
8539
  if (child.exitCode !== null || child.signalCode !== null) {
8187
8540
  return;
8188
8541
  }
8189
- const exited = new Promise((resolve3) => {
8190
- entry.exitWaiters.push(resolve3);
8542
+ const exited = new Promise((resolve4) => {
8543
+ entry.exitWaiters.push(resolve4);
8191
8544
  });
8192
8545
  try {
8193
8546
  child.kill("SIGTERM");
@@ -8262,7 +8615,7 @@ var TransformerManager = class {
8262
8615
  if (!entry.endsWith(".pid")) {
8263
8616
  continue;
8264
8617
  }
8265
- const pidPath = path8.join(paths.transformersDir(), entry);
8618
+ const pidPath = path9.join(paths.transformersDir(), entry);
8266
8619
  let pid;
8267
8620
  try {
8268
8621
  const raw = await fsp6.readFile(pidPath, "utf8");
@@ -8301,7 +8654,7 @@ var TransformerManager = class {
8301
8654
  }
8302
8655
  const t = entry.config;
8303
8656
  const command = t.command.length > 0 ? t.command : [t.name];
8304
- const logStream = fs12.createWriteStream(paths.transformerLogFile(t.name), {
8657
+ const logStream = fs13.createWriteStream(paths.transformerLogFile(t.name), {
8305
8658
  flags: "a"
8306
8659
  });
8307
8660
  logStream.write(
@@ -8354,7 +8707,7 @@ var TransformerManager = class {
8354
8707
  }
8355
8708
  if (typeof child.pid === "number") {
8356
8709
  try {
8357
- fs12.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
8710
+ fs13.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
8358
8711
  `, {
8359
8712
  encoding: "utf8",
8360
8713
  mode: 384
@@ -8379,7 +8732,7 @@ var TransformerManager = class {
8379
8732
  });
8380
8733
  child.on("exit", (code, signal) => {
8381
8734
  try {
8382
- fs12.unlinkSync(paths.transformerPidFile(t.name));
8735
+ fs13.unlinkSync(paths.transformerPidFile(t.name));
8383
8736
  } catch {
8384
8737
  }
8385
8738
  logStream.write(
@@ -8394,8 +8747,8 @@ var TransformerManager = class {
8394
8747
  entry.processToken = void 0;
8395
8748
  }
8396
8749
  const waiters = entry.exitWaiters.splice(0);
8397
- for (const resolve3 of waiters) {
8398
- resolve3();
8750
+ for (const resolve4 of waiters) {
8751
+ resolve4();
8399
8752
  }
8400
8753
  if (this.stopping || entry.manuallyStopped) {
8401
8754
  try {
@@ -8490,7 +8843,7 @@ var ExtensionCommandRegistry = class {
8490
8843
 
8491
8844
  // src/core/agent-prune.ts
8492
8845
  import * as fsp7 from "fs/promises";
8493
- import * as path9 from "path";
8846
+ import * as path10 from "path";
8494
8847
  var logSink3 = (msg) => {
8495
8848
  process.stderr.write(msg + "\n");
8496
8849
  };
@@ -8508,7 +8861,7 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
8508
8861
  desiredByAgent.set(a.id, a.version ?? "current");
8509
8862
  }
8510
8863
  const activeByAgent = sessionManager.activeAgentVersions();
8511
- const platformDir = path9.join(paths.agentsDir(), platformKey);
8864
+ const platformDir = path10.join(paths.agentsDir(), platformKey);
8512
8865
  let agentEntries;
8513
8866
  try {
8514
8867
  agentEntries = await fsp7.readdir(platformDir, { withFileTypes: true });
@@ -8530,7 +8883,7 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
8530
8883
  continue;
8531
8884
  }
8532
8885
  const activeVersions = activeByAgent.get(agentId) ?? /* @__PURE__ */ new Set();
8533
- const agentDir = path9.join(platformDir, agentId);
8886
+ const agentDir = path10.join(platformDir, agentId);
8534
8887
  let versionEntries;
8535
8888
  try {
8536
8889
  versionEntries = await fsp7.readdir(agentDir, { withFileTypes: true });
@@ -8554,7 +8907,7 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
8554
8907
  if (version.includes(".partial-")) {
8555
8908
  continue;
8556
8909
  }
8557
- const versionDir = path9.join(agentDir, version);
8910
+ const versionDir = path10.join(agentDir, version);
8558
8911
  try {
8559
8912
  await fsp7.rm(versionDir, { recursive: true, force: true });
8560
8913
  logSink3(`hydra-acp: pruned stale ${agentId} ${version} (${versionDir})`);
@@ -8567,23 +8920,88 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
8567
8920
  }
8568
8921
  }
8569
8922
 
8923
+ // src/core/agent-sync-scheduler.ts
8924
+ function startAgentSyncScheduler(opts) {
8925
+ let timer;
8926
+ let stopped = false;
8927
+ let cursor = 0;
8928
+ const log = (level, msg) => {
8929
+ if (!opts.logger) {
8930
+ return;
8931
+ }
8932
+ opts.logger[level](`agent-sync: ${msg}`);
8933
+ };
8934
+ const tick = async () => {
8935
+ const installed = [];
8936
+ try {
8937
+ const doc = await opts.registry.load();
8938
+ for (const a of doc.agents) {
8939
+ const state = await agentInstallState(a);
8940
+ if (state === "yes") {
8941
+ installed.push(a.id);
8942
+ }
8943
+ }
8944
+ } catch (err) {
8945
+ log("warn", `registry load failed: ${err.message}`);
8946
+ return opts.intervalMs;
8947
+ }
8948
+ if (installed.length === 0) {
8949
+ return opts.intervalMs;
8950
+ }
8951
+ const idx = cursor % installed.length;
8952
+ cursor = (cursor + 1) % installed.length;
8953
+ const agentId = installed[idx];
8954
+ try {
8955
+ const { synced, skipped } = await opts.manager.syncFromAgent(agentId);
8956
+ log(
8957
+ "info",
8958
+ `${agentId}: synced ${synced.length}, skipped ${skipped}`
8959
+ );
8960
+ } catch (err) {
8961
+ log("warn", `${agentId}: ${err.message}`);
8962
+ }
8963
+ return Math.max(1, Math.floor(opts.intervalMs / installed.length));
8964
+ };
8965
+ const scheduleNext = (delayMs) => {
8966
+ if (stopped) {
8967
+ return;
8968
+ }
8969
+ timer = setTimeout(() => {
8970
+ tick().then((nextDelay) => {
8971
+ scheduleNext(nextDelay);
8972
+ }).catch((err) => {
8973
+ log("warn", `tick crashed: ${err.message}`);
8974
+ scheduleNext(opts.intervalMs);
8975
+ });
8976
+ }, delayMs);
8977
+ timer.unref();
8978
+ };
8979
+ scheduleNext(opts.intervalMs);
8980
+ return () => {
8981
+ stopped = true;
8982
+ if (timer) {
8983
+ clearTimeout(timer);
8984
+ timer = void 0;
8985
+ }
8986
+ };
8987
+ }
8988
+
8570
8989
  // src/core/session-tokens.ts
8571
- import * as fs13 from "fs/promises";
8572
- import * as path10 from "path";
8573
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
8990
+ import * as path11 from "path";
8991
+ import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
8574
8992
  var TOKEN_PREFIX = "hydra_session_";
8575
8993
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
8576
8994
  var ID_LENGTH = 12;
8577
8995
  var TOKEN_BYTES = 32;
8578
8996
  var WRITE_DEBOUNCE_MS = 50;
8579
8997
  function tokensFilePath() {
8580
- return path10.join(paths.home(), "session-tokens.json");
8998
+ return path11.join(paths.home(), "session-tokens.json");
8581
8999
  }
8582
9000
  function sha256Hex(input) {
8583
9001
  return createHash("sha256").update(input).digest("hex");
8584
9002
  }
8585
9003
  function randomHex(bytes) {
8586
- return randomBytes(bytes).toString("hex");
9004
+ return randomBytes2(bytes).toString("hex");
8587
9005
  }
8588
9006
  function generateId() {
8589
9007
  return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
@@ -8603,17 +9021,11 @@ var SessionTokenStore = class _SessionTokenStore {
8603
9021
  }
8604
9022
  static async load() {
8605
9023
  let records = [];
8606
- try {
8607
- const raw = await fs13.readFile(tokensFilePath(), "utf8");
8608
- const parsed = JSON.parse(raw);
8609
- if (parsed && Array.isArray(parsed.records)) {
8610
- records = parsed.records.filter(isRecord);
8611
- }
8612
- } catch (err) {
8613
- const e = err;
8614
- if (e.code !== "ENOENT") {
8615
- throw err;
8616
- }
9024
+ const parsed = await readJsonSafe(
9025
+ tokensFilePath()
9026
+ );
9027
+ if (parsed && Array.isArray(parsed.records)) {
9028
+ records = parsed.records.filter(isRecord);
8617
9029
  }
8618
9030
  const store = new _SessionTokenStore(records);
8619
9031
  const removed = store.sweepExpired(/* @__PURE__ */ new Date());
@@ -8730,14 +9142,11 @@ var SessionTokenStore = class _SessionTokenStore {
8730
9142
  await this.writeInflight;
8731
9143
  }
8732
9144
  const records = Array.from(this.records.values());
8733
- const payload = JSON.stringify({ records }, null, 2) + "\n";
8734
- this.writeInflight = (async () => {
8735
- await fs13.mkdir(paths.home(), { recursive: true });
8736
- await fs13.writeFile(tokensFilePath(), payload, {
8737
- encoding: "utf8",
8738
- mode: 384
8739
- });
8740
- })();
9145
+ this.writeInflight = writeJsonAtomic(
9146
+ tokensFilePath(),
9147
+ { records },
9148
+ { mode: 384 }
9149
+ );
8741
9150
  try {
8742
9151
  await this.writeInflight;
8743
9152
  } finally {
@@ -8905,89 +9314,6 @@ var AuthRateLimiter = class {
8905
9314
  // src/daemon/routes/sessions.ts
8906
9315
  import * as os3 from "os";
8907
9316
 
8908
- // src/core/bundle.ts
8909
- import { z as z5 } from "zod";
8910
- var HistoryEntrySchema = z5.object({
8911
- method: z5.string(),
8912
- params: z5.unknown(),
8913
- recordedAt: z5.number()
8914
- });
8915
- var BundleSession = z5.object({
8916
- // The exporter's local id. Regenerated fresh on import (sessionId is
8917
- // the local namespace; lineageId is what survives across hops).
8918
- sessionId: z5.string(),
8919
- // Required on bundles — the export path backfills if the source
8920
- // record was written before lineageId existed.
8921
- lineageId: z5.string(),
8922
- // The exporter's agent-side session id at export time. Carried so
8923
- // importers can persist it as a breadcrumb (and, eventually, as the
8924
- // handle a "connect back to origin" feature would need). Omitted on
8925
- // bundles whose source record never bound to an agent (e.g. a
8926
- // re-export of an imported, not-yet-attached session).
8927
- upstreamSessionId: z5.string().optional(),
8928
- agentId: z5.string(),
8929
- cwd: z5.string(),
8930
- title: z5.string().optional(),
8931
- currentModel: z5.string().optional(),
8932
- currentMode: z5.string().optional(),
8933
- currentUsage: PersistedUsage.optional(),
8934
- agentCommands: z5.array(PersistedAgentCommand).optional(),
8935
- agentModes: z5.array(PersistedAgentMode).optional(),
8936
- createdAt: z5.string(),
8937
- updatedAt: z5.string()
8938
- });
8939
- var Bundle = z5.object({
8940
- version: z5.literal(1),
8941
- exportedAt: z5.string(),
8942
- exportedFrom: z5.object({
8943
- hydraVersion: z5.string(),
8944
- machine: z5.string(),
8945
- // Externally-reachable name (and optional ":port") for the exporting
8946
- // daemon, sourced from config.daemon.publicHost (or daemon.host when
8947
- // non-loopback). Carried so an importer can construct a hydra:// URL
8948
- // that dials back to the origin — e.g. over Tailscale. Omitted when
8949
- // the exporter has no routable address; never falls back to loopback.
8950
- hydraHost: z5.string().optional()
8951
- }),
8952
- session: BundleSession,
8953
- history: z5.array(HistoryEntrySchema),
8954
- promptHistory: z5.array(z5.string()).optional()
8955
- });
8956
- function encodeBundle(params) {
8957
- const bundle = {
8958
- version: 1,
8959
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
8960
- exportedFrom: {
8961
- hydraVersion: params.hydraVersion,
8962
- machine: params.machine,
8963
- ...params.hydraHost !== void 0 && params.hydraHost.length > 0 ? { hydraHost: params.hydraHost } : {}
8964
- },
8965
- session: {
8966
- sessionId: params.record.sessionId,
8967
- lineageId: params.record.lineageId,
8968
- ...params.record.upstreamSessionId ? { upstreamSessionId: params.record.upstreamSessionId } : {},
8969
- agentId: params.record.agentId,
8970
- cwd: params.record.cwd,
8971
- ...params.record.title !== void 0 ? { title: params.record.title } : {},
8972
- ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
8973
- ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
8974
- ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
8975
- ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
8976
- ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
8977
- createdAt: params.record.createdAt,
8978
- updatedAt: params.record.updatedAt
8979
- },
8980
- history: params.history
8981
- };
8982
- if (params.promptHistory !== void 0) {
8983
- bundle.promptHistory = params.promptHistory;
8984
- }
8985
- return bundle;
8986
- }
8987
- function decodeBundle(raw) {
8988
- return Bundle.parse(raw);
8989
- }
8990
-
8991
9317
  // src/core/render-update.ts
8992
9318
  import stripAnsi from "strip-ansi";
8993
9319
  var STRIP_CONTROLS = /[\x00-\x08\x0b-\x1f\x7f]/g;
@@ -9181,6 +9507,51 @@ function isExitPlanModeTool(name) {
9181
9507
  const normalised = name.toLowerCase().replace(/[_\s-]/g, "");
9182
9508
  return normalised === "exitplanmode";
9183
9509
  }
9510
+ function extractEditDiff(u) {
9511
+ const content = u.content;
9512
+ if (Array.isArray(content)) {
9513
+ for (const block of content) {
9514
+ if (!block || typeof block !== "object") {
9515
+ continue;
9516
+ }
9517
+ const b = block;
9518
+ if (b.type !== "diff") {
9519
+ continue;
9520
+ }
9521
+ const oldText = typeof b.oldText === "string" ? b.oldText : void 0;
9522
+ const newText = typeof b.newText === "string" ? b.newText : void 0;
9523
+ if (oldText === void 0 && newText === void 0) {
9524
+ continue;
9525
+ }
9526
+ const path14 = typeof b.path === "string" ? b.path : void 0;
9527
+ return {
9528
+ ...path14 !== void 0 ? { path: path14 } : {},
9529
+ oldText: oldText ?? "",
9530
+ newText: newText ?? ""
9531
+ };
9532
+ }
9533
+ }
9534
+ const rawInput = u.rawInput;
9535
+ if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)) {
9536
+ const r = rawInput;
9537
+ const filePath = typeof r.file_path === "string" ? r.file_path : typeof r.path === "string" ? r.path : void 0;
9538
+ if (typeof r.old_string === "string" && typeof r.new_string === "string") {
9539
+ return {
9540
+ ...filePath !== void 0 ? { path: filePath } : {},
9541
+ oldText: r.old_string,
9542
+ newText: r.new_string
9543
+ };
9544
+ }
9545
+ if (typeof r.content === "string") {
9546
+ return {
9547
+ ...filePath !== void 0 ? { path: filePath } : {},
9548
+ oldText: "",
9549
+ newText: r.content
9550
+ };
9551
+ }
9552
+ }
9553
+ return null;
9554
+ }
9184
9555
  function readExitPlanMarkdown(u) {
9185
9556
  const rawInput = u.rawInput;
9186
9557
  if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput)) {
@@ -9220,6 +9591,10 @@ function mapToolCall(u) {
9220
9591
  if (rawKind !== void 0) {
9221
9592
  event.rawKind = rawKind;
9222
9593
  }
9594
+ const diff = extractEditDiff(u);
9595
+ if (diff !== null) {
9596
+ event.editDiff = diff;
9597
+ }
9223
9598
  return event;
9224
9599
  }
9225
9600
  function mapToolCallUpdate(u) {
@@ -9230,7 +9605,8 @@ function mapToolCallUpdate(u) {
9230
9605
  const rawTitle = readString(u, "title");
9231
9606
  const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
9232
9607
  const status = readString(u, "status");
9233
- const meaningful = title !== void 0 || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
9608
+ const diff = extractEditDiff(u);
9609
+ const meaningful = title !== void 0 || diff !== null || status === "completed" || status === "failed" || status === "rejected" || status === "cancelled";
9234
9610
  if (!meaningful) {
9235
9611
  return null;
9236
9612
  }
@@ -9253,6 +9629,9 @@ function mapToolCallUpdate(u) {
9253
9629
  if (status !== void 0) {
9254
9630
  event.status = status;
9255
9631
  }
9632
+ if (diff !== null) {
9633
+ event.editDiff = diff;
9634
+ }
9256
9635
  if (status === "failed") {
9257
9636
  const errorText = extractToolFailureText(u);
9258
9637
  if (errorText !== null) {
@@ -10172,6 +10551,48 @@ function registerSessionRoutes(app, manager, defaults) {
10172
10551
  reply.header("Content-Type", "text/markdown; charset=utf-8");
10173
10552
  reply.code(200).send(bundleToMarkdown(bundle));
10174
10553
  });
10554
+ app.post("/v1/sessions/:id/fork", async (request, reply) => {
10555
+ const raw = request.params.id;
10556
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
10557
+ const body = request.body ?? {};
10558
+ const opts = {};
10559
+ if (body.forkAt !== void 0) {
10560
+ if (typeof body.forkAt !== "string" || body.forkAt.length === 0) {
10561
+ reply.code(400).send({ error: "forkAt must be a non-empty string" });
10562
+ return;
10563
+ }
10564
+ opts.forkAt = body.forkAt;
10565
+ }
10566
+ if (body.cwd !== void 0) {
10567
+ if (typeof body.cwd !== "string" || body.cwd.length === 0) {
10568
+ reply.code(400).send({ error: "cwd must be a non-empty string" });
10569
+ return;
10570
+ }
10571
+ opts.cwd = expandHome(body.cwd);
10572
+ }
10573
+ if (body.agentId !== void 0) {
10574
+ if (typeof body.agentId !== "string" || body.agentId.length === 0) {
10575
+ reply.code(400).send({ error: "agentId must be a non-empty string" });
10576
+ return;
10577
+ }
10578
+ opts.agentId = body.agentId;
10579
+ }
10580
+ try {
10581
+ const result = await manager.forkSession(id, opts);
10582
+ reply.code(201).send(result);
10583
+ } catch (err) {
10584
+ const e = err;
10585
+ if (e.code === JsonRpcErrorCodes.SessionNotFound) {
10586
+ reply.code(404).send({ error: e.message });
10587
+ return;
10588
+ }
10589
+ if (e.code === JsonRpcErrorCodes.InvalidParams || e.code === JsonRpcErrorCodes.AgentNotInstalled) {
10590
+ reply.code(400).send({ error: e.message });
10591
+ return;
10592
+ }
10593
+ reply.code(500).send({ error: e.message });
10594
+ }
10595
+ });
10175
10596
  app.post("/v1/sessions/import", async (request, reply) => {
10176
10597
  const body = request.body ?? {};
10177
10598
  if (body.bundle === void 0) {
@@ -10284,15 +10705,20 @@ function registerSessionRoutes(app, manager, defaults) {
10284
10705
  function registerAgentRoutes(app, registry, manager, opts = {}) {
10285
10706
  app.get("/v1/agents", async () => {
10286
10707
  const doc = await registry.load();
10287
- return {
10288
- version: doc.version,
10289
- agents: doc.agents.map((a) => ({
10708
+ const agents = await Promise.all(
10709
+ doc.agents.map(async (a) => ({
10290
10710
  id: a.id,
10291
10711
  name: a.name,
10292
10712
  version: a.version,
10293
10713
  description: a.description,
10294
- distributions: Object.keys(a.distribution)
10714
+ distributions: Object.keys(a.distribution),
10715
+ installed: await agentInstallState(a)
10295
10716
  }))
10717
+ );
10718
+ return {
10719
+ version: doc.version,
10720
+ fetchedAt: registry.lastFetchedAt(),
10721
+ agents
10296
10722
  };
10297
10723
  });
10298
10724
  app.get("/v1/registry", async () => {
@@ -10611,12 +11037,12 @@ import { z as z6 } from "zod";
10611
11037
 
10612
11038
  // src/core/password.ts
10613
11039
  import * as fs14 from "fs/promises";
10614
- import * as path11 from "path";
10615
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
11040
+ import * as path12 from "path";
11041
+ import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
10616
11042
  import { promisify } from "util";
10617
11043
  var scryptAsync = promisify(scrypt);
10618
11044
  function passwordHashPath() {
10619
- return path11.join(paths.home(), "password-hash");
11045
+ return path12.join(paths.home(), "password-hash");
10620
11046
  }
10621
11047
  var DEFAULT_N = 1 << 15;
10622
11048
  var MAX_MEM = 128 * 1024 * 1024;
@@ -10805,13 +11231,13 @@ function wsToMessageStream(ws) {
10805
11231
  throw new Error("ws is closed");
10806
11232
  }
10807
11233
  const text = JSON.stringify(message);
10808
- await new Promise((resolve3, reject) => {
11234
+ await new Promise((resolve4, reject) => {
10809
11235
  ws.send(text, (err) => {
10810
11236
  if (err) {
10811
11237
  reject(err);
10812
11238
  return;
10813
11239
  }
10814
- resolve3();
11240
+ resolve4();
10815
11241
  });
10816
11242
  });
10817
11243
  },
@@ -10833,8 +11259,8 @@ function wsToMessageStream(ws) {
10833
11259
 
10834
11260
  // src/daemon/acp-ws.ts
10835
11261
  import * as os4 from "os";
10836
- import * as path12 from "path";
10837
- import { randomBytes as randomBytes3 } from "crypto";
11262
+ import * as path13 from "path";
11263
+ import { randomBytes as randomBytes4 } from "crypto";
10838
11264
  function registerAcpWsEndpoint(app, deps) {
10839
11265
  app.get("/acp", { websocket: true }, async (socket, request) => {
10840
11266
  const token = tokenFromUpgradeRequest({
@@ -10916,6 +11342,50 @@ function registerAcpWsEndpoint(app, deps) {
10916
11342
  registry.clear(processIdentity.name);
10917
11343
  });
10918
11344
  }
11345
+ if (processIdentity && deps.extensionMcp) {
11346
+ const mcpRegistry = deps.extensionMcp;
11347
+ connection.onRequest("hydra-acp/register_mcp_tools", async (raw) => {
11348
+ const params = raw ?? {};
11349
+ const instructions = typeof params.instructions === "string" ? params.instructions : void 0;
11350
+ const tools = Array.isArray(params.tools) ? params.tools.map((t) => {
11351
+ if (!t || typeof t !== "object") {
11352
+ return void 0;
11353
+ }
11354
+ const obj = t;
11355
+ if (typeof obj.name !== "string" || obj.name.length === 0) {
11356
+ return void 0;
11357
+ }
11358
+ if (typeof obj.description !== "string") {
11359
+ return void 0;
11360
+ }
11361
+ if (obj.inputSchema === null || typeof obj.inputSchema !== "object") {
11362
+ return void 0;
11363
+ }
11364
+ const spec = {
11365
+ name: obj.name,
11366
+ description: obj.description,
11367
+ inputSchema: obj.inputSchema
11368
+ };
11369
+ if (obj.outputSchema !== null && typeof obj.outputSchema === "object") {
11370
+ spec.outputSchema = obj.outputSchema;
11371
+ }
11372
+ return spec;
11373
+ }).filter((s) => s !== void 0) : [];
11374
+ if (tools.length === 0) {
11375
+ throw new Error("register_mcp_tools requires at least one tool");
11376
+ }
11377
+ mcpRegistry.register(
11378
+ processIdentity.name,
11379
+ connection,
11380
+ instructions,
11381
+ tools
11382
+ );
11383
+ return { ok: true, registered: tools.length };
11384
+ });
11385
+ connection.onClose(() => {
11386
+ mcpRegistry.clear(processIdentity.name);
11387
+ });
11388
+ }
10919
11389
  if (processIdentity?.kind === "transformer") {
10920
11390
  connection.onRequest("transformer/initialize", async (raw) => {
10921
11391
  const params = raw ?? {};
@@ -10987,6 +11457,23 @@ function registerAcpWsEndpoint(app, deps) {
10987
11457
  });
10988
11458
  return { childSessionId: child.sessionId };
10989
11459
  });
11460
+ connection.onRequest("hydra-acp/fork_session", async (raw) => {
11461
+ const params = raw ?? {};
11462
+ if (typeof params.sessionId !== "string") {
11463
+ throw Object.assign(
11464
+ new Error("fork_session requires sessionId"),
11465
+ { code: JsonRpcErrorCodes.InvalidParams }
11466
+ );
11467
+ }
11468
+ const forkAt = typeof params.forkAt === "string" ? params.forkAt : void 0;
11469
+ const cwd = typeof params.cwd === "string" ? params.cwd : void 0;
11470
+ const agentId = typeof params.agentId === "string" ? params.agentId : void 0;
11471
+ return await deps.manager.forkSession(params.sessionId, {
11472
+ ...forkAt !== void 0 ? { forkAt } : {},
11473
+ ...cwd !== void 0 ? { cwd } : {},
11474
+ ...agentId !== void 0 ? { agentId } : {}
11475
+ });
11476
+ });
10990
11477
  connection.onRequest("hydra-acp/await_child", async (raw) => {
10991
11478
  const params = raw ?? {};
10992
11479
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
@@ -11002,13 +11489,13 @@ function registerAcpWsEndpoint(app, deps) {
11002
11489
  { code: JsonRpcErrorCodes.SessionNotFound }
11003
11490
  );
11004
11491
  }
11005
- return new Promise((resolve3) => {
11492
+ return new Promise((resolve4) => {
11006
11493
  const entries = [];
11007
11494
  let unsubscribe;
11008
11495
  const finish = () => {
11009
11496
  clearTimeout(timer);
11010
11497
  unsubscribe?.();
11011
- resolve3({ entries });
11498
+ resolve4({ entries });
11012
11499
  };
11013
11500
  unsubscribe = child.onBroadcast((entry) => {
11014
11501
  entries.push(entry);
@@ -11060,12 +11547,12 @@ function registerAcpWsEndpoint(app, deps) {
11060
11547
  let stdinToken;
11061
11548
  let stdinReservation;
11062
11549
  let augmentedMcpServers = params.mcpServers;
11063
- if (hydraMeta.mcpStdin === true && deps.stdinMcpRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11064
- stdinToken = randomBytes3(32).toString("hex");
11065
- stdinReservation = deps.stdinMcpRegistry.reserve(stdinToken);
11066
- const url = `${deps.getDaemonOrigin()}/mcp/stdin`;
11550
+ if (hydraMeta.mcpStdin === true && deps.mcpTokenRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11551
+ stdinToken = randomBytes4(32).toString("hex");
11552
+ stdinReservation = deps.mcpTokenRegistry.reserve(stdinToken);
11553
+ const url = `${deps.getDaemonOrigin()}/mcp/hydra-acp-stdin`;
11067
11554
  const descriptor = {
11068
- name: "hydra_stdin",
11555
+ name: "hydra-acp-stdin",
11069
11556
  type: "http",
11070
11557
  url,
11071
11558
  headers: [
@@ -11074,6 +11561,28 @@ function registerAcpWsEndpoint(app, deps) {
11074
11561
  };
11075
11562
  augmentedMcpServers = [...params.mcpServers ?? [], descriptor];
11076
11563
  }
11564
+ let extMcpToken;
11565
+ let extMcpReservation;
11566
+ if (deps.extensionMcp !== void 0 && deps.mcpTokenRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11567
+ const extNames = deps.extensionMcp.list();
11568
+ if (extNames.length > 0) {
11569
+ extMcpToken = randomBytes4(32).toString("hex");
11570
+ extMcpReservation = deps.mcpTokenRegistry.reserve(extMcpToken);
11571
+ const origin = deps.getDaemonOrigin();
11572
+ const descriptors = extNames.map((name) => ({
11573
+ name,
11574
+ type: "http",
11575
+ url: `${origin}/mcp/${name}`,
11576
+ headers: [
11577
+ { name: "Authorization", value: `Bearer ${extMcpToken}` }
11578
+ ]
11579
+ }));
11580
+ augmentedMcpServers = [
11581
+ ...augmentedMcpServers ?? [],
11582
+ ...descriptors
11583
+ ];
11584
+ }
11585
+ }
11077
11586
  let session;
11078
11587
  try {
11079
11588
  session = await deps.manager.create({
@@ -11091,16 +11600,27 @@ function registerAcpWsEndpoint(app, deps) {
11091
11600
  if (stdinReservation !== void 0) {
11092
11601
  stdinReservation.abandon(err instanceof Error ? err : void 0);
11093
11602
  }
11603
+ if (extMcpReservation !== void 0) {
11604
+ extMcpReservation.abandon(err instanceof Error ? err : void 0);
11605
+ }
11094
11606
  throw err;
11095
11607
  }
11096
- if (stdinToken !== void 0 && stdinReservation !== void 0 && deps.stdinMcpRegistry !== void 0) {
11608
+ if (stdinToken !== void 0 && stdinReservation !== void 0 && deps.mcpTokenRegistry !== void 0) {
11097
11609
  const token2 = stdinToken;
11098
- const registry = deps.stdinMcpRegistry;
11610
+ const registry = deps.mcpTokenRegistry;
11099
11611
  stdinReservation.complete(session);
11100
11612
  session.onClose(() => {
11101
11613
  void registry.unbind(token2);
11102
11614
  });
11103
11615
  }
11616
+ if (extMcpToken !== void 0 && extMcpReservation !== void 0 && deps.mcpTokenRegistry !== void 0) {
11617
+ const token2 = extMcpToken;
11618
+ const registry = deps.mcpTokenRegistry;
11619
+ extMcpReservation.complete(session);
11620
+ session.onClose(() => {
11621
+ void registry.unbind(token2);
11622
+ });
11623
+ }
11104
11624
  const client = bindClientToSession(connection, session, state);
11105
11625
  const { entries: replay } = await session.attach(client, "full");
11106
11626
  state.attached.set(session.sessionId, {
@@ -11403,7 +11923,7 @@ function registerAcpWsEndpoint(app, deps) {
11403
11923
  openOpts.fileCapBytes = params.fileCapBytes;
11404
11924
  }
11405
11925
  if ((params.mode ?? "memory") === "file") {
11406
- openOpts.filePathFor = (sid) => path12.join(os4.tmpdir(), `hydra-stdin-${sid}.log`);
11926
+ openOpts.filePathFor = (sid) => path13.join(os4.tmpdir(), `hydra-acp-stdin-${sid}.log`);
11407
11927
  }
11408
11928
  return session.openStream(openOpts);
11409
11929
  });
@@ -11821,26 +12341,25 @@ function bindClientToSession(connection, session, state, clientInfo, callerClien
11821
12341
  };
11822
12342
  }
11823
12343
 
11824
- // src/daemon/mcp/stdin-registry.ts
11825
- var StdinMcpRegistry = class {
12344
+ // src/daemon/mcp/token-registry.ts
12345
+ var McpTokenRegistry = class {
11826
12346
  byToken = /* @__PURE__ */ new Map();
11827
- // Reserve a token slot before the session exists. Used by acp-ws when
11828
- // we need to inject the bearer into the agent's mcpServers BEFORE
11829
- // manager.create() returns — claude-acp connects to /mcp/stdin during
11830
- // session/new initialization (eagerly), so the route handler must be
11831
- // able to find the token by the time the agent's first request lands.
11832
12347
  reserve(token) {
11833
12348
  if (this.byToken.has(token)) {
11834
- throw new Error(`stdin MCP token already bound`);
12349
+ throw new Error("mcp token already bound");
11835
12350
  }
11836
12351
  let resolveSession;
11837
12352
  let rejectSession;
11838
- const sessionReady = new Promise((resolve3, reject) => {
11839
- resolveSession = resolve3;
12353
+ const sessionReady = new Promise((resolve4, reject) => {
12354
+ resolveSession = resolve4;
11840
12355
  rejectSession = reject;
11841
12356
  });
11842
12357
  sessionReady.catch(() => void 0);
11843
- const entry = { session: void 0, sessionReady };
12358
+ const entry = {
12359
+ session: void 0,
12360
+ sessionReady,
12361
+ disposers: []
12362
+ };
11844
12363
  this.byToken.set(token, entry);
11845
12364
  return {
11846
12365
  complete: (session) => {
@@ -11849,7 +12368,7 @@ var StdinMcpRegistry = class {
11849
12368
  },
11850
12369
  abandon: (reason) => {
11851
12370
  this.byToken.delete(token);
11852
- rejectSession(reason ?? new Error("stdin MCP reservation abandoned"));
12371
+ rejectSession(reason ?? new Error("mcp token reservation abandoned"));
11853
12372
  }
11854
12373
  };
11855
12374
  }
@@ -11862,29 +12381,26 @@ var StdinMcpRegistry = class {
11862
12381
  lookup(token) {
11863
12382
  return this.byToken.get(token);
11864
12383
  }
11865
- attachTransport(token, server, transport) {
11866
- const ep = this.byToken.get(token);
11867
- if (!ep) {
12384
+ // Register a cleanup callback for this token. No-op if the token is
12385
+ // not currently bound — late additions after unbind() would never fire
12386
+ // anyway, so dropping them silently is safer than throwing into an
12387
+ // unrelated cleanup path.
12388
+ addDisposer(token, dispose) {
12389
+ const entry = this.byToken.get(token);
12390
+ if (entry === void 0) {
11868
12391
  return;
11869
12392
  }
11870
- ep.server = server;
11871
- ep.transport = transport;
12393
+ entry.disposers.push(dispose);
11872
12394
  }
11873
12395
  async unbind(token) {
11874
- const ep = this.byToken.get(token);
11875
- if (!ep) {
12396
+ const entry = this.byToken.get(token);
12397
+ if (entry === void 0) {
11876
12398
  return;
11877
12399
  }
11878
12400
  this.byToken.delete(token);
11879
- if (ep.transport) {
12401
+ for (const dispose of entry.disposers) {
11880
12402
  try {
11881
- await ep.transport.close();
11882
- } catch {
11883
- }
11884
- }
11885
- if (ep.server) {
11886
- try {
11887
- await ep.server.close();
12403
+ await dispose();
11888
12404
  } catch {
11889
12405
  }
11890
12406
  }
@@ -11899,6 +12415,8 @@ import { randomUUID } from "crypto";
11899
12415
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11900
12416
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
11901
12417
  import { z as z7 } from "zod";
12418
+
12419
+ // src/daemon/mcp/bearer.ts
11902
12420
  var BEARER_PREFIX2 = "Bearer ";
11903
12421
  function extractBearer(req) {
11904
12422
  const header = req.headers.authorization;
@@ -11911,15 +12429,17 @@ function extractBearer(req) {
11911
12429
  const token = header.slice(BEARER_PREFIX2.length).trim();
11912
12430
  return token.length > 0 ? token : void 0;
11913
12431
  }
12432
+
12433
+ // src/daemon/mcp/stdin-server.ts
11914
12434
  function buildMcpServer(session) {
11915
12435
  const server = new McpServer(
11916
- { name: "hydra-stdin", version: "1.0.0" },
12436
+ { name: "hydra-acp-stdin", version: "1.0.0" },
11917
12437
  {
11918
- instructions: "Piped input from `hydra cat --stream` is exposed here as a byte stream. Use `tail_stdin` for the latest N bytes (good for finding the end of a log), `head_stdin` for the first N bytes (good for headers/preamble), `read_stdin` for windowed reads against an absolute byte cursor, `wait_for_more` to block until new bytes arrive past a cursor, and `stdin_info` for the current cursors/capacity/closed status. Byte payloads come back base64-encoded."
12438
+ instructions: "Piped input from `hydra cat --stream` is exposed here as a byte stream. Use `tail` for the latest N bytes (good for finding the end of a log), `head` for the first N bytes (good for headers/preamble), `read` for windowed reads against an absolute byte cursor, `wait_for_more` to block until new bytes arrive past a cursor, and `info` for the current cursors/capacity/closed status. Byte payloads come back base64-encoded."
11919
12439
  }
11920
12440
  );
11921
12441
  server.registerTool(
11922
- "tail_stdin",
12442
+ "tail",
11923
12443
  {
11924
12444
  description: "Return the most recent `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means older bytes existed but have been evicted from the ring.",
11925
12445
  inputSchema: {
@@ -11940,7 +12460,7 @@ function buildMcpServer(session) {
11940
12460
  }
11941
12461
  );
11942
12462
  server.registerTool(
11943
- "head_stdin",
12463
+ "head",
11944
12464
  {
11945
12465
  description: "Return the first `bytes` bytes of piped stdin (capped server-side, default 64 KiB max). `truncated:true` means the head has already been evicted from the ring and the returned bytes start at the oldest still-resident cursor.",
11946
12466
  inputSchema: {
@@ -11961,7 +12481,7 @@ function buildMcpServer(session) {
11961
12481
  }
11962
12482
  );
11963
12483
  server.registerTool(
11964
- "read_stdin",
12484
+ "read",
11965
12485
  {
11966
12486
  description: "Read up to `max_bytes` bytes starting at absolute byte `cursor`. Returns `{bytes, nextCursor, gap?, eof?}` \u2014 `gap` is the number of bytes silently skipped because the ring had evicted them; `eof:true` means the producer closed and there is nothing left to read.",
11967
12487
  inputSchema: {
@@ -12014,9 +12534,9 @@ function buildMcpServer(session) {
12014
12534
  }
12015
12535
  );
12016
12536
  server.registerTool(
12017
- "grep_stdin",
12537
+ "grep",
12018
12538
  {
12019
- description: "Scan piped stdin line-by-line and return lines matching `pattern`. Prefer this over `read_stdin` when the question is 'find lines that mention X' \u2014 it filters server-side so you don't pull and decode 64 KiB base64 windows. Returns `{matches: [{cursor, line, before?, after?}], truncated, nextCursor, gap?, scannedBytes, eof?}`. Lines come back as decoded UTF-8 strings (not base64). When `truncated:true`, re-call with `cursor: nextCursor` to resume.",
12539
+ description: "Scan piped stdin line-by-line and return lines matching `pattern`. Prefer this over `read` when the question is 'find lines that mention X' \u2014 it filters server-side so you don't pull and decode 64 KiB base64 windows. Returns `{matches: [{cursor, line, before?, after?}], truncated, nextCursor, gap?, scannedBytes, eof?}`. Lines come back as decoded UTF-8 strings (not base64). When `truncated:true`, re-call with `cursor: nextCursor` to resume.",
12020
12540
  inputSchema: {
12021
12541
  pattern: z7.string().min(1).describe(
12022
12542
  "Search pattern. Treated as a JavaScript regular expression by default (set `regex:false` for a literal substring match)."
@@ -12068,7 +12588,7 @@ function buildMcpServer(session) {
12068
12588
  }
12069
12589
  );
12070
12590
  server.registerTool(
12071
- "stdin_info",
12591
+ "info",
12072
12592
  {
12073
12593
  description: "Report cursor / capacity / closed state of the stdin ring. Cheap; safe to call repeatedly.",
12074
12594
  inputSchema: {}
@@ -12088,66 +12608,337 @@ function buildMcpServer(session) {
12088
12608
  );
12089
12609
  return server;
12090
12610
  }
12091
- async function ensureTransport(token, session, registry) {
12092
- const existing = registry.lookup(token);
12093
- if (existing?.transport !== void 0) {
12094
- return existing.transport;
12611
+ var SESSION_READY_TIMEOUT_MS = 1e4;
12612
+ function registerStdinMcpRoutes(app, tokenRegistry) {
12613
+ const builtPerToken = /* @__PURE__ */ new Map();
12614
+ async function ensureTransport(token, session) {
12615
+ const existing = builtPerToken.get(token);
12616
+ if (existing !== void 0) {
12617
+ return existing.transport;
12618
+ }
12619
+ const server = buildMcpServer(session);
12620
+ const transport = new StreamableHTTPServerTransport({
12621
+ sessionIdGenerator: () => randomUUID()
12622
+ });
12623
+ await server.connect(transport);
12624
+ const pair = { server, transport };
12625
+ builtPerToken.set(token, pair);
12626
+ tokenRegistry.addDisposer(token, async () => {
12627
+ builtPerToken.delete(token);
12628
+ try {
12629
+ await transport.close();
12630
+ } catch {
12631
+ }
12632
+ try {
12633
+ await server.close();
12634
+ } catch {
12635
+ }
12636
+ });
12637
+ return transport;
12638
+ }
12639
+ async function handle(req, reply) {
12640
+ const token = extractBearer(req);
12641
+ if (token === void 0) {
12642
+ reply.code(401).send({ error: "missing bearer token" });
12643
+ return;
12644
+ }
12645
+ const entry = tokenRegistry.lookup(token);
12646
+ if (entry === void 0) {
12647
+ reply.code(404).send({ error: "unknown stdin token" });
12648
+ return;
12649
+ }
12650
+ let session;
12651
+ if (entry.session !== void 0) {
12652
+ session = entry.session;
12653
+ } else {
12654
+ let timer;
12655
+ const timeout = new Promise((resolve4) => {
12656
+ timer = setTimeout(() => resolve4(void 0), SESSION_READY_TIMEOUT_MS);
12657
+ });
12658
+ const resolved = await Promise.race([
12659
+ entry.sessionReady.catch(() => void 0),
12660
+ timeout
12661
+ ]);
12662
+ if (timer !== void 0) {
12663
+ clearTimeout(timer);
12664
+ }
12665
+ if (resolved === void 0) {
12666
+ reply.code(503).send({ error: "session not ready" });
12667
+ return;
12668
+ }
12669
+ session = resolved;
12670
+ }
12671
+ const transport = await ensureTransport(token, session);
12672
+ reply.hijack();
12673
+ await transport.handleRequest(req.raw, reply.raw, req.body);
12095
12674
  }
12096
- const server = buildMcpServer(session);
12097
- const transport = new StreamableHTTPServerTransport({
12098
- sessionIdGenerator: () => randomUUID()
12675
+ const opts = { config: { skipAuth: true } };
12676
+ app.post("/mcp/hydra-acp-stdin", opts, async (req, reply) => {
12677
+ await handle(req, reply);
12678
+ });
12679
+ app.get("/mcp/hydra-acp-stdin", opts, async (req, reply) => {
12680
+ await handle(req, reply);
12681
+ });
12682
+ app.delete("/mcp/hydra-acp-stdin", opts, async (req, reply) => {
12683
+ await handle(req, reply);
12099
12684
  });
12100
- await server.connect(transport);
12101
- registry.attachTransport(token, server, transport);
12102
- return transport;
12103
12685
  }
12104
- var SESSION_READY_TIMEOUT_MS = 1e4;
12105
- async function handle(req, reply, registry) {
12106
- const token = extractBearer(req);
12107
- if (token === void 0) {
12108
- reply.code(401).send({ error: "missing bearer token" });
12109
- return;
12686
+
12687
+ // src/core/extension-mcp.ts
12688
+ var ExtensionMcpRegistry = class {
12689
+ byName = /* @__PURE__ */ new Map();
12690
+ changeHandlers = [];
12691
+ // Set-the-whole-spec semantics, same as ExtensionCommandRegistry. A
12692
+ // second register for the same extName overwrites tools + instructions
12693
+ // wholesale; the change notification lets the route evict any cached
12694
+ // transports built against the old spec.
12695
+ register(extName, connection, instructions, tools) {
12696
+ this.byName.set(extName, {
12697
+ connection,
12698
+ instructions,
12699
+ tools: [...tools]
12700
+ });
12701
+ this.fireChanged(extName, "register");
12110
12702
  }
12111
- const ep = registry.lookup(token);
12112
- if (ep === void 0) {
12113
- reply.code(404).send({ error: "unknown stdin token" });
12114
- return;
12703
+ clear(extName) {
12704
+ if (this.byName.delete(extName)) {
12705
+ this.fireChanged(extName, "clear");
12706
+ }
12115
12707
  }
12116
- let session;
12117
- if (ep.session !== void 0) {
12118
- session = ep.session;
12119
- } else {
12120
- let timer;
12121
- const timeout = new Promise((resolve3) => {
12122
- timer = setTimeout(() => resolve3(void 0), SESSION_READY_TIMEOUT_MS);
12123
- });
12124
- const resolved = await Promise.race([
12125
- ep.sessionReady.catch(() => void 0),
12708
+ lookup(extName) {
12709
+ return this.byName.get(extName);
12710
+ }
12711
+ // List of currently-registered extension names. Used by session-create
12712
+ // to decide whether to mint an extension-MCP token and which mcpServers
12713
+ // entries to emit.
12714
+ list() {
12715
+ return Array.from(this.byName.keys());
12716
+ }
12717
+ onChange(handler) {
12718
+ this.changeHandlers.push(handler);
12719
+ return () => {
12720
+ const i = this.changeHandlers.indexOf(handler);
12721
+ if (i >= 0) {
12722
+ this.changeHandlers.splice(i, 1);
12723
+ }
12724
+ };
12725
+ }
12726
+ fireChanged(extName, kind) {
12727
+ for (const h of this.changeHandlers) {
12728
+ try {
12729
+ h(extName, kind);
12730
+ } catch {
12731
+ }
12732
+ }
12733
+ }
12734
+ };
12735
+
12736
+ // src/daemon/mcp/extension-route.ts
12737
+ import { StreamableHTTPServerTransport as StreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
12738
+ import { randomUUID as randomUUID2 } from "crypto";
12739
+
12740
+ // src/daemon/mcp/build-extension-server.ts
12741
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
12742
+ import {
12743
+ CallToolRequestSchema,
12744
+ ListToolsRequestSchema
12745
+ } from "@modelcontextprotocol/sdk/types.js";
12746
+ var DEFAULT_INVOKE_TIMEOUT_MS = 6e4;
12747
+ function buildExtensionServer(extensionName, entry, options = {}) {
12748
+ const invokeTimeoutMs = options.invokeTimeoutMs ?? DEFAULT_INVOKE_TIMEOUT_MS;
12749
+ const server = new Server(
12750
+ { name: extensionName, version: "1.0.0" },
12751
+ {
12752
+ capabilities: {
12753
+ // listChanged: false matches the v1 strategy — the daemon closes
12754
+ // transports on re-register; agents reconnect and re-list against
12755
+ // the new spec naturally. Flipping to true is the upgrade path
12756
+ // if any supported agent caches tools/list across reconnects.
12757
+ tools: { listChanged: false }
12758
+ },
12759
+ ...entry.instructions !== void 0 ? { instructions: entry.instructions } : {}
12760
+ }
12761
+ );
12762
+ const toolsByName = new Map(
12763
+ entry.tools.map((t) => [t.name, t])
12764
+ );
12765
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
12766
+ tools: entry.tools.map((t) => ({
12767
+ name: t.name,
12768
+ description: t.description,
12769
+ inputSchema: t.inputSchema,
12770
+ ...t.outputSchema !== void 0 ? { outputSchema: t.outputSchema } : {}
12771
+ }))
12772
+ }));
12773
+ server.setRequestHandler(
12774
+ CallToolRequestSchema,
12775
+ async (req) => {
12776
+ const toolName = req.params.name;
12777
+ if (!toolsByName.has(toolName)) {
12778
+ return errorResult(`unknown tool: ${toolName}`);
12779
+ }
12780
+ try {
12781
+ const raw = await invokeWithTimeout(
12782
+ entry.connection,
12783
+ extensionName,
12784
+ toolName,
12785
+ req.params.arguments ?? {},
12786
+ invokeTimeoutMs
12787
+ );
12788
+ return normalizeToolResult(raw, toolName);
12789
+ } catch (err) {
12790
+ return errorResult(
12791
+ err instanceof Error ? err.message : String(err)
12792
+ );
12793
+ }
12794
+ }
12795
+ );
12796
+ return server;
12797
+ }
12798
+ async function invokeWithTimeout(connection, server, tool, args, timeoutMs) {
12799
+ let timer;
12800
+ const timeout = new Promise((_, reject) => {
12801
+ timer = setTimeout(
12802
+ () => reject(new Error(`extension timeout after ${timeoutMs}ms`)),
12803
+ timeoutMs
12804
+ );
12805
+ });
12806
+ try {
12807
+ return await Promise.race([
12808
+ connection.request("hydra-acp/invoke_mcp_tool", {
12809
+ server,
12810
+ tool,
12811
+ args
12812
+ }),
12126
12813
  timeout
12127
12814
  ]);
12815
+ } finally {
12128
12816
  if (timer !== void 0) {
12129
12817
  clearTimeout(timer);
12130
12818
  }
12131
- if (resolved === void 0) {
12132
- reply.code(503).send({ error: "session not ready" });
12819
+ }
12820
+ }
12821
+ function normalizeToolResult(raw, toolName) {
12822
+ if (raw === null || typeof raw !== "object") {
12823
+ return errorResult(`extension ${toolName} returned non-object`);
12824
+ }
12825
+ const obj = raw;
12826
+ if (!Array.isArray(obj.content)) {
12827
+ return errorResult(`extension ${toolName} omitted content array`);
12828
+ }
12829
+ return obj;
12830
+ }
12831
+ function errorResult(message) {
12832
+ return {
12833
+ content: [{ type: "text", text: message }],
12834
+ isError: true
12835
+ };
12836
+ }
12837
+
12838
+ // src/daemon/mcp/extension-route.ts
12839
+ var SESSION_READY_TIMEOUT_MS2 = 1e4;
12840
+ function registerExtensionMcpRoutes(app, tokenRegistry, extensionMcp, options = {}) {
12841
+ const built = /* @__PURE__ */ new Map();
12842
+ async function disposeBuiltPair(pair) {
12843
+ try {
12844
+ await pair.transport.close();
12845
+ } catch {
12846
+ }
12847
+ try {
12848
+ await pair.server.close();
12849
+ } catch {
12850
+ }
12851
+ }
12852
+ function evictExtension(extName) {
12853
+ for (const tokenScope of built.values()) {
12854
+ const pair = tokenScope.get(extName);
12855
+ if (pair !== void 0) {
12856
+ tokenScope.delete(extName);
12857
+ void disposeBuiltPair(pair);
12858
+ }
12859
+ }
12860
+ }
12861
+ extensionMcp.onChange((extName) => {
12862
+ evictExtension(extName);
12863
+ });
12864
+ async function ensureTransport(token, extName) {
12865
+ let tokenScope = built.get(token);
12866
+ if (tokenScope === void 0) {
12867
+ tokenScope = /* @__PURE__ */ new Map();
12868
+ built.set(token, tokenScope);
12869
+ tokenRegistry.addDisposer(token, async () => {
12870
+ const scope = built.get(token);
12871
+ if (scope === void 0) {
12872
+ return;
12873
+ }
12874
+ built.delete(token);
12875
+ for (const pair of scope.values()) {
12876
+ await disposeBuiltPair(pair);
12877
+ }
12878
+ });
12879
+ }
12880
+ const existing = tokenScope.get(extName);
12881
+ if (existing !== void 0) {
12882
+ return existing.transport;
12883
+ }
12884
+ const entry = extensionMcp.lookup(extName);
12885
+ if (entry === void 0) {
12886
+ return void 0;
12887
+ }
12888
+ const server = buildExtensionServer(extName, entry, options.buildOptions);
12889
+ const transport = new StreamableHTTPServerTransport2({
12890
+ sessionIdGenerator: () => randomUUID2()
12891
+ });
12892
+ await server.connect(transport);
12893
+ tokenScope.set(extName, { server, transport });
12894
+ return transport;
12895
+ }
12896
+ async function handle(req, reply) {
12897
+ const token = extractBearer(req);
12898
+ if (token === void 0) {
12899
+ reply.code(401).send({ error: "missing bearer token" });
12900
+ return;
12901
+ }
12902
+ const entry = tokenRegistry.lookup(token);
12903
+ if (entry === void 0) {
12904
+ reply.code(404).send({ error: "unknown mcp token" });
12905
+ return;
12906
+ }
12907
+ if (entry.session === void 0) {
12908
+ let timer;
12909
+ const timeout = new Promise((resolve4) => {
12910
+ timer = setTimeout(() => resolve4(void 0), SESSION_READY_TIMEOUT_MS2);
12911
+ });
12912
+ const resolved = await Promise.race([
12913
+ entry.sessionReady.catch(() => void 0),
12914
+ timeout
12915
+ ]);
12916
+ if (timer !== void 0) {
12917
+ clearTimeout(timer);
12918
+ }
12919
+ if (resolved === void 0) {
12920
+ reply.code(503).send({ error: "session not ready" });
12921
+ return;
12922
+ }
12923
+ }
12924
+ const extName = req.params.name;
12925
+ const transport = await ensureTransport(token, extName);
12926
+ if (transport === void 0) {
12927
+ reply.code(404).send({ error: `unknown mcp server: ${extName}` });
12133
12928
  return;
12134
12929
  }
12135
- session = resolved;
12930
+ reply.hijack();
12931
+ await transport.handleRequest(req.raw, reply.raw, req.body);
12136
12932
  }
12137
- const transport = await ensureTransport(token, session, registry);
12138
- reply.hijack();
12139
- await transport.handleRequest(req.raw, reply.raw, req.body);
12140
- }
12141
- function registerStdinMcpRoutes(app, registry) {
12142
12933
  const opts = { config: { skipAuth: true } };
12143
- app.post("/mcp/stdin", opts, async (req, reply) => {
12144
- await handle(req, reply, registry);
12934
+ app.post("/mcp/:name", opts, async (req, reply) => {
12935
+ await handle(req, reply);
12145
12936
  });
12146
- app.get("/mcp/stdin", opts, async (req, reply) => {
12147
- await handle(req, reply, registry);
12937
+ app.get("/mcp/:name", opts, async (req, reply) => {
12938
+ await handle(req, reply);
12148
12939
  });
12149
- app.delete("/mcp/stdin", opts, async (req, reply) => {
12150
- await handle(req, reply, registry);
12940
+ app.delete("/mcp/:name", opts, async (req, reply) => {
12941
+ await handle(req, reply);
12151
12942
  });
12152
12943
  }
12153
12944
 
@@ -12256,8 +13047,10 @@ async function startDaemon(config, serviceToken) {
12256
13047
  store: sessionTokenStore,
12257
13048
  rateLimiter: authRateLimiter
12258
13049
  });
12259
- const stdinMcpRegistry = new StdinMcpRegistry();
12260
- registerStdinMcpRoutes(app, stdinMcpRegistry);
13050
+ const mcpTokenRegistry = new McpTokenRegistry();
13051
+ const extensionMcp = new ExtensionMcpRegistry();
13052
+ registerStdinMcpRoutes(app, mcpTokenRegistry);
13053
+ registerExtensionMcpRoutes(app, mcpTokenRegistry, extensionMcp);
12261
13054
  let daemonOriginCached;
12262
13055
  const getDaemonOrigin = () => {
12263
13056
  if (daemonOriginCached !== void 0) {
@@ -12278,7 +13071,8 @@ async function startDaemon(config, serviceToken) {
12278
13071
  onTransformerVersion: (name, version) => transformers.reportVersion(name, version),
12279
13072
  transformers,
12280
13073
  extensionCommands,
12281
- stdinMcpRegistry,
13074
+ mcpTokenRegistry,
13075
+ extensionMcp,
12282
13076
  getDaemonOrigin
12283
13077
  });
12284
13078
  await app.listen({ host: config.daemon.host, port: config.daemon.port });
@@ -12314,13 +13108,24 @@ async function startDaemon(config, serviceToken) {
12314
13108
  `queue replay scan failed: ${err.message}`
12315
13109
  );
12316
13110
  });
13111
+ const intervalMs = config.daemon.agentSyncIntervalMinutes * 60 * 1e3;
13112
+ const stopAgentSync = intervalMs > 0 ? startAgentSyncScheduler({
13113
+ registry,
13114
+ manager,
13115
+ intervalMs,
13116
+ logger: agentLogger
13117
+ }) : void 0;
12317
13118
  const shutdown = async () => {
13119
+ if (stopAgentSync) {
13120
+ stopAgentSync();
13121
+ }
12318
13122
  clearInterval(sweepInterval);
12319
13123
  await sessionTokenStore.flush();
12320
13124
  await extensions.stop();
12321
13125
  await transformers.stop();
12322
13126
  await manager.closeAll();
12323
13127
  await manager.flushMetaWrites();
13128
+ await manager.flushHistoryWrites();
12324
13129
  setBinaryInstallLogger(null);
12325
13130
  setNpmInstallLogger(null);
12326
13131
  setAgentPruneLogger(null);
@@ -12334,7 +13139,17 @@ async function startDaemon(config, serviceToken) {
12334
13139
  } catch {
12335
13140
  }
12336
13141
  };
12337
- return { app, manager, registry, extensions, transformers, shutdown };
13142
+ return {
13143
+ app,
13144
+ manager,
13145
+ registry,
13146
+ extensions,
13147
+ transformers,
13148
+ mcpTokenRegistry,
13149
+ extensionMcp,
13150
+ processRegistry,
13151
+ shutdown
13152
+ };
12338
13153
  }
12339
13154
  async function buildLogStream(level) {
12340
13155
  const fileStream = await createPinoRoll({