@hydra-acp/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/daemon/server.ts
2
- import * as fs5 from "fs";
2
+ import * as fs7 from "fs";
3
3
  import * as fsp2 from "fs/promises";
4
4
  import Fastify from "fastify";
5
5
  import websocketPlugin from "@fastify/websocket";
@@ -20,6 +20,11 @@ function hydraHome() {
20
20
  if (override && override.length > 0) {
21
21
  return path.resolve(override);
22
22
  }
23
+ if (process.env.VITEST) {
24
+ throw new Error(
25
+ "HYDRA_ACP_HOME is unset under VITEST; vitest.setup.ts must run first"
26
+ );
27
+ }
23
28
  return path.join(os.homedir(), ".hydra-acp");
24
29
  }
25
30
  var paths = {
@@ -32,11 +37,17 @@ var paths = {
32
37
  agentsDir: () => path.join(hydraHome(), "agents"),
33
38
  agentDir: (id) => path.join(hydraHome(), "agents", id),
34
39
  sessionsDir: () => path.join(hydraHome(), "sessions"),
35
- sessionFile: (id) => path.join(hydraHome(), "sessions", `${id}.json`),
40
+ // One directory per session id under sessions/. Co-locates the
41
+ // session record, its transcript, and any future per-session state
42
+ // (uploads, scratch, etc.) so the lifecycle is just "rm -rf the dir".
43
+ sessionDir: (id) => path.join(hydraHome(), "sessions", id),
44
+ sessionFile: (id) => path.join(hydraHome(), "sessions", id, "meta.json"),
45
+ historyFile: (id) => path.join(hydraHome(), "sessions", id, "history.jsonl"),
36
46
  extensionsDir: () => path.join(hydraHome(), "extensions"),
37
47
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
38
48
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
39
- tuiHistoryFile: () => path.join(hydraHome(), "tui-history")
49
+ tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
50
+ tuiLogFile: () => path.join(hydraHome(), "tui.log")
40
51
  };
41
52
 
42
53
  // src/core/config.ts
@@ -64,7 +75,11 @@ var TuiConfig = z.object({
64
75
  // /clear, ^L, resize — bypass this throttle. Default 1000 (1 Hz) keeps
65
76
  // CPU low during heavy streaming; bump to 250 for 4 Hz, 100 for ~10 Hz,
66
77
  // or 0 to disable throttling entirely.
67
- repaintThrottleMs: z.number().int().nonnegative().default(1e3)
78
+ repaintThrottleMs: z.number().int().nonnegative().default(1e3),
79
+ // Cap on logical lines retained in the in-memory scrollback render
80
+ // buffer. Oldest lines are dropped on overflow. The on-disk session
81
+ // history is unaffected; this only bounds the TUI's local view buffer.
82
+ maxScrollbackLines: z.number().int().positive().default(1e4)
68
83
  });
69
84
  var ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
70
85
  var ExtensionBody = z.object({
@@ -89,7 +104,7 @@ var HydraConfig = z.object({
89
104
  // recency and truncated to this count. `--all` overrides in the CLI.
90
105
  sessionListColdLimit: z.number().int().nonnegative().default(20),
91
106
  extensions: z.record(ExtensionName, ExtensionBody).default({}),
92
- tui: TuiConfig.default({ repaintThrottleMs: 1e3 })
107
+ tui: TuiConfig.default({ repaintThrottleMs: 1e3, maxScrollbackLines: 1e4 })
93
108
  });
94
109
  function extensionList(config) {
95
110
  return Object.entries(config.extensions).map(([name, body]) => ({
@@ -332,6 +347,9 @@ function planSpawn(agent, extraArgs = []) {
332
347
  throw new Error(`Agent ${agent.id} has no usable distribution method.`);
333
348
  }
334
349
 
350
+ // src/core/session-manager.ts
351
+ import * as fs5 from "fs/promises";
352
+
335
353
  // src/core/agent-instance.ts
336
354
  import { spawn } from "child_process";
337
355
 
@@ -410,6 +428,35 @@ function extractHydraMeta(meta) {
410
428
  out.resume = parsed.data;
411
429
  }
412
430
  }
431
+ if (typeof obj.currentModel === "string") {
432
+ out.currentModel = obj.currentModel;
433
+ }
434
+ if (typeof obj.currentMode === "string") {
435
+ out.currentMode = obj.currentMode;
436
+ }
437
+ if (typeof obj.turnStartedAt === "number" && obj.turnStartedAt > 0) {
438
+ out.turnStartedAt = obj.turnStartedAt;
439
+ }
440
+ if (Array.isArray(obj.availableCommands)) {
441
+ const cmds = [];
442
+ for (const raw of obj.availableCommands) {
443
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
444
+ continue;
445
+ }
446
+ const c = raw;
447
+ if (typeof c.name !== "string") {
448
+ continue;
449
+ }
450
+ const cmd = { name: c.name };
451
+ if (typeof c.description === "string") {
452
+ cmd.description = c.description;
453
+ }
454
+ cmds.push(cmd);
455
+ }
456
+ if (cmds.length > 0) {
457
+ out.availableCommands = cmds;
458
+ }
459
+ }
413
460
  return out;
414
461
  }
415
462
  function mergeMeta(passthrough, ours) {
@@ -779,14 +826,26 @@ var Session = class {
779
826
  agentMeta;
780
827
  agentArgs;
781
828
  title;
829
+ // Snapshot state delivered to attaching clients via the attach
830
+ // response _meta rather than via history replay (which would be
831
+ // stale-prone for snapshot-shaped events).
832
+ currentModel;
833
+ currentMode;
782
834
  updatedAt;
783
835
  clients = /* @__PURE__ */ new Map();
784
836
  history = [];
837
+ historyStore;
785
838
  promptQueue = [];
786
839
  promptInFlight = false;
787
840
  closed = false;
788
841
  closeHandlers = [];
789
842
  titleHandlers = [];
843
+ // Subscribers notified after every entry that's actually persisted to
844
+ // history (skipping snapshot-shaped events filtered by
845
+ // recordAndBroadcast). The HTTP /v1/sessions/:id/history?follow=1
846
+ // endpoint uses this to tail a live session's conversation stream
847
+ // without participating in turns or prompts.
848
+ broadcastHandlers = [];
790
849
  // True once we've observed our first session/prompt; gates the
791
850
  // first-prompt-seeded title so subsequent prompts don't churn it.
792
851
  firstPromptSeeded = false;
@@ -806,12 +865,18 @@ var Session = class {
806
865
  idleTimer;
807
866
  spawnReplacementAgent;
808
867
  agentChangeHandlers = [];
809
- // Last available_commands_update we observed from the agent. Stored so
810
- // we can re-broadcast a merged (hydra ∪ agent) list whenever either
811
- // half changes most importantly when a fresh client attaches and
812
- // replays history, since the in-cache update is the daemon's merged
813
- // form (the agent's raw form is never broadcast).
868
+ // Last available_commands_update we observed from the agent. Stored
869
+ // so we can re-broadcast a merged (hydra ∪ agent) list whenever
870
+ // either half changes, and persisted to meta.json so a fresh attach
871
+ // can deliver the merged list via _meta without depending on history
872
+ // replay.
814
873
  agentAdvertisedCommands = [];
874
+ // Persist hooks for snapshot-shaped state. SessionManager hooks these
875
+ // to mirror changes into meta.json so cold-resurrect attaches can
876
+ // surface the latest snapshot via the attach response _meta.
877
+ agentCommandsHandlers = [];
878
+ modelHandlers = [];
879
+ modeHandlers = [];
815
880
  constructor(init) {
816
881
  this.sessionId = init.sessionId ?? `${HYDRA_SESSION_PREFIX}${generateHydraId()}`;
817
882
  this.cwd = init.cwd;
@@ -821,11 +886,22 @@ var Session = class {
821
886
  this.agentMeta = init.agentMeta;
822
887
  this.agentArgs = init.agentArgs;
823
888
  this.title = init.title;
889
+ this.currentModel = init.currentModel;
890
+ this.currentMode = init.currentMode;
891
+ if (init.agentCommands && init.agentCommands.length > 0) {
892
+ this.agentAdvertisedCommands = [...init.agentCommands];
893
+ }
824
894
  this.idleTimeoutMs = init.idleTimeoutMs ?? 0;
825
895
  this.spawnReplacementAgent = init.spawnReplacementAgent;
896
+ if (init.firstPromptSeeded) {
897
+ this.firstPromptSeeded = true;
898
+ }
899
+ this.historyStore = init.historyStore;
900
+ if (init.seedHistory && init.seedHistory.length > 0) {
901
+ this.history = [...init.seedHistory];
902
+ }
826
903
  this.updatedAt = Date.now();
827
904
  this.wireAgent(this.agent);
828
- this.broadcastMergedCommands();
829
905
  }
830
906
  broadcastMergedCommands() {
831
907
  const merged = [
@@ -854,8 +930,15 @@ var Session = class {
854
930
  }
855
931
  const agentCmds = extractAdvertisedCommands(params);
856
932
  if (agentCmds !== null) {
857
- this.agentAdvertisedCommands = agentCmds;
858
- this.broadcastMergedCommands();
933
+ this.setAgentAdvertisedCommands(agentCmds);
934
+ return;
935
+ }
936
+ if (this.maybeApplyAgentModel(params)) {
937
+ this.recordAndBroadcast("session/update", params);
938
+ return;
939
+ }
940
+ if (this.maybeApplyAgentMode(params)) {
941
+ this.recordAndBroadcast("session/update", params);
859
942
  return;
860
943
  }
861
944
  this.maybeApplyAgentSessionInfo(params);
@@ -877,6 +960,50 @@ var Session = class {
877
960
  get attachedCount() {
878
961
  return this.clients.size;
879
962
  }
963
+ // Wall-clock when the in-flight agent turn began, or undefined when
964
+ // idle. Derived from history: the most recent prompt_received without
965
+ // a later turn_complete is the outstanding turn, and its recordedAt
966
+ // is when the prompt was first broadcast. Used by buildResponseMeta
967
+ // so a fresh client reattaching mid-turn boots up with the busy
968
+ // banner showing real elapsed time.
969
+ get turnStartedAt() {
970
+ for (let i = this.history.length - 1; i >= 0; i--) {
971
+ const entry = this.history[i];
972
+ if (!entry) {
973
+ continue;
974
+ }
975
+ const params = entry.params;
976
+ const kind = params?.update?.sessionUpdate;
977
+ if (kind === "turn_complete") {
978
+ return void 0;
979
+ }
980
+ if (kind === "prompt_received") {
981
+ return entry.recordedAt;
982
+ }
983
+ }
984
+ return void 0;
985
+ }
986
+ // Snapshot of the current in-memory replay history. Used by the
987
+ // HTTP history endpoint to deliver the "what's accumulated so far"
988
+ // prefix before optionally tailing with onBroadcast. Returns a copy
989
+ // so callers can't mutate our cache.
990
+ getHistorySnapshot() {
991
+ return [...this.history];
992
+ }
993
+ // Subscribe to recordable broadcast entries — fires once per entry
994
+ // that lands in history (so snapshot-shaped session_info/model/mode/
995
+ // available_commands updates do NOT trigger this; they're broadcast
996
+ // live but not recorded). Returns an unsubscribe function the caller
997
+ // must invoke when done.
998
+ onBroadcast(handler) {
999
+ this.broadcastHandlers.push(handler);
1000
+ return () => {
1001
+ const i = this.broadcastHandlers.indexOf(handler);
1002
+ if (i >= 0) {
1003
+ this.broadcastHandlers.splice(i, 1);
1004
+ }
1005
+ };
1006
+ }
880
1007
  attach(client, historyPolicy) {
881
1008
  if (this.closed) {
882
1009
  throw withCode(
@@ -933,13 +1060,19 @@ var Session = class {
933
1060
  this.broadcastPromptReceived(client, params);
934
1061
  this.maybeSeedTitleFromPrompt(params);
935
1062
  return this.enqueuePrompt(async () => {
936
- const response = await this.agent.connection.request(
937
- "session/prompt",
938
- {
939
- ...params,
940
- sessionId: this.upstreamSessionId
941
- }
942
- );
1063
+ let response;
1064
+ try {
1065
+ response = await this.agent.connection.request(
1066
+ "session/prompt",
1067
+ {
1068
+ ...params,
1069
+ sessionId: this.upstreamSessionId
1070
+ }
1071
+ );
1072
+ } catch (err) {
1073
+ this.broadcastTurnComplete(client.clientId, { stopReason: "error" });
1074
+ throw err;
1075
+ }
943
1076
  this.broadcastTurnComplete(client.clientId, response);
944
1077
  return response;
945
1078
  });
@@ -1027,6 +1160,13 @@ var Session = class {
1027
1160
  return;
1028
1161
  }
1029
1162
  this.cancelIdleTimer();
1163
+ if (opts.regenTitle && this.firstPromptSeeded) {
1164
+ const timeoutMs = opts.regenTitleTimeoutMs ?? 5e3;
1165
+ await Promise.race([
1166
+ this.runTitleRegen().catch(() => void 0),
1167
+ new Promise((r) => setTimeout(r, timeoutMs).unref?.())
1168
+ ]);
1169
+ }
1030
1170
  await this.agent.kill().catch(() => void 0);
1031
1171
  this.markClosed({ deleteRecord: opts.deleteRecord ?? false });
1032
1172
  }
@@ -1075,13 +1215,98 @@ var Session = class {
1075
1215
  }
1076
1216
  const promptParams = params ?? {};
1077
1217
  const text = extractPromptText(promptParams.prompt);
1078
- const seed = firstLine(text, 80);
1218
+ const seed = firstLine(text, 200);
1079
1219
  if (!seed) {
1080
1220
  return;
1081
1221
  }
1082
1222
  this.firstPromptSeeded = true;
1083
1223
  this.setTitle(seed);
1084
1224
  }
1225
+ // Apply an agent-emitted current_model_update. Returns true if the
1226
+ // notification was a model update (caller still needs to broadcast
1227
+ // it). Returns false otherwise so the caller can try the next kind.
1228
+ maybeApplyAgentModel(params) {
1229
+ const obj = params ?? {};
1230
+ const update = obj.update ?? {};
1231
+ if (update.sessionUpdate !== "current_model_update") {
1232
+ return false;
1233
+ }
1234
+ const raw = typeof update.currentModel === "string" ? update.currentModel : typeof update.model === "string" ? update.model : void 0;
1235
+ if (raw === void 0) {
1236
+ return true;
1237
+ }
1238
+ const trimmed = raw.trim();
1239
+ if (!trimmed || trimmed === this.currentModel) {
1240
+ return true;
1241
+ }
1242
+ this.currentModel = trimmed;
1243
+ for (const handler of this.modelHandlers) {
1244
+ try {
1245
+ handler(trimmed);
1246
+ } catch {
1247
+ }
1248
+ }
1249
+ return true;
1250
+ }
1251
+ maybeApplyAgentMode(params) {
1252
+ const obj = params ?? {};
1253
+ const update = obj.update ?? {};
1254
+ if (update.sessionUpdate !== "current_mode_update") {
1255
+ return false;
1256
+ }
1257
+ const raw = typeof update.currentMode === "string" ? update.currentMode : typeof update.mode === "string" ? update.mode : void 0;
1258
+ if (raw === void 0) {
1259
+ return true;
1260
+ }
1261
+ const trimmed = raw.trim();
1262
+ if (!trimmed || trimmed === this.currentMode) {
1263
+ return true;
1264
+ }
1265
+ this.currentMode = trimmed;
1266
+ for (const handler of this.modeHandlers) {
1267
+ try {
1268
+ handler(trimmed);
1269
+ } catch {
1270
+ }
1271
+ }
1272
+ return true;
1273
+ }
1274
+ // Update the cached agent command list, fire persist handlers, and
1275
+ // broadcast the merged list to attached clients. Idempotent on a
1276
+ // structurally identical list so we don't churn meta.json on noisy
1277
+ // re-emissions.
1278
+ setAgentAdvertisedCommands(commands) {
1279
+ if (sameAdvertisedCommands(this.agentAdvertisedCommands, commands)) {
1280
+ this.broadcastMergedCommands();
1281
+ return;
1282
+ }
1283
+ this.agentAdvertisedCommands = commands;
1284
+ for (const handler of this.agentCommandsHandlers) {
1285
+ try {
1286
+ handler(commands);
1287
+ } catch {
1288
+ }
1289
+ }
1290
+ this.broadcastMergedCommands();
1291
+ }
1292
+ // Subscribe to snapshot-state updates. SessionManager wires these to
1293
+ // persist the new value into meta.json so cold resurrect can restore
1294
+ // them via the attach response _meta.
1295
+ onAgentCommandsChange(handler) {
1296
+ this.agentCommandsHandlers.push(handler);
1297
+ }
1298
+ onModelChange(handler) {
1299
+ this.modelHandlers.push(handler);
1300
+ }
1301
+ onModeChange(handler) {
1302
+ this.modeHandlers.push(handler);
1303
+ }
1304
+ // Returns a freshly merged command list (hydra ∪ agent) for callers
1305
+ // that need a snapshot — notably acp-ws.ts's buildResponseMeta when
1306
+ // assembling the attach response.
1307
+ mergedAvailableCommands() {
1308
+ return [...hydraCommandsAsAdvertised(), ...this.agentAdvertisedCommands];
1309
+ }
1085
1310
  // Pick up an agent-emitted session_info_update and store its title
1086
1311
  // as our canonical record. The notification is also forwarded to
1087
1312
  // clients via the surrounding recordAndBroadcast call. Authoritative
@@ -1369,7 +1594,8 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1369
1594
  }
1370
1595
  this.idleTimer = setTimeout(() => {
1371
1596
  this.idleTimer = void 0;
1372
- void this.close({ deleteRecord: false }).catch(() => void 0);
1597
+ const opts = this.firstPromptSeeded ? { deleteRecord: false, regenTitle: true } : { deleteRecord: true };
1598
+ void this.close(opts).catch(() => void 0);
1373
1599
  }, this.idleTimeoutMs);
1374
1600
  if (typeof this.idleTimer.unref === "function") {
1375
1601
  this.idleTimer.unref();
@@ -1392,9 +1618,34 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
1392
1618
  }
1393
1619
  recordAndBroadcast(method, params, excludeClientId) {
1394
1620
  const rewritten = this.rewriteForClient(params);
1395
- this.history.push({ method, params: rewritten, recordedAt: Date.now() });
1396
- if (this.history.length > 1e3) {
1397
- this.history = this.history.slice(-500);
1621
+ const recordable = !isStateUpdate(method, rewritten);
1622
+ if (recordable) {
1623
+ const entry = {
1624
+ method,
1625
+ params: rewritten,
1626
+ recordedAt: Date.now()
1627
+ };
1628
+ this.history.push(entry);
1629
+ let trimmed = false;
1630
+ if (this.history.length > 1e3) {
1631
+ this.history = this.history.slice(-500);
1632
+ trimmed = true;
1633
+ }
1634
+ if (this.historyStore) {
1635
+ if (trimmed) {
1636
+ void this.historyStore.rewrite(this.sessionId, [...this.history]).catch(() => void 0);
1637
+ } else {
1638
+ void this.historyStore.append(this.sessionId, entry).catch(
1639
+ () => void 0
1640
+ );
1641
+ }
1642
+ }
1643
+ for (const handler of this.broadcastHandlers) {
1644
+ try {
1645
+ handler(entry);
1646
+ } catch {
1647
+ }
1648
+ }
1398
1649
  }
1399
1650
  this.updatedAt = Date.now();
1400
1651
  for (const client of this.clients.values()) {
@@ -1494,6 +1745,31 @@ function withCode(err, code) {
1494
1745
  err.code = code;
1495
1746
  return err;
1496
1747
  }
1748
+ var STATE_UPDATE_KINDS = /* @__PURE__ */ new Set([
1749
+ "session_info_update",
1750
+ "current_model_update",
1751
+ "current_mode_update",
1752
+ "available_commands_update"
1753
+ ]);
1754
+ function isStateUpdate(method, params) {
1755
+ if (method !== "session/update") {
1756
+ return false;
1757
+ }
1758
+ const obj = params ?? {};
1759
+ const kind = obj.update?.sessionUpdate;
1760
+ return typeof kind === "string" && STATE_UPDATE_KINDS.has(kind);
1761
+ }
1762
+ function sameAdvertisedCommands(a, b) {
1763
+ if (a.length !== b.length) {
1764
+ return false;
1765
+ }
1766
+ for (let i = 0; i < a.length; i++) {
1767
+ if (a[i]?.name !== b[i]?.name || a[i]?.description !== b[i]?.description) {
1768
+ return false;
1769
+ }
1770
+ }
1771
+ return true;
1772
+ }
1497
1773
  function captureInternalChunk(capture, params) {
1498
1774
  const obj = params ?? {};
1499
1775
  const update = obj.update ?? {};
@@ -1561,6 +1837,10 @@ function firstLine(text, max) {
1561
1837
  import * as fs3 from "fs/promises";
1562
1838
  import * as path2 from "path";
1563
1839
  import { z as z4 } from "zod";
1840
+ var PersistedAgentCommand = z4.object({
1841
+ name: z4.string(),
1842
+ description: z4.string().optional()
1843
+ });
1564
1844
  var SessionRecord = z4.object({
1565
1845
  version: z4.literal(1),
1566
1846
  sessionId: z4.string(),
@@ -1569,6 +1849,13 @@ var SessionRecord = z4.object({
1569
1849
  cwd: z4.string(),
1570
1850
  title: z4.string().optional(),
1571
1851
  agentArgs: z4.array(z4.string()).optional(),
1852
+ // Snapshot of "what is currently true about this session" carried in
1853
+ // meta.json so a late-attaching or cold-resurrected client can be
1854
+ // told via the attach response _meta without depending on history
1855
+ // replay of a snapshot-shaped notification.
1856
+ currentModel: z4.string().optional(),
1857
+ currentMode: z4.string().optional(),
1858
+ agentCommands: z4.array(PersistedAgentCommand).optional(),
1572
1859
  createdAt: z4.string(),
1573
1860
  updatedAt: z4.string()
1574
1861
  });
@@ -1581,7 +1868,7 @@ function assertSafeId(id) {
1581
1868
  var SessionStore = class {
1582
1869
  async write(record) {
1583
1870
  assertSafeId(record.sessionId);
1584
- await fs3.mkdir(paths.sessionsDir(), { recursive: true });
1871
+ await fs3.mkdir(paths.sessionDir(record.sessionId), { recursive: true });
1585
1872
  const full = { version: 1, ...record };
1586
1873
  await fs3.writeFile(
1587
1874
  paths.sessionFile(record.sessionId),
@@ -1621,6 +1908,14 @@ var SessionStore = class {
1621
1908
  throw err;
1622
1909
  }
1623
1910
  }
1911
+ try {
1912
+ await fs3.rmdir(paths.sessionDir(sessionId));
1913
+ } catch (err) {
1914
+ const e = err;
1915
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
1916
+ throw err;
1917
+ }
1918
+ }
1624
1919
  }
1625
1920
  async list() {
1626
1921
  let entries;
@@ -1635,11 +1930,7 @@ var SessionStore = class {
1635
1930
  }
1636
1931
  const records = [];
1637
1932
  for (const entry of entries) {
1638
- if (!entry.endsWith(".json")) {
1639
- continue;
1640
- }
1641
- const id = entry.slice(0, -".json".length);
1642
- const record = await this.read(id);
1933
+ const record = await this.read(entry);
1643
1934
  if (record) {
1644
1935
  records.push(record);
1645
1936
  }
@@ -1656,17 +1947,143 @@ function recordFromMemorySession(args) {
1656
1947
  cwd: args.cwd,
1657
1948
  title: args.title,
1658
1949
  agentArgs: args.agentArgs,
1950
+ currentModel: args.currentModel,
1951
+ currentMode: args.currentMode,
1952
+ agentCommands: args.agentCommands,
1659
1953
  createdAt: args.createdAt ?? now,
1660
1954
  updatedAt: args.updatedAt ?? now
1661
1955
  };
1662
1956
  }
1663
1957
 
1958
+ // src/core/history-store.ts
1959
+ import * as fs4 from "fs/promises";
1960
+ var SESSION_ID_PATTERN2 = /^[A-Za-z0-9_-]+$/;
1961
+ var MAX_ENTRIES = 1e3;
1962
+ var HistoryStore = class {
1963
+ // Serialize writes per session id so appends and rewrites don't
1964
+ // interleave JSONL lines on disk. The chain swallows errors so one
1965
+ // failed append doesn't poison every subsequent write.
1966
+ writeQueues = /* @__PURE__ */ new Map();
1967
+ async append(sessionId, entry) {
1968
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1969
+ return;
1970
+ }
1971
+ return this.enqueue(sessionId, async () => {
1972
+ await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
1973
+ const line = JSON.stringify(entry) + "\n";
1974
+ await fs4.appendFile(paths.historyFile(sessionId), line, {
1975
+ encoding: "utf8",
1976
+ mode: 384
1977
+ });
1978
+ });
1979
+ }
1980
+ async rewrite(sessionId, entries) {
1981
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1982
+ return;
1983
+ }
1984
+ return this.enqueue(sessionId, async () => {
1985
+ await fs4.mkdir(paths.sessionDir(sessionId), { recursive: true });
1986
+ const body = entries.length === 0 ? "" : entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
1987
+ await fs4.writeFile(paths.historyFile(sessionId), body, {
1988
+ encoding: "utf8",
1989
+ mode: 384
1990
+ });
1991
+ });
1992
+ }
1993
+ async load(sessionId) {
1994
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
1995
+ return [];
1996
+ }
1997
+ const pending = this.writeQueues.get(sessionId);
1998
+ if (pending) {
1999
+ await pending;
2000
+ }
2001
+ let raw;
2002
+ try {
2003
+ raw = await fs4.readFile(paths.historyFile(sessionId), "utf8");
2004
+ } catch (err) {
2005
+ const e = err;
2006
+ if (e.code === "ENOENT") {
2007
+ return [];
2008
+ }
2009
+ throw err;
2010
+ }
2011
+ const out = [];
2012
+ for (const line of raw.split("\n")) {
2013
+ if (line.length === 0) {
2014
+ continue;
2015
+ }
2016
+ let parsed;
2017
+ try {
2018
+ parsed = JSON.parse(line);
2019
+ } catch {
2020
+ continue;
2021
+ }
2022
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2023
+ continue;
2024
+ }
2025
+ const obj = parsed;
2026
+ if (typeof obj.method !== "string") {
2027
+ continue;
2028
+ }
2029
+ if (typeof obj.recordedAt !== "number") {
2030
+ continue;
2031
+ }
2032
+ out.push({
2033
+ method: obj.method,
2034
+ params: obj.params,
2035
+ recordedAt: obj.recordedAt
2036
+ });
2037
+ }
2038
+ if (out.length > MAX_ENTRIES) {
2039
+ return out.slice(-MAX_ENTRIES);
2040
+ }
2041
+ return out;
2042
+ }
2043
+ async delete(sessionId) {
2044
+ if (!SESSION_ID_PATTERN2.test(sessionId)) {
2045
+ return;
2046
+ }
2047
+ return this.enqueue(sessionId, async () => {
2048
+ try {
2049
+ await fs4.unlink(paths.historyFile(sessionId));
2050
+ } catch (err) {
2051
+ const e = err;
2052
+ if (e.code !== "ENOENT") {
2053
+ throw err;
2054
+ }
2055
+ }
2056
+ try {
2057
+ await fs4.rmdir(paths.sessionDir(sessionId));
2058
+ } catch (err) {
2059
+ const e = err;
2060
+ if (e.code !== "ENOENT" && e.code !== "ENOTEMPTY") {
2061
+ throw err;
2062
+ }
2063
+ }
2064
+ });
2065
+ }
2066
+ enqueue(sessionId, task) {
2067
+ const prev = this.writeQueues.get(sessionId) ?? Promise.resolve();
2068
+ const task$ = prev.then(task, task);
2069
+ const settled = task$.catch(() => void 0);
2070
+ this.writeQueues.set(sessionId, settled);
2071
+ void settled.finally(() => {
2072
+ if (this.writeQueues.get(sessionId) === settled) {
2073
+ this.writeQueues.delete(sessionId);
2074
+ }
2075
+ });
2076
+ return task$;
2077
+ }
2078
+ };
2079
+
1664
2080
  // src/core/session-manager.ts
1665
2081
  var SessionManager = class {
1666
2082
  constructor(registry, spawner, store, options = {}) {
1667
2083
  this.registry = registry;
1668
2084
  this.spawner = spawner ?? ((opts) => AgentInstance.spawn(opts));
1669
2085
  this.store = store ?? new SessionStore();
2086
+ this.histories = new HistoryStore();
1670
2087
  this.idleTimeoutMs = options.idleTimeoutMs ?? 0;
1671
2088
  }
1672
2089
  registry;
@@ -1674,7 +2091,12 @@ var SessionManager = class {
1674
2091
  resurrectionInflight = /* @__PURE__ */ new Map();
1675
2092
  spawner;
1676
2093
  store;
2094
+ histories;
1677
2095
  idleTimeoutMs;
2096
+ // Serialize meta.json read-modify-write operations per session id so
2097
+ // concurrent snapshot updates (e.g. an agent emitting model + mode
2098
+ // back-to-back) don't lose writes via interleaved reads.
2099
+ metaWriteQueues = /* @__PURE__ */ new Map();
1678
2100
  async create(params) {
1679
2101
  const fresh = await this.bootstrapAgent({
1680
2102
  agentId: params.agentId,
@@ -1691,7 +2113,8 @@ var SessionManager = class {
1691
2113
  title: params.title,
1692
2114
  agentArgs: params.agentArgs,
1693
2115
  idleTimeoutMs: this.idleTimeoutMs,
1694
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
2116
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2117
+ historyStore: this.histories
1695
2118
  });
1696
2119
  await this.attachManagerHooks(session);
1697
2120
  return session;
@@ -1767,7 +2190,13 @@ var SessionManager = class {
1767
2190
  title: params.title,
1768
2191
  agentArgs: params.agentArgs,
1769
2192
  idleTimeoutMs: this.idleTimeoutMs,
1770
- spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] })
2193
+ spawnReplacementAgent: (p) => this.bootstrapAgent({ ...p, mcpServers: [] }),
2194
+ historyStore: this.histories,
2195
+ seedHistory: params.seedHistory,
2196
+ currentModel: params.currentModel,
2197
+ currentMode: params.currentMode,
2198
+ agentCommands: params.agentCommands,
2199
+ firstPromptSeeded: true
1771
2200
  });
1772
2201
  await this.attachManagerHooks(session);
1773
2202
  return session;
@@ -1821,6 +2250,7 @@ var SessionManager = class {
1821
2250
  this.sessions.delete(session.sessionId);
1822
2251
  if (deleteRecord) {
1823
2252
  void this.store.delete(session.sessionId).catch(() => void 0);
2253
+ void this.histories.delete(session.sessionId).catch(() => void 0);
1824
2254
  }
1825
2255
  });
1826
2256
  session.onTitleChange((title) => {
@@ -1831,6 +2261,24 @@ var SessionManager = class {
1831
2261
  () => void 0
1832
2262
  );
1833
2263
  });
2264
+ session.onModelChange((model) => {
2265
+ void this.persistSnapshot(session.sessionId, { currentModel: model }).catch(
2266
+ () => void 0
2267
+ );
2268
+ });
2269
+ session.onModeChange((mode) => {
2270
+ void this.persistSnapshot(session.sessionId, { currentMode: mode }).catch(
2271
+ () => void 0
2272
+ );
2273
+ });
2274
+ session.onAgentCommandsChange((commands) => {
2275
+ void this.persistSnapshot(session.sessionId, {
2276
+ agentCommands: commands.map((c) => ({
2277
+ name: c.name,
2278
+ ...c.description !== void 0 ? { description: c.description } : {}
2279
+ }))
2280
+ }).catch(() => void 0);
2281
+ });
1834
2282
  this.sessions.set(session.sessionId, session);
1835
2283
  await this.store.write(
1836
2284
  recordFromMemorySession({
@@ -1839,22 +2287,45 @@ var SessionManager = class {
1839
2287
  agentId: session.agentId,
1840
2288
  cwd: session.cwd,
1841
2289
  title: session.title,
1842
- agentArgs: session.agentArgs
2290
+ agentArgs: session.agentArgs,
2291
+ currentModel: session.currentModel,
2292
+ currentMode: session.currentMode
1843
2293
  })
1844
2294
  ).catch(() => void 0);
1845
2295
  }
2296
+ // Resolve a session's recorded history without forcing a resurrect.
2297
+ // Returns the in-memory snapshot if the session is hot, falls back
2298
+ // to the on-disk history file otherwise. Returns undefined if the
2299
+ // session id is unknown to both the live map and disk store, so the
2300
+ // caller can distinguish "no history yet" (empty array) from "404".
2301
+ async getHistory(sessionId) {
2302
+ const live = this.sessions.get(sessionId);
2303
+ if (live) {
2304
+ return live.getHistorySnapshot();
2305
+ }
2306
+ const record = await this.store.read(sessionId);
2307
+ if (!record) {
2308
+ return void 0;
2309
+ }
2310
+ return this.histories.load(sessionId).catch(() => []);
2311
+ }
1846
2312
  async loadFromDisk(sessionId) {
1847
2313
  const record = await this.store.read(sessionId);
1848
2314
  if (!record) {
1849
2315
  return void 0;
1850
2316
  }
2317
+ const seedHistory = await this.histories.load(sessionId).catch(() => []);
1851
2318
  return {
1852
2319
  hydraSessionId: record.sessionId,
1853
2320
  upstreamSessionId: record.upstreamSessionId,
1854
2321
  agentId: record.agentId,
1855
2322
  cwd: record.cwd,
1856
2323
  title: record.title,
1857
- agentArgs: record.agentArgs
2324
+ agentArgs: record.agentArgs,
2325
+ seedHistory: seedHistory.length > 0 ? seedHistory : void 0,
2326
+ currentModel: record.currentModel,
2327
+ currentMode: record.currentMode,
2328
+ agentCommands: record.agentCommands
1858
2329
  };
1859
2330
  }
1860
2331
  get(sessionId) {
@@ -1896,13 +2367,14 @@ var SessionManager = class {
1896
2367
  continue;
1897
2368
  }
1898
2369
  liveIds.add(session.sessionId);
2370
+ const used = await historyMtimeIso(session.sessionId) ?? new Date(session.updatedAt).toISOString();
1899
2371
  entries.push({
1900
2372
  sessionId: session.sessionId,
1901
2373
  upstreamSessionId: session.upstreamSessionId,
1902
2374
  cwd: session.cwd,
1903
2375
  title: session.title,
1904
2376
  agentId: session.agentId,
1905
- updatedAt: new Date(session.updatedAt).toISOString(),
2377
+ updatedAt: used,
1906
2378
  attachedClients: session.attachedCount,
1907
2379
  status: "live"
1908
2380
  });
@@ -1915,13 +2387,14 @@ var SessionManager = class {
1915
2387
  if (filter.cwd && r.cwd !== filter.cwd) {
1916
2388
  continue;
1917
2389
  }
2390
+ const used = await historyMtimeIso(r.sessionId) ?? r.updatedAt;
1918
2391
  entries.push({
1919
2392
  sessionId: r.sessionId,
1920
2393
  upstreamSessionId: r.upstreamSessionId,
1921
2394
  cwd: r.cwd,
1922
2395
  title: r.title,
1923
2396
  agentId: r.agentId,
1924
- updatedAt: r.updatedAt,
2397
+ updatedAt: used,
1925
2398
  attachedClients: 0,
1926
2399
  status: "cold"
1927
2400
  });
@@ -1937,19 +2410,25 @@ var SessionManager = class {
1937
2410
  await this.store.delete(sessionId).catch(() => void 0);
1938
2411
  return true;
1939
2412
  }
2413
+ async hasRecord(sessionId) {
2414
+ const record = await this.store.read(sessionId).catch(() => void 0);
2415
+ return record !== void 0;
2416
+ }
1940
2417
  // Persist a title update from Session.setTitle. The on-disk record
1941
2418
  // was written at create time; updating it here keeps the session
1942
2419
  // record's title in sync with what was broadcast to clients so a
1943
2420
  // daemon restart (and later resurrect) restores the same title.
1944
2421
  async persistTitle(sessionId, title) {
1945
- const record = await this.store.read(sessionId);
1946
- if (!record) {
1947
- return;
1948
- }
1949
- await this.store.write({
1950
- ...record,
1951
- title,
1952
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2422
+ await this.enqueueMetaWrite(sessionId, async () => {
2423
+ const record = await this.store.read(sessionId);
2424
+ if (!record) {
2425
+ return;
2426
+ }
2427
+ await this.store.write({
2428
+ ...record,
2429
+ title,
2430
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2431
+ });
1953
2432
  });
1954
2433
  }
1955
2434
  // Persist an agent swap from /hydra switch. The on-disk record's
@@ -1957,16 +2436,51 @@ var SessionManager = class {
1957
2436
  // later resurrect) brings the session back up on the agent the user
1958
2437
  // most recently switched to, not the one it was originally created on.
1959
2438
  async persistAgentChange(sessionId, agentId, upstreamSessionId) {
1960
- const record = await this.store.read(sessionId);
1961
- if (!record) {
1962
- return;
1963
- }
1964
- await this.store.write({
1965
- ...record,
1966
- agentId,
1967
- upstreamSessionId,
1968
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2439
+ await this.enqueueMetaWrite(sessionId, async () => {
2440
+ const record = await this.store.read(sessionId);
2441
+ if (!record) {
2442
+ return;
2443
+ }
2444
+ await this.store.write({
2445
+ ...record,
2446
+ agentId,
2447
+ upstreamSessionId,
2448
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2449
+ });
2450
+ });
2451
+ }
2452
+ // Update one or more snapshot fields (model, mode, commands) in
2453
+ // meta.json. Used so cold-resurrect can deliver the latest snapshot
2454
+ // to attaching clients via the attach response _meta. No-op if the
2455
+ // session record has gone away (race with deleteRecord).
2456
+ async persistSnapshot(sessionId, update) {
2457
+ await this.enqueueMetaWrite(sessionId, async () => {
2458
+ const record = await this.store.read(sessionId);
2459
+ if (!record) {
2460
+ return;
2461
+ }
2462
+ await this.store.write({
2463
+ ...record,
2464
+ ...update.currentModel !== void 0 ? { currentModel: update.currentModel } : {},
2465
+ ...update.currentMode !== void 0 ? { currentMode: update.currentMode } : {},
2466
+ ...update.agentCommands !== void 0 ? { agentCommands: update.agentCommands } : {},
2467
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2468
+ });
2469
+ });
2470
+ }
2471
+ // Serialize meta.json writes per session id so concurrent
2472
+ // read-modify-write operations don't interleave reads.
2473
+ enqueueMetaWrite(sessionId, task) {
2474
+ const prev = this.metaWriteQueues.get(sessionId) ?? Promise.resolve();
2475
+ const next = prev.then(task, task);
2476
+ const settled = next.catch(() => void 0);
2477
+ this.metaWriteQueues.set(sessionId, settled);
2478
+ void settled.finally(() => {
2479
+ if (this.metaWriteQueues.get(sessionId) === settled) {
2480
+ this.metaWriteQueues.delete(sessionId);
2481
+ }
1969
2482
  });
2483
+ return next;
1970
2484
  }
1971
2485
  async closeAll() {
1972
2486
  const sessions = [...this.sessions.values()];
@@ -1974,10 +2488,18 @@ var SessionManager = class {
1974
2488
  this.sessions.clear();
1975
2489
  }
1976
2490
  };
2491
+ async function historyMtimeIso(sessionId) {
2492
+ try {
2493
+ const st = await fs5.stat(paths.historyFile(sessionId));
2494
+ return new Date(st.mtimeMs).toISOString();
2495
+ } catch {
2496
+ return void 0;
2497
+ }
2498
+ }
1977
2499
 
1978
2500
  // src/core/extensions.ts
1979
2501
  import { spawn as spawn2 } from "child_process";
1980
- import * as fs4 from "fs";
2502
+ import * as fs6 from "fs";
1981
2503
  import * as fsp from "fs/promises";
1982
2504
  import * as path3 from "path";
1983
2505
  var RESTART_BASE_MS = 1e3;
@@ -2260,7 +2782,7 @@ var ExtensionManager = class {
2260
2782
  }
2261
2783
  const ext = entry.config;
2262
2784
  const command = ext.command.length > 0 ? ext.command : [ext.name];
2263
- const logStream = fs4.createWriteStream(paths.extensionLogFile(ext.name), {
2785
+ const logStream = fs6.createWriteStream(paths.extensionLogFile(ext.name), {
2264
2786
  flags: "a"
2265
2787
  });
2266
2788
  logStream.write(
@@ -2310,7 +2832,7 @@ var ExtensionManager = class {
2310
2832
  }
2311
2833
  if (typeof child.pid === "number") {
2312
2834
  try {
2313
- fs4.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2835
+ fs6.writeFileSync(paths.extensionPidFile(ext.name), `${child.pid}
2314
2836
  `, {
2315
2837
  encoding: "utf8",
2316
2838
  mode: 384
@@ -2335,7 +2857,7 @@ var ExtensionManager = class {
2335
2857
  });
2336
2858
  child.on("exit", (code, signal) => {
2337
2859
  try {
2338
- fs4.unlinkSync(paths.extensionPidFile(ext.name));
2860
+ fs6.unlinkSync(paths.extensionPidFile(ext.name));
2339
2861
  } catch {
2340
2862
  }
2341
2863
  logStream.write(
@@ -2447,8 +2969,7 @@ function constantTimeEqual(a, b) {
2447
2969
  function registerSessionRoutes(app, manager, defaults) {
2448
2970
  app.get("/v1/sessions", async (request) => {
2449
2971
  const query = request.query;
2450
- const all = query?.all === "true" || query?.all === "1";
2451
- const sessions = await manager.list({ cwd: query?.cwd, all });
2972
+ const sessions = await manager.list({ cwd: query?.cwd });
2452
2973
  return { sessions };
2453
2974
  });
2454
2975
  app.post("/v1/sessions", async (request, reply) => {
@@ -2470,6 +2991,22 @@ function registerSessionRoutes(app, manager, defaults) {
2470
2991
  reply.code(500).send({ error: err.message });
2471
2992
  }
2472
2993
  });
2994
+ app.post("/v1/sessions/:id/kill", async (request, reply) => {
2995
+ const raw = request.params.id;
2996
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
2997
+ const session = manager.get(id);
2998
+ if (session) {
2999
+ await session.close({ deleteRecord: false });
3000
+ reply.code(204).send();
3001
+ return;
3002
+ }
3003
+ const exists = await manager.hasRecord(id);
3004
+ if (!exists) {
3005
+ reply.code(404).send({ error: "session not found" });
3006
+ return;
3007
+ }
3008
+ reply.code(204).send();
3009
+ });
2473
3010
  app.delete("/v1/sessions/:id", async (request, reply) => {
2474
3011
  const raw = request.params.id;
2475
3012
  const id = await manager.resolveCanonicalId(raw) ?? raw;
@@ -2486,6 +3023,50 @@ function registerSessionRoutes(app, manager, defaults) {
2486
3023
  }
2487
3024
  reply.code(204).send();
2488
3025
  });
3026
+ app.get("/v1/sessions/:id/history", async (request, reply) => {
3027
+ const raw = request.params.id;
3028
+ const query = request.query;
3029
+ const follow = query?.follow === "1" || query?.follow === "true";
3030
+ const id = await manager.resolveCanonicalId(raw) ?? raw;
3031
+ const live = manager.get(id);
3032
+ let snapshot;
3033
+ let unsubscribe;
3034
+ if (live) {
3035
+ snapshot = live.getHistorySnapshot();
3036
+ if (follow) {
3037
+ unsubscribe = live.onBroadcast((entry) => {
3038
+ if (reply.raw.writableEnded) {
3039
+ return;
3040
+ }
3041
+ reply.raw.write(JSON.stringify(entry) + "\n");
3042
+ });
3043
+ }
3044
+ } else {
3045
+ const cold = await manager.getHistory(id);
3046
+ if (cold === void 0) {
3047
+ reply.code(404).send({ error: "session not found" });
3048
+ return reply;
3049
+ }
3050
+ snapshot = cold;
3051
+ }
3052
+ reply.raw.setHeader("Content-Type", "application/x-ndjson");
3053
+ reply.raw.setHeader("Cache-Control", "no-cache");
3054
+ reply.raw.statusCode = 200;
3055
+ for (const entry of snapshot ?? []) {
3056
+ reply.raw.write(JSON.stringify(entry) + "\n");
3057
+ }
3058
+ if (!unsubscribe) {
3059
+ reply.raw.end();
3060
+ return reply;
3061
+ }
3062
+ request.raw.on("close", () => {
3063
+ unsubscribe?.();
3064
+ if (!reply.raw.writableEnded) {
3065
+ reply.raw.end();
3066
+ }
3067
+ });
3068
+ return reply;
3069
+ });
2489
3070
  }
2490
3071
 
2491
3072
  // src/daemon/routes/agents.ts
@@ -2633,6 +3214,16 @@ function parseRegisterBody(body) {
2633
3214
  };
2634
3215
  }
2635
3216
 
3217
+ // src/daemon/routes/config.ts
3218
+ function registerConfigRoutes(app, defaults) {
3219
+ app.get("/v1/config", async () => {
3220
+ return {
3221
+ defaultAgent: defaults.defaultAgent,
3222
+ defaultCwd: defaults.defaultCwd
3223
+ };
3224
+ });
3225
+ }
3226
+
2636
3227
  // src/daemon/acp-ws.ts
2637
3228
  import { nanoid as nanoid2 } from "nanoid";
2638
3229
 
@@ -2946,6 +3537,19 @@ function buildResponseMeta(session) {
2946
3537
  if (session.agentArgs && session.agentArgs.length > 0) {
2947
3538
  ours.agentArgs = session.agentArgs;
2948
3539
  }
3540
+ if (session.currentModel !== void 0) {
3541
+ ours.currentModel = session.currentModel;
3542
+ }
3543
+ if (session.currentMode !== void 0) {
3544
+ ours.currentMode = session.currentMode;
3545
+ }
3546
+ const commands = session.mergedAvailableCommands();
3547
+ if (commands.length > 0) {
3548
+ ours.availableCommands = commands;
3549
+ }
3550
+ if (session.turnStartedAt !== void 0) {
3551
+ ours.turnStartedAt = session.turnStartedAt;
3552
+ }
2949
3553
  return mergeMeta(session.agentMeta, ours);
2950
3554
  }
2951
3555
  function buildInitializeResult() {
@@ -3031,6 +3635,10 @@ async function startDaemon(config) {
3031
3635
  });
3032
3636
  registerAgentRoutes(app, registry);
3033
3637
  registerExtensionRoutes(app, extensions);
3638
+ registerConfigRoutes(app, {
3639
+ defaultAgent: config.defaultAgent,
3640
+ defaultCwd: config.defaultCwd
3641
+ });
3034
3642
  registerAcpWsEndpoint(app, {
3035
3643
  config,
3036
3644
  manager,
@@ -3066,7 +3674,7 @@ async function startDaemon(config) {
3066
3674
  await manager.closeAll();
3067
3675
  await app.close();
3068
3676
  try {
3069
- fs5.unlinkSync(paths.pidFile());
3677
+ fs7.unlinkSync(paths.pidFile());
3070
3678
  } catch {
3071
3679
  }
3072
3680
  try {