@hydra-acp/cli 0.1.52 → 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({
@@ -245,7 +309,22 @@ var TuiConfig = z.object({
245
309
  // shared across all sessions; it's append-only on disk, so long-lived
246
310
  // installs can grow past this — it's enforced at load time and per
247
311
  // append in memory.
248
- 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")
249
328
  });
250
329
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
251
330
  var ExtensionBody = z.object({
@@ -300,7 +379,8 @@ var HydraConfig = z.object({
300
379
  progressIndicator: true,
301
380
  defaultEnterAction: "amend",
302
381
  showThoughts: true,
303
- promptHistoryMaxEntries: 2e3
382
+ promptHistoryMaxEntries: 2e3,
383
+ showFileUpdates: "edit"
304
384
  })
305
385
  });
306
386
  function extensionList(config) {
@@ -316,17 +396,8 @@ function transformerList(config) {
316
396
  }));
317
397
  }
318
398
  async function readConfigFile() {
319
- let raw;
320
- try {
321
- raw = await fs2.readFile(paths.config(), "utf8");
322
- } catch (err) {
323
- const e = err;
324
- if (e.code === "ENOENT") {
325
- return {};
326
- }
327
- throw err;
328
- }
329
- return JSON.parse(raw);
399
+ const parsed = await readJsonSafe(paths.config());
400
+ return parsed ?? {};
330
401
  }
331
402
  async function migrateLegacyAuthToken() {
332
403
  const raw = await readConfigFile();
@@ -337,7 +408,7 @@ async function migrateLegacyAuthToken() {
337
408
  }
338
409
  let tokenFileExists = false;
339
410
  try {
340
- await fs2.access(paths.authToken());
411
+ await fs3.access(paths.authToken());
341
412
  tokenFileExists = true;
342
413
  } catch (err) {
343
414
  const e = err;
@@ -355,10 +426,7 @@ async function migrateLegacyAuthToken() {
355
426
  if (Object.keys(daemon).length === 0) {
356
427
  delete raw.daemon;
357
428
  }
358
- await fs2.writeFile(paths.config(), JSON.stringify(raw, null, 2) + "\n", {
359
- encoding: "utf8",
360
- mode: 384
361
- });
429
+ await writeJsonAtomic(paths.config(), raw, { mode: 384 });
362
430
  process.stderr.write(
363
431
  `hydra-acp: migrated auth token from ${paths.config()} to ${paths.authToken()}.
364
432
  `
@@ -369,11 +437,7 @@ async function loadConfig() {
369
437
  return HydraConfig.parse(await readConfigFile());
370
438
  }
371
439
  async function writeConfig(config) {
372
- await fs2.mkdir(paths.home(), { recursive: true });
373
- await fs2.writeFile(paths.config(), JSON.stringify(config, null, 2) + "\n", {
374
- encoding: "utf8",
375
- mode: 384
376
- });
440
+ await writeJsonAtomic(paths.config(), config, { mode: 384 });
377
441
  }
378
442
  function defaultConfig() {
379
443
  return HydraConfig.parse({});
@@ -392,12 +456,12 @@ function expandHome(p) {
392
456
  }
393
457
 
394
458
  // src/core/registry.ts
395
- import * as fs4 from "fs/promises";
459
+ import * as fs5 from "fs/promises";
396
460
  import * as path4 from "path";
397
461
  import { z as z2 } from "zod";
398
462
 
399
463
  // src/core/binary-install.ts
400
- import * as fs3 from "fs";
464
+ import * as fs4 from "fs";
401
465
  import * as fsp from "fs/promises";
402
466
  import * as path2 from "path";
403
467
  import { spawn } from "child_process";
@@ -530,7 +594,7 @@ async function downloadTo(args) {
530
594
  );
531
595
  }
532
596
  const total = Number(response.headers.get("content-length") ?? "0");
533
- const out = fs3.createWriteStream(dest);
597
+ const out = fs4.createWriteStream(dest);
534
598
  const nodeStream = Readable.fromWeb(response.body);
535
599
  safeEmit(args.onProgress, {
536
600
  phase: "download_start",
@@ -1030,54 +1094,26 @@ var Registry = class {
1030
1094
  return cached;
1031
1095
  }
1032
1096
  async readDiskCache() {
1033
- let text;
1034
- try {
1035
- text = await fs4.readFile(paths.registryCache(), "utf8");
1036
- } catch (err) {
1037
- const e = err;
1038
- if (e.code === "ENOENT") {
1039
- return void 0;
1040
- }
1041
- 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;
1042
1102
  }
1043
1103
  try {
1044
- const parsed = JSON.parse(text);
1045
- if (typeof parsed.fetchedAt !== "number" || parsed.data === void 0) {
1046
- return void 0;
1047
- }
1048
1104
  const data = RegistryDocument.parse(parsed.data);
1049
1105
  return { fetchedAt: parsed.fetchedAt, raw: parsed.data, data };
1050
1106
  } catch {
1051
1107
  return void 0;
1052
1108
  }
1053
1109
  }
1054
- // Atomic write: dump to a sibling temp path, then rename onto the
1055
- // target. POSIX rename is atomic within a filesystem, so readers
1056
- // either see the old file or the fully-written new file — never a
1057
- // truncated middle. This also makes simultaneous writers safe
1058
- // without a lock file: the loser of the rename race just gets its
1059
- // version replaced by the winner's.
1060
1110
  async writeDiskCache(cache) {
1061
- await fs4.mkdir(paths.home(), { recursive: true });
1062
- const final = paths.registryCache();
1063
- const tmp = `${final}.tmp-${process.pid}-${randSuffix()}`;
1064
- const body = JSON.stringify(
1065
- { fetchedAt: cache.fetchedAt, data: cache.raw },
1066
- null,
1067
- 2
1068
- ) + "\n";
1069
- try {
1070
- await fs4.writeFile(tmp, body, "utf8");
1071
- await fs4.rename(tmp, final);
1072
- } catch (err) {
1073
- await fs4.unlink(tmp).catch(() => void 0);
1074
- throw err;
1075
- }
1111
+ await writeJsonAtomic(paths.registryCache(), {
1112
+ fetchedAt: cache.fetchedAt,
1113
+ data: cache.raw
1114
+ });
1076
1115
  }
1077
1116
  };
1078
- function randSuffix() {
1079
- return Math.random().toString(36).slice(2, 10);
1080
- }
1081
1117
  function npxPackageBasename(agent) {
1082
1118
  const pkg = agent.distribution.npx?.package;
1083
1119
  if (!pkg) {
@@ -1122,7 +1158,7 @@ async function agentInstallState(agent) {
1122
1158
  }
1123
1159
  async function fileExists3(p) {
1124
1160
  try {
1125
- await fs4.access(p);
1161
+ await fs5.access(p);
1126
1162
  return true;
1127
1163
  } catch {
1128
1164
  return false;
@@ -1476,6 +1512,11 @@ var SessionListEntry = z3.object({
1476
1512
  importedFromUpstreamSessionId: z3.string().optional(),
1477
1513
  // Set when this session was spawned as a child by a transformer.
1478
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(),
1479
1520
  // clientInfo from the process that issued session/new. Lets list views
1480
1521
  // hide cat-style ancillary sessions by default while letting an
1481
1522
  // override flag surface them.
@@ -2073,7 +2114,7 @@ stderr: ${tail}` : reason;
2073
2114
  };
2074
2115
 
2075
2116
  // src/core/session-manager.ts
2076
- import * as fs10 from "fs/promises";
2117
+ import * as fs11 from "fs/promises";
2077
2118
  import * as os2 from "os";
2078
2119
  import { customAlphabet as customAlphabet3 } from "nanoid";
2079
2120
 
@@ -2553,22 +2594,22 @@ function hydraCommandsAsAdvertised() {
2553
2594
  }
2554
2595
 
2555
2596
  // src/core/queue-store.ts
2556
- import * as fs5 from "fs/promises";
2597
+ import * as fs6 from "fs/promises";
2557
2598
  async function rewriteQueue(sessionId, entries) {
2558
2599
  const file = paths.queueFile(sessionId);
2559
2600
  if (entries.length === 0) {
2560
- await fs5.unlink(file).catch(() => void 0);
2601
+ await fs6.unlink(file).catch(() => void 0);
2561
2602
  return;
2562
2603
  }
2563
- await fs5.mkdir(paths.sessionDir(sessionId), { recursive: true });
2604
+ await fs6.mkdir(paths.sessionDir(sessionId), { recursive: true });
2564
2605
  const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
2565
- await fs5.writeFile(file, body, "utf8");
2606
+ await fs6.writeFile(file, body, "utf8");
2566
2607
  }
2567
2608
  async function loadQueue(sessionId) {
2568
2609
  const file = paths.queueFile(sessionId);
2569
2610
  let text;
2570
2611
  try {
2571
- text = await fs5.readFile(file, "utf8");
2612
+ text = await fs6.readFile(file, "utf8");
2572
2613
  } catch (err) {
2573
2614
  if (err.code === "ENOENT") {
2574
2615
  return [];
@@ -2590,7 +2631,7 @@ async function loadQueue(sessionId) {
2590
2631
  }
2591
2632
  async function deleteQueue(sessionId) {
2592
2633
  const file = paths.queueFile(sessionId);
2593
- await fs5.unlink(file).catch(() => void 0);
2634
+ await fs6.unlink(file).catch(() => void 0);
2594
2635
  }
2595
2636
 
2596
2637
  // src/core/session.ts
@@ -2622,6 +2663,8 @@ var Session = class {
2622
2663
  agentCapabilities;
2623
2664
  agentArgs;
2624
2665
  parentSessionId;
2666
+ forkedFromSessionId;
2667
+ forkedFromMessageId;
2625
2668
  originatingClient;
2626
2669
  title;
2627
2670
  // Snapshot state delivered to attaching clients via the attach
@@ -2650,6 +2693,13 @@ var Session = class {
2650
2693
  // enqueue) and leave the file out of sync with in-memory state.
2651
2694
  queueWriteChain = Promise.resolve();
2652
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;
2653
2703
  closeHandlers = [];
2654
2704
  titleHandlers = [];
2655
2705
  // Subscribers notified after every entry that's actually persisted to
@@ -2777,6 +2827,8 @@ var Session = class {
2777
2827
  this.agentCapabilities = init.agentCapabilities;
2778
2828
  this.agentArgs = init.agentArgs;
2779
2829
  this.parentSessionId = init.parentSessionId;
2830
+ this.forkedFromSessionId = init.forkedFromSessionId;
2831
+ this.forkedFromMessageId = init.forkedFromMessageId;
2780
2832
  this.originatingClient = init.originatingClient;
2781
2833
  this.title = init.title;
2782
2834
  this.currentModel = init.currentModel;
@@ -3885,6 +3937,7 @@ var Session = class {
3885
3937
  if (this.closed) {
3886
3938
  return;
3887
3939
  }
3940
+ this.closing = true;
3888
3941
  this.logger?.info(
3889
3942
  `session ${this.sessionId} closing deleteRecord=${opts.deleteRecord ?? false} regenTitle=${opts.regenTitle ?? false}`
3890
3943
  );
@@ -5016,21 +5069,22 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5016
5069
  if (this.closed) {
5017
5070
  return;
5018
5071
  }
5072
+ this.closing = true;
5019
5073
  this.closed = true;
5020
5074
  this.cancelIdleTimer();
5021
5075
  if (this.extensionCommandsUnsub) {
5022
5076
  this.extensionCommandsUnsub();
5023
5077
  this.extensionCommandsUnsub = void 0;
5024
5078
  }
5025
- if (this.currentEntry?.kind === "user") {
5079
+ if (this.currentEntry?.kind === "user" && !this.recentlyTerminal.has(this.currentEntry.messageId)) {
5026
5080
  this.broadcastTurnComplete(
5027
5081
  this.currentEntry.clientId,
5028
5082
  { stopReason: "interrupted" },
5029
5083
  this.currentEntry.messageId,
5030
5084
  this.currentEntry.wasAmend
5031
5085
  );
5032
- this.currentEntry = void 0;
5033
5086
  }
5087
+ this.currentEntry = void 0;
5034
5088
  const stranded = this.promptQueue;
5035
5089
  this.promptQueue = [];
5036
5090
  for (const entry of stranded) {
@@ -5359,6 +5413,9 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
5359
5413
  await new Promise((r) => setImmediate(r));
5360
5414
  try {
5361
5415
  while (this.promptQueue.length > 0) {
5416
+ if (this.closing) {
5417
+ break;
5418
+ }
5362
5419
  const next = this.promptQueue.shift();
5363
5420
  if (!next) {
5364
5421
  break;
@@ -5730,7 +5787,7 @@ function firstLine(text, max) {
5730
5787
  }
5731
5788
 
5732
5789
  // src/core/session-store.ts
5733
- import * as fs6 from "fs/promises";
5790
+ import * as fs7 from "fs/promises";
5734
5791
  import * as path5 from "path";
5735
5792
  import { customAlphabet as customAlphabet2 } from "nanoid";
5736
5793
  import { z as z4 } from "zod";
@@ -5815,6 +5872,12 @@ var SessionRecord = z4.object({
5815
5872
  // Set when this session was spawned as a child by a transformer via
5816
5873
  // hydra-acp/spawn_child_session. Points to the spawning session's id.
5817
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(),
5818
5881
  // clientInfo from the process that issued session/new. Picker and
5819
5882
  // `sessions list` use this to hide cat-style ancillary sessions by
5820
5883
  // default; carried in meta.json so cold sessions filter the same way.
@@ -5831,30 +5894,21 @@ function assertSafeId(id) {
5831
5894
  var SessionStore = class {
5832
5895
  async write(record) {
5833
5896
  assertSafeId(record.sessionId);
5834
- await fs6.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
5835
5897
  const full = { version: 1, ...record };
5836
- await fs6.writeFile(
5837
- paths.sessionFile(record.sessionId),
5838
- JSON.stringify(full, null, 2) + "\n",
5839
- { encoding: "utf8", mode: 384 }
5840
- );
5898
+ await writeJsonAtomic(paths.sessionFile(record.sessionId), full, {
5899
+ mode: 384
5900
+ });
5841
5901
  }
5842
5902
  async read(sessionId) {
5843
5903
  if (!SESSION_ID_PATTERN.test(sessionId)) {
5844
5904
  return void 0;
5845
5905
  }
5846
- let raw;
5847
- try {
5848
- raw = await fs6.readFile(paths.sessionFile(sessionId), "utf8");
5849
- } catch (err) {
5850
- const e = err;
5851
- if (e.code === "ENOENT") {
5852
- return void 0;
5853
- }
5854
- throw err;
5906
+ const parsed = await readJsonSafe(paths.sessionFile(sessionId));
5907
+ if (parsed === void 0) {
5908
+ return void 0;
5855
5909
  }
5856
5910
  try {
5857
- return SessionRecord.parse(JSON.parse(raw));
5911
+ return SessionRecord.parse(parsed);
5858
5912
  } catch {
5859
5913
  return void 0;
5860
5914
  }
@@ -5864,7 +5918,7 @@ var SessionStore = class {
5864
5918
  return;
5865
5919
  }
5866
5920
  try {
5867
- await fs6.unlink(paths.sessionFile(sessionId));
5921
+ await fs7.unlink(paths.sessionFile(sessionId));
5868
5922
  } catch (err) {
5869
5923
  const e = err;
5870
5924
  if (e.code !== "ENOENT") {
@@ -5872,7 +5926,7 @@ var SessionStore = class {
5872
5926
  }
5873
5927
  }
5874
5928
  try {
5875
- await fs6.rmdir(paths.sessionDir(sessionId));
5929
+ await fs7.rmdir(paths.sessionDir(sessionId));
5876
5930
  } catch (err) {
5877
5931
  const e = err;
5878
5932
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -5902,7 +5956,7 @@ var SessionStore = class {
5902
5956
  async list() {
5903
5957
  let entries;
5904
5958
  try {
5905
- entries = await fs6.readdir(paths.sessionsDir());
5959
+ entries = await fs7.readdir(paths.sessionsDir());
5906
5960
  } catch (err) {
5907
5961
  const e = err;
5908
5962
  if (e.code === "ENOENT") {
@@ -5941,6 +5995,8 @@ function recordFromMemorySession(args) {
5941
5995
  agentModels: args.agentModels,
5942
5996
  pendingHistorySync: args.pendingHistorySync,
5943
5997
  parentSessionId: args.parentSessionId,
5998
+ forkedFromSessionId: args.forkedFromSessionId,
5999
+ forkedFromMessageId: args.forkedFromMessageId,
5944
6000
  originatingClient: args.originatingClient,
5945
6001
  createdAt: args.createdAt ?? now,
5946
6002
  updatedAt: args.updatedAt ?? now
@@ -5948,7 +6004,7 @@ function recordFromMemorySession(args) {
5948
6004
  }
5949
6005
 
5950
6006
  // src/core/history-store.ts
5951
- import * as fs7 from "fs/promises";
6007
+ import * as fs8 from "fs/promises";
5952
6008
  var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
5953
6009
  var DEFAULT_MAX_ENTRIES = 1e3;
5954
6010
  var HistoryStore = class {
@@ -5965,9 +6021,9 @@ var HistoryStore = class {
5965
6021
  return;
5966
6022
  }
5967
6023
  return this.enqueue(sessionId, async () => {
5968
- await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
6024
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
5969
6025
  const line = JSON.stringify(entry) + "\n";
5970
- await fs7.appendFile(paths.historyFile(sessionId), line, {
6026
+ await fs8.appendFile(paths.historyFile(sessionId), line, {
5971
6027
  encoding: "utf8",
5972
6028
  mode: 384
5973
6029
  });
@@ -5978,9 +6034,9 @@ var HistoryStore = class {
5978
6034
  return;
5979
6035
  }
5980
6036
  return this.enqueue(sessionId, async () => {
5981
- await fs7.mkdir(paths.sessionDir(sessionId), { recursive: true });
6037
+ await fs8.mkdir(paths.sessionDir(sessionId), { recursive: true });
5982
6038
  const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
5983
- await fs7.writeFile(paths.historyFile(sessionId), body, {
6039
+ await fs8.writeFile(paths.historyFile(sessionId), body, {
5984
6040
  encoding: "utf8",
5985
6041
  mode: 384
5986
6042
  });
@@ -5997,7 +6053,7 @@ var HistoryStore = class {
5997
6053
  return this.enqueue(sessionId, async () => {
5998
6054
  let raw;
5999
6055
  try {
6000
- raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
6056
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
6001
6057
  } catch (err) {
6002
6058
  const e = err;
6003
6059
  if (e.code === "ENOENT") {
@@ -6010,7 +6066,7 @@ var HistoryStore = class {
6010
6066
  return;
6011
6067
  }
6012
6068
  const trimmed = lines.slice(-maxEntries);
6013
- await fs7.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
6069
+ await fs8.writeFile(paths.historyFile(sessionId), trimmed.join("\n") + "\n", {
6014
6070
  encoding: "utf8",
6015
6071
  mode: 384
6016
6072
  });
@@ -6026,7 +6082,7 @@ var HistoryStore = class {
6026
6082
  }
6027
6083
  let raw;
6028
6084
  try {
6029
- raw = await fs7.readFile(paths.historyFile(sessionId), "utf8");
6085
+ raw = await fs8.readFile(paths.historyFile(sessionId), "utf8");
6030
6086
  } catch (err) {
6031
6087
  const e = err;
6032
6088
  if (e.code === "ENOENT") {
@@ -6066,13 +6122,26 @@ var HistoryStore = class {
6066
6122
  }
6067
6123
  return out;
6068
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
+ }
6069
6138
  async delete(sessionId) {
6070
6139
  if (!SESSION_ID_PATTERN2.test(sessionId)) {
6071
6140
  return;
6072
6141
  }
6073
6142
  return this.enqueue(sessionId, async () => {
6074
6143
  try {
6075
- await fs7.unlink(paths.historyFile(sessionId));
6144
+ await fs8.unlink(paths.historyFile(sessionId));
6076
6145
  } catch (err) {
6077
6146
  const e = err;
6078
6147
  if (e.code !== "ENOENT") {
@@ -6080,7 +6149,7 @@ var HistoryStore = class {
6080
6149
  }
6081
6150
  }
6082
6151
  try {
6083
- await fs7.rmdir(paths.sessionDir(sessionId));
6152
+ await fs8.rmdir(paths.sessionDir(sessionId));
6084
6153
  } catch (err) {
6085
6154
  const e = err;
6086
6155
  if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
@@ -6104,25 +6173,108 @@ var HistoryStore = class {
6104
6173
  };
6105
6174
 
6106
6175
  // src/tui/history.ts
6107
- import { promises as fs8 } from "fs";
6176
+ import { promises as fs9 } from "fs";
6108
6177
  import * as path6 from "path";
6109
6178
  async function saveHistory(file, history) {
6110
- await fs8.mkdir(path6.dirname(file), { recursive: true });
6179
+ await fs9.mkdir(path6.dirname(file), { recursive: true });
6111
6180
  const lines = history.map((entry) => JSON.stringify(entry));
6112
- 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);
6113
6265
  }
6114
6266
 
6115
6267
  // src/core/hydra-version.ts
6116
6268
  import { fileURLToPath } from "url";
6117
6269
  import * as path7 from "path";
6118
- import * as fs9 from "fs";
6270
+ import * as fs10 from "fs";
6119
6271
  function resolveVersion() {
6120
6272
  try {
6121
6273
  let dir = path7.dirname(fileURLToPath(import.meta.url));
6122
6274
  for (let i = 0; i < 8; i += 1) {
6123
6275
  const candidate = path7.join(dir, "package.json");
6124
- if (fs9.existsSync(candidate)) {
6125
- const pkg = JSON.parse(fs9.readFileSync(candidate, "utf8"));
6276
+ if (fs10.existsSync(candidate)) {
6277
+ const pkg = JSON.parse(fs10.readFileSync(candidate, "utf8"));
6126
6278
  if (typeof pkg.version === "string" && pkg.version.length > 0 && (typeof pkg.name !== "string" || pkg.name.includes("hydra-acp"))) {
6127
6279
  return pkg.version;
6128
6280
  }
@@ -6403,6 +6555,8 @@ var SessionManager = class {
6403
6555
  firstPromptSeeded: !!params.title,
6404
6556
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6405
6557
  originatingClient: params.originatingClient,
6558
+ forkedFromSessionId: params.forkedFromSessionId,
6559
+ forkedFromMessageId: params.forkedFromMessageId,
6406
6560
  extensionCommands: this.extensionCommands
6407
6561
  });
6408
6562
  await this.attachManagerHooks(session);
@@ -6471,6 +6625,8 @@ var SessionManager = class {
6471
6625
  firstPromptSeeded: !!params.title,
6472
6626
  createdAt: params.createdAt ? new Date(params.createdAt).getTime() : void 0,
6473
6627
  originatingClient: params.originatingClient,
6628
+ forkedFromSessionId: params.forkedFromSessionId,
6629
+ forkedFromMessageId: params.forkedFromMessageId,
6474
6630
  extensionCommands: this.extensionCommands
6475
6631
  });
6476
6632
  await this.attachManagerHooks(session);
@@ -6479,7 +6635,7 @@ var SessionManager = class {
6479
6635
  }
6480
6636
  async resolveImportCwd(cwd) {
6481
6637
  try {
6482
- const stat2 = await fs10.stat(cwd);
6638
+ const stat2 = await fs11.stat(cwd);
6483
6639
  if (stat2.isDirectory()) {
6484
6640
  return cwd;
6485
6641
  }
@@ -6821,7 +6977,9 @@ var SessionManager = class {
6821
6977
  agentModels: record.agentModels,
6822
6978
  createdAt: record.createdAt,
6823
6979
  pendingHistorySync: record.pendingHistorySync,
6824
- originatingClient: record.originatingClient
6980
+ originatingClient: record.originatingClient,
6981
+ forkedFromSessionId: record.forkedFromSessionId,
6982
+ forkedFromMessageId: record.forkedFromMessageId
6825
6983
  };
6826
6984
  }
6827
6985
  async clearPendingHistorySync(sessionId) {
@@ -6922,6 +7080,8 @@ var SessionManager = class {
6922
7080
  currentModel: session.currentModel,
6923
7081
  currentUsage: session.currentUsage,
6924
7082
  parentSessionId: session.parentSessionId,
7083
+ forkedFromSessionId: session.forkedFromSessionId,
7084
+ forkedFromMessageId: session.forkedFromMessageId,
6925
7085
  originatingClient: session.originatingClient,
6926
7086
  updatedAt: used,
6927
7087
  attachedClients: session.attachedCount,
@@ -6952,6 +7112,8 @@ var SessionManager = class {
6952
7112
  importedFromMachine: r.importedFromMachine,
6953
7113
  importedFromUpstreamSessionId: r.importedFromUpstreamSessionId,
6954
7114
  parentSessionId: r.parentSessionId,
7115
+ forkedFromSessionId: r.forkedFromSessionId,
7116
+ forkedFromMessageId: r.forkedFromMessageId,
6955
7117
  originatingClient: r.originatingClient,
6956
7118
  updatedAt: used,
6957
7119
  attachedClients: 0,
@@ -7039,10 +7201,114 @@ var SessionManager = class {
7039
7201
  replaced: false
7040
7202
  };
7041
7203
  }
7042
- // Write the imported bundle's history.jsonl, prompt-history (if
7043
- // 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
7044
7306
  // marker that the first attach should bootstrap a fresh agent and
7045
- // 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.
7046
7312
  async writeImportedRecord(args) {
7047
7313
  await this.histories.rewrite(
7048
7314
  args.sessionId,
@@ -7050,7 +7316,7 @@ var SessionManager = class {
7050
7316
  );
7051
7317
  const sourceMtime = new Date(args.bundle.session.updatedAt);
7052
7318
  if (!Number.isNaN(sourceMtime.getTime())) {
7053
- 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);
7054
7320
  }
7055
7321
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
7056
7322
  await saveHistory(
@@ -7059,14 +7325,20 @@ var SessionManager = class {
7059
7325
  ).catch(() => void 0);
7060
7326
  }
7061
7327
  const now = (/* @__PURE__ */ new Date()).toISOString();
7328
+ const isFork = args.forkedFromSessionId !== void 0;
7062
7329
  await this.enqueueMetaWrite(args.sessionId, async () => {
7063
7330
  await this.store.write({
7064
7331
  sessionId: args.sessionId,
7065
7332
  lineageId: args.bundle.session.lineageId,
7066
7333
  upstreamSessionId: "",
7067
- importedFromSessionId: args.bundle.session.sessionId,
7068
- importedFromUpstreamSessionId: args.bundle.session.upstreamSessionId,
7069
- 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
+ },
7070
7342
  agentId: args.bundle.session.agentId,
7071
7343
  cwd: args.cwd ?? args.bundle.session.cwd,
7072
7344
  title: args.bundle.session.title,
@@ -7200,6 +7472,14 @@ var SessionManager = class {
7200
7472
  }
7201
7473
  await Promise.allSettled(pending);
7202
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
+ }
7203
7483
  // Startup hook: scan persisted sessions for non-empty queue files,
7204
7484
  // apply the TTL, resurrect anything with surviving entries, and
7205
7485
  // replay them through the normal queue path. Called from the daemon
@@ -7298,6 +7578,8 @@ function mergeForPersistence(session, existing) {
7298
7578
  agentModes,
7299
7579
  agentModels,
7300
7580
  parentSessionId: session.parentSessionId ?? existing?.parentSessionId,
7581
+ forkedFromSessionId: session.forkedFromSessionId ?? existing?.forkedFromSessionId,
7582
+ forkedFromMessageId: session.forkedFromMessageId ?? existing?.forkedFromMessageId,
7301
7583
  originatingClient: session.originatingClient ?? existing?.originatingClient,
7302
7584
  createdAt: existing?.createdAt ?? new Date(session.createdAt).toISOString()
7303
7585
  });
@@ -7567,9 +7849,26 @@ function parseModesList(list) {
7567
7849
  }
7568
7850
  return out;
7569
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
+ }
7570
7869
  async function loadPromptHistorySafely(sessionId) {
7571
7870
  try {
7572
- const raw = await fs10.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7871
+ const raw = await fs11.readFile(paths.tuiHistoryFile(sessionId), "utf8");
7573
7872
  const out = [];
7574
7873
  for (const line of raw.split("\n")) {
7575
7874
  if (line.length === 0) {
@@ -7590,7 +7889,7 @@ async function loadPromptHistorySafely(sessionId) {
7590
7889
  }
7591
7890
  async function historyMtimeIso(sessionId) {
7592
7891
  try {
7593
- const st = await fs10.stat(paths.historyFile(sessionId));
7892
+ const st = await fs11.stat(paths.historyFile(sessionId));
7594
7893
  return new Date(st.mtimeMs).toISOString();
7595
7894
  } catch {
7596
7895
  return void 0;
@@ -7599,7 +7898,7 @@ async function historyMtimeIso(sessionId) {
7599
7898
 
7600
7899
  // src/core/extensions.ts
7601
7900
  import { spawn as spawn4 } from "child_process";
7602
- import * as fs11 from "fs";
7901
+ import * as fs12 from "fs";
7603
7902
  import * as fsp5 from "fs/promises";
7604
7903
  import * as path8 from "path";
7605
7904
  var RESTART_BASE_MS = 1e3;
@@ -7895,7 +8194,7 @@ var ExtensionManager = class {
7895
8194
  }
7896
8195
  const ext = entry.config;
7897
8196
  const command = ext.command.length > 0 ? ext.command : [ext.name];
7898
- const logStream = fs11.createWriteStream(paths.extensionLogFile(ext.name), {
8197
+ const logStream = fs12.createWriteStream(paths.extensionLogFile(ext.name), {
7899
8198
  flags: "a"
7900
8199
  });
7901
8200
  logStream.write(
@@ -7948,7 +8247,7 @@ var ExtensionManager = class {
7948
8247
  }
7949
8248
  if (typeof child.pid === "number") {
7950
8249
  try {
7951
- fs11.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
8250
+ fs12.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
7952
8251
  `, {
7953
8252
  encoding: "utf8",
7954
8253
  mode: 384
@@ -7973,7 +8272,7 @@ var ExtensionManager = class {
7973
8272
  });
7974
8273
  child.on("exit", (code, signal) => {
7975
8274
  try {
7976
- fs11.unlinkSync(paths.extensionPidFile(ext.name));
8275
+ fs12.unlinkSync(paths.extensionPidFile(ext.name));
7977
8276
  } catch {
7978
8277
  }
7979
8278
  logStream.write(
@@ -8035,7 +8334,7 @@ function withCode2(err, code) {
8035
8334
 
8036
8335
  // src/core/transformer-manager.ts
8037
8336
  import { spawn as spawn5 } from "child_process";
8038
- import * as fs12 from "fs";
8337
+ import * as fs13 from "fs";
8039
8338
  import * as fsp6 from "fs/promises";
8040
8339
  import * as path9 from "path";
8041
8340
  var RESTART_BASE_MS2 = 1e3;
@@ -8355,7 +8654,7 @@ var TransformerManager = class {
8355
8654
  }
8356
8655
  const t = entry.config;
8357
8656
  const command = t.command.length > 0 ? t.command : [t.name];
8358
- const logStream = fs12.createWriteStream(paths.transformerLogFile(t.name), {
8657
+ const logStream = fs13.createWriteStream(paths.transformerLogFile(t.name), {
8359
8658
  flags: "a"
8360
8659
  });
8361
8660
  logStream.write(
@@ -8408,7 +8707,7 @@ var TransformerManager = class {
8408
8707
  }
8409
8708
  if (typeof child.pid === "number") {
8410
8709
  try {
8411
- fs12.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
8710
+ fs13.writeFileSync(paths.transformerPidFile(t.name), `${child.pid}
8412
8711
  `, {
8413
8712
  encoding: "utf8",
8414
8713
  mode: 384
@@ -8433,7 +8732,7 @@ var TransformerManager = class {
8433
8732
  });
8434
8733
  child.on("exit", (code, signal) => {
8435
8734
  try {
8436
- fs12.unlinkSync(paths.transformerPidFile(t.name));
8735
+ fs13.unlinkSync(paths.transformerPidFile(t.name));
8437
8736
  } catch {
8438
8737
  }
8439
8738
  logStream.write(
@@ -8688,9 +8987,8 @@ function startAgentSyncScheduler(opts) {
8688
8987
  }
8689
8988
 
8690
8989
  // src/core/session-tokens.ts
8691
- import * as fs13 from "fs/promises";
8692
8990
  import * as path11 from "path";
8693
- import { createHash, randomBytes, timingSafeEqual } from "crypto";
8991
+ import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
8694
8992
  var TOKEN_PREFIX = "hydra_session_";
8695
8993
  var DEFAULT_TTL_SEC = 60 * 60 * 24 * 30;
8696
8994
  var ID_LENGTH = 12;
@@ -8703,7 +9001,7 @@ function sha256Hex(input) {
8703
9001
  return createHash("sha256").update(input).digest("hex");
8704
9002
  }
8705
9003
  function randomHex(bytes) {
8706
- return randomBytes(bytes).toString("hex");
9004
+ return randomBytes2(bytes).toString("hex");
8707
9005
  }
8708
9006
  function generateId() {
8709
9007
  return randomHex(ID_LENGTH).slice(0, ID_LENGTH * 2);
@@ -8723,17 +9021,11 @@ var SessionTokenStore = class _SessionTokenStore {
8723
9021
  }
8724
9022
  static async load() {
8725
9023
  let records = [];
8726
- try {
8727
- const raw = await fs13.readFile(tokensFilePath(), "utf8");
8728
- const parsed = JSON.parse(raw);
8729
- if (parsed && Array.isArray(parsed.records)) {
8730
- records = parsed.records.filter(isRecord);
8731
- }
8732
- } catch (err) {
8733
- const e = err;
8734
- if (e.code !== "ENOENT") {
8735
- throw err;
8736
- }
9024
+ const parsed = await readJsonSafe(
9025
+ tokensFilePath()
9026
+ );
9027
+ if (parsed && Array.isArray(parsed.records)) {
9028
+ records = parsed.records.filter(isRecord);
8737
9029
  }
8738
9030
  const store = new _SessionTokenStore(records);
8739
9031
  const removed = store.sweepExpired(/* @__PURE__ */ new Date());
@@ -8850,14 +9142,11 @@ var SessionTokenStore = class _SessionTokenStore {
8850
9142
  await this.writeInflight;
8851
9143
  }
8852
9144
  const records = Array.from(this.records.values());
8853
- const payload = JSON.stringify({ records }, null, 2) + "\n";
8854
- this.writeInflight = (async () => {
8855
- await fs13.mkdir(paths.home(), { recursive: true });
8856
- await fs13.writeFile(tokensFilePath(), payload, {
8857
- encoding: "utf8",
8858
- mode: 384
8859
- });
8860
- })();
9145
+ this.writeInflight = writeJsonAtomic(
9146
+ tokensFilePath(),
9147
+ { records },
9148
+ { mode: 384 }
9149
+ );
8861
9150
  try {
8862
9151
  await this.writeInflight;
8863
9152
  } finally {
@@ -9025,89 +9314,6 @@ var AuthRateLimiter = class {
9025
9314
  // src/daemon/routes/sessions.ts
9026
9315
  import * as os3 from "os";
9027
9316
 
9028
- // src/core/bundle.ts
9029
- import { z as z5 } from "zod";
9030
- var HistoryEntrySchema = z5.object({
9031
- method: z5.string(),
9032
- params: z5.unknown(),
9033
- recordedAt: z5.number()
9034
- });
9035
- var BundleSession = z5.object({
9036
- // The exporter's local id. Regenerated fresh on import (sessionId is
9037
- // the local namespace; lineageId is what survives across hops).
9038
- sessionId: z5.string(),
9039
- // Required on bundles — the export path backfills if the source
9040
- // record was written before lineageId existed.
9041
- lineageId: z5.string(),
9042
- // The exporter's agent-side session id at export time. Carried so
9043
- // importers can persist it as a breadcrumb (and, eventually, as the
9044
- // handle a "connect back to origin" feature would need). Omitted on
9045
- // bundles whose source record never bound to an agent (e.g. a
9046
- // re-export of an imported, not-yet-attached session).
9047
- upstreamSessionId: z5.string().optional(),
9048
- agentId: z5.string(),
9049
- cwd: z5.string(),
9050
- title: z5.string().optional(),
9051
- currentModel: z5.string().optional(),
9052
- currentMode: z5.string().optional(),
9053
- currentUsage: PersistedUsage.optional(),
9054
- agentCommands: z5.array(PersistedAgentCommand).optional(),
9055
- agentModes: z5.array(PersistedAgentMode).optional(),
9056
- createdAt: z5.string(),
9057
- updatedAt: z5.string()
9058
- });
9059
- var Bundle = z5.object({
9060
- version: z5.literal(1),
9061
- exportedAt: z5.string(),
9062
- exportedFrom: z5.object({
9063
- hydraVersion: z5.string(),
9064
- machine: z5.string(),
9065
- // Externally-reachable name (and optional ":port") for the exporting
9066
- // daemon, sourced from config.daemon.publicHost (or daemon.host when
9067
- // non-loopback). Carried so an importer can construct a hydra:// URL
9068
- // that dials back to the origin — e.g. over Tailscale. Omitted when
9069
- // the exporter has no routable address; never falls back to loopback.
9070
- hydraHost: z5.string().optional()
9071
- }),
9072
- session: BundleSession,
9073
- history: z5.array(HistoryEntrySchema),
9074
- promptHistory: z5.array(z5.string()).optional()
9075
- });
9076
- function encodeBundle(params) {
9077
- const bundle = {
9078
- version: 1,
9079
- exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
9080
- exportedFrom: {
9081
- hydraVersion: params.hydraVersion,
9082
- machine: params.machine,
9083
- ...params.hydraHost !== void 0 && params.hydraHost.length > 0 ? { hydraHost: params.hydraHost } : {}
9084
- },
9085
- session: {
9086
- sessionId: params.record.sessionId,
9087
- lineageId: params.record.lineageId,
9088
- ...params.record.upstreamSessionId ? { upstreamSessionId: params.record.upstreamSessionId } : {},
9089
- agentId: params.record.agentId,
9090
- cwd: params.record.cwd,
9091
- ...params.record.title !== void 0 ? { title: params.record.title } : {},
9092
- ...params.record.currentModel !== void 0 ? { currentModel: params.record.currentModel } : {},
9093
- ...params.record.currentMode !== void 0 ? { currentMode: params.record.currentMode } : {},
9094
- ...params.record.currentUsage !== void 0 ? { currentUsage: params.record.currentUsage } : {},
9095
- ...params.record.agentCommands !== void 0 ? { agentCommands: params.record.agentCommands } : {},
9096
- ...params.record.agentModes !== void 0 ? { agentModes: params.record.agentModes } : {},
9097
- createdAt: params.record.createdAt,
9098
- updatedAt: params.record.updatedAt
9099
- },
9100
- history: params.history
9101
- };
9102
- if (params.promptHistory !== void 0) {
9103
- bundle.promptHistory = params.promptHistory;
9104
- }
9105
- return bundle;
9106
- }
9107
- function decodeBundle(raw) {
9108
- return Bundle.parse(raw);
9109
- }
9110
-
9111
9317
  // src/core/render-update.ts
9112
9318
  import stripAnsi from "strip-ansi";
9113
9319
  var STRIP_CONTROLS = /[\x00-\x08\x0b-\x1f\x7f]/g;
@@ -9301,6 +9507,51 @@ function isExitPlanModeTool(name) {
9301
9507
  const normalised = name.toLowerCase().replace(/[_\s-]/g, "");
9302
9508
  return normalised === "exitplanmode";
9303
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
+ }
9304
9555
  function readExitPlanMarkdown(u) {
9305
9556
  const rawInput = u.rawInput;
9306
9557
  if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput)) {
@@ -9340,6 +9591,10 @@ function mapToolCall(u) {
9340
9591
  if (rawKind !== void 0) {
9341
9592
  event.rawKind = rawKind;
9342
9593
  }
9594
+ const diff = extractEditDiff(u);
9595
+ if (diff !== null) {
9596
+ event.editDiff = diff;
9597
+ }
9343
9598
  return event;
9344
9599
  }
9345
9600
  function mapToolCallUpdate(u) {
@@ -9350,7 +9605,8 @@ function mapToolCallUpdate(u) {
9350
9605
  const rawTitle = readString(u, "title");
9351
9606
  const title = rawTitle !== void 0 ? sanitizeSingleLine(rawTitle) : void 0;
9352
9607
  const status = readString(u, "status");
9353
- 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";
9354
9610
  if (!meaningful) {
9355
9611
  return null;
9356
9612
  }
@@ -9373,6 +9629,9 @@ function mapToolCallUpdate(u) {
9373
9629
  if (status !== void 0) {
9374
9630
  event.status = status;
9375
9631
  }
9632
+ if (diff !== null) {
9633
+ event.editDiff = diff;
9634
+ }
9376
9635
  if (status === "failed") {
9377
9636
  const errorText = extractToolFailureText(u);
9378
9637
  if (errorText !== null) {
@@ -10292,6 +10551,48 @@ function registerSessionRoutes(app, manager, defaults) {
10292
10551
  reply.header("Content-Type", "text/markdown; charset=utf-8");
10293
10552
  reply.code(200).send(bundleToMarkdown(bundle));
10294
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
+ });
10295
10596
  app.post("/v1/sessions/import", async (request, reply) => {
10296
10597
  const body = request.body ?? {};
10297
10598
  if (body.bundle === void 0) {
@@ -10737,7 +11038,7 @@ import { z as z6 } from "zod";
10737
11038
  // src/core/password.ts
10738
11039
  import * as fs14 from "fs/promises";
10739
11040
  import * as path12 from "path";
10740
- import { randomBytes as randomBytes2, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
11041
+ import { randomBytes as randomBytes3, scrypt, timingSafeEqual as timingSafeEqual2 } from "crypto";
10741
11042
  import { promisify } from "util";
10742
11043
  var scryptAsync = promisify(scrypt);
10743
11044
  function passwordHashPath() {
@@ -10959,7 +11260,7 @@ function wsToMessageStream(ws) {
10959
11260
  // src/daemon/acp-ws.ts
10960
11261
  import * as os4 from "os";
10961
11262
  import * as path13 from "path";
10962
- import { randomBytes as randomBytes3 } from "crypto";
11263
+ import { randomBytes as randomBytes4 } from "crypto";
10963
11264
  function registerAcpWsEndpoint(app, deps) {
10964
11265
  app.get("/acp", { websocket: true }, async (socket, request) => {
10965
11266
  const token = tokenFromUpgradeRequest({
@@ -11156,6 +11457,23 @@ function registerAcpWsEndpoint(app, deps) {
11156
11457
  });
11157
11458
  return { childSessionId: child.sessionId };
11158
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
+ });
11159
11477
  connection.onRequest("hydra-acp/await_child", async (raw) => {
11160
11478
  const params = raw ?? {};
11161
11479
  const childSessionId = typeof params.childSessionId === "string" ? params.childSessionId : void 0;
@@ -11230,7 +11548,7 @@ function registerAcpWsEndpoint(app, deps) {
11230
11548
  let stdinReservation;
11231
11549
  let augmentedMcpServers = params.mcpServers;
11232
11550
  if (hydraMeta.mcpStdin === true && deps.mcpTokenRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11233
- stdinToken = randomBytes3(32).toString("hex");
11551
+ stdinToken = randomBytes4(32).toString("hex");
11234
11552
  stdinReservation = deps.mcpTokenRegistry.reserve(stdinToken);
11235
11553
  const url = `${deps.getDaemonOrigin()}/mcp/hydra-acp-stdin`;
11236
11554
  const descriptor = {
@@ -11248,7 +11566,7 @@ function registerAcpWsEndpoint(app, deps) {
11248
11566
  if (deps.extensionMcp !== void 0 && deps.mcpTokenRegistry !== void 0 && deps.getDaemonOrigin !== void 0) {
11249
11567
  const extNames = deps.extensionMcp.list();
11250
11568
  if (extNames.length > 0) {
11251
- extMcpToken = randomBytes3(32).toString("hex");
11569
+ extMcpToken = randomBytes4(32).toString("hex");
11252
11570
  extMcpReservation = deps.mcpTokenRegistry.reserve(extMcpToken);
11253
11571
  const origin = deps.getDaemonOrigin();
11254
11572
  const descriptors = extNames.map((name) => ({
@@ -12807,6 +13125,7 @@ async function startDaemon(config, serviceToken) {
12807
13125
  await transformers.stop();
12808
13126
  await manager.closeAll();
12809
13127
  await manager.flushMetaWrites();
13128
+ await manager.flushHistoryWrites();
12810
13129
  setBinaryInstallLogger(null);
12811
13130
  setNpmInstallLogger(null);
12812
13131
  setAgentPruneLogger(null);