@hydra-acp/cli 0.1.39 → 0.1.41

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/README.md CHANGED
@@ -187,7 +187,7 @@ hydra-acp session
187
187
 
188
188
  # 6. Attach a second client to an existing session.
189
189
  # Bare invocation auto-detects: TUI in a terminal, ACP shim when piped.
190
- hydra-acp --session-id hydra_session_abc123
190
+ hydra-acp --session hydra_session_abc123
191
191
  ```
192
192
 
193
193
  ## CLI
@@ -198,34 +198,55 @@ hydra-acp shim # explicit shim mode (forces shim re
198
198
  hydra-acp tui # explicit terminal-UI mode
199
199
  hydra-acp launch <agent> # launcher mode: shim that forces the
200
200
  # daemon to spawn <agent> on session/new
201
- hydra-acp --session-id <id> # attach to existing session
201
+ hydra-acp cat [-p <prompt>] [--detach] # pipe-friendly headless mode: feeds stdin
202
+ # to a session as prompts and streams the
203
+ # agent's reply to stdout
204
+ hydra-acp --session <id-or-url> # attach to existing session
202
205
  # (TUI in a TTY, shim otherwise)
206
+ hydra-acp --reattach # pick the most-recent session for cwd
207
+ hydra-acp --new # force a fresh session
208
+ hydra-acp --readonly # open a session as a transcript viewer (with --session)
203
209
 
204
210
  hydra-acp init # generate the service token
211
+
212
+ hydra-acp daemon [status] # output status of daemon
205
213
  hydra-acp daemon start [--foreground] # detached by default; --foreground to attach
206
- hydra-acp daemon stop
207
- hydra-acp daemon status
214
+ hydra-acp daemon stop # stop running daemon
215
+ hydra-acp daemon restart # stop then start the daemon
216
+ hydra-acp daemon logs [-f] [-n N] # tail (default 50) or follow the daemon log
208
217
 
209
- hydra-acp session # list sessions
210
- hydra-acp session kill <id> # close a live session (keeps the on-disk record so it can be resurrected)
211
- hydra-acp session remove <id> # remove a session entirely (live or cold)
218
+ hydra-acp session [list ] # list sessions
219
+ hydra-acp session kill <id> # close a live session (keeps the on-disk record so it can be resurrected)
220
+ hydra-acp session remove <id> # remove a session entirely (live or cold)
212
221
  hydra-acp session export <id> [--out <file>|.]
213
222
  # write a session bundle (meta + history) to <file>,
214
223
  # to a default-named file when --out=., or to stdout
215
- hydra-acp session import <file>|- [--replace]
224
+ hydra-acp session transcript <id>|<file> [--out <file>|.]
225
+ # render a session (id via daemon, or a local .hydra
226
+ # bundle) as a markdown transcript
227
+ hydra-acp session import <file>|- [--replace] [--cwd <path>] [--info]
216
228
  # import a bundle from <file> or stdin (-);
217
- # --replace overwrites an existing lineage match
229
+ # --replace overwrites a lineage match (kills it
230
+ # if live); --cwd overrides the bundle's recorded
231
+ # working directory; --info prints the bundle's
232
+ # meta without importing
218
233
 
219
- hydra-acp extension # list configured extensions and live state
234
+ hydra-acp extension [list] # list configured extensions and live state
220
235
  hydra-acp extension add <name> # add to config (--command, --args, --env, --disabled)
221
236
  hydra-acp extension remove <name> # remove from config
222
237
  hydra-acp extension start|stop|restart <n> # lifecycle on a running extension
223
238
  hydra-acp extension logs <name> [-f] [-n] # tail (default 50) or follow an extension's log
224
239
 
225
- hydra-acp agent # list agents in the registry
240
+ hydra-acp agent [list] # list agents in the registry
226
241
  hydra-acp agent install <id> # pre-install an agent (else lazy on first use)
242
+ hydra-acp agent refresh # force a registry re-fetch
243
+ hydra-acp agent sync <id> # spawn <id> just long enough to ACP session/list it,
244
+ # then persist any sessions it remembers as cold rows
245
+ # (lets you bring in pre-existing agent sessions)
227
246
 
228
- hydra-acp config # print resolved config path/values
247
+ hydra-acp auth # list active session tokens
248
+ hydra-acp auth password [--force] # set the daemon's master password
249
+ hydra-acp auth revoke <id> # revoke a session token
229
250
  ```
230
251
 
231
252
  A bare invocation (`hydra-acp` with no subcommand) auto-dispatches based on whether stdout is a TTY: a real terminal launches the TUI, a piped stdio (the editor-spawned case) drops into shim mode. Pass `shim` or `tui` explicitly to force one or the other. Editors should configure `hydra-acp shim` so the choice is unambiguous regardless of how the editor wires stdio.
@@ -317,11 +338,11 @@ Every config-knob flag has an `HYDRA_ACP_FOO_BAR` env-var equivalent. Flag wins
317
338
  | `--name` | `HYDRA_ACP_NAME` |
318
339
  | `--agent` | `HYDRA_ACP_AGENT` |
319
340
  | `--model` | `HYDRA_ACP_MODEL` |
320
- | `--session-id` | `HYDRA_ACP_SESSION_ID` |
341
+ | `--session` | `HYDRA_ACP_SESSION` |
321
342
 
322
343
  `--model` is a one-shot override for the per-agent `defaultModels` entry in `~/.hydra-acp/config.json`. It only applies at fresh session creation — resurrect and `/hydra agent` switch ignore it (resurrected sessions stay on whatever model they were last using).
323
344
 
324
- Action commands (`init`, `daemon`, `sessions`, `--help`, `--version`, `--rotate-token`) are not config knobs and are flag-only.
345
+ Action commands (`init`, `daemon`, `session`, `extension`, `agent`, `auth`, `cat`, `--help`, `--version`, `--rotate-token`) are not config knobs and are flag-only.
325
346
 
326
347
  ### Registry id resolution
327
348
 
package/dist/cli.js CHANGED
@@ -91,6 +91,11 @@ var init_paths = __esm({
91
91
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
92
92
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
93
93
  tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
94
+ // Cross-session prompt history. Up-arrow / ^R fall through to this
95
+ // after the per-session list is exhausted. JSONL, one entry per
96
+ // line, append-only so concurrent TUIs don't lose each other's
97
+ // writes.
98
+ globalTuiHistoryFile: () => path.join(hydraHome(), "prompt-history"),
94
99
  tuiLogFile: () => path.join(hydraHome(), "tui.log")
95
100
  };
96
101
  }
@@ -3832,7 +3837,7 @@ function parseHistory(text) {
3832
3837
  }
3833
3838
  return out;
3834
3839
  }
3835
- function appendEntry(history, entry) {
3840
+ function appendEntry(history, entry, cap = HISTORY_CAP) {
3836
3841
  const trimmed = entry.replace(/\n+$/, "");
3837
3842
  if (trimmed.length === 0) {
3838
3843
  return history;
@@ -3841,8 +3846,8 @@ function appendEntry(history, entry) {
3841
3846
  return history;
3842
3847
  }
3843
3848
  const out = history.concat(trimmed);
3844
- if (out.length > HISTORY_CAP) {
3845
- return out.slice(out.length - HISTORY_CAP);
3849
+ if (out.length > cap) {
3850
+ return out.slice(out.length - cap);
3846
3851
  }
3847
3852
  return out;
3848
3853
  }
@@ -3851,11 +3856,30 @@ async function saveHistory(file, history) {
3851
3856
  const lines = history.map((entry) => JSON.stringify(entry));
3852
3857
  await fs10.writeFile(file, lines.length > 0 ? lines.join("\n") + "\n" : "");
3853
3858
  }
3854
- var HISTORY_CAP;
3859
+ async function appendHistoryLine(file, entry) {
3860
+ const trimmed = entry.replace(/\n+$/, "");
3861
+ if (trimmed.length === 0) {
3862
+ return;
3863
+ }
3864
+ await fs10.mkdir(path6.dirname(file), { recursive: true });
3865
+ await fs10.appendFile(file, JSON.stringify(trimmed) + "\n", {
3866
+ encoding: "utf8"
3867
+ });
3868
+ }
3869
+ function buildCombinedHistory(global, session) {
3870
+ if (session.length === 0) {
3871
+ return [...global];
3872
+ }
3873
+ const sessionSet = new Set(session);
3874
+ const filteredGlobal = global.filter((e) => !sessionSet.has(e));
3875
+ return [...filteredGlobal, ...session];
3876
+ }
3877
+ var HISTORY_CAP, GLOBAL_HISTORY_CAP;
3855
3878
  var init_history = __esm({
3856
3879
  "src/tui/history.ts"() {
3857
3880
  "use strict";
3858
3881
  HISTORY_CAP = 500;
3882
+ GLOBAL_HISTORY_CAP = 2e3;
3859
3883
  }
3860
3884
  });
3861
3885
 
@@ -6134,7 +6158,7 @@ function writeStyled(term, text, style) {
6134
6158
  term(text);
6135
6159
  return;
6136
6160
  case "thought":
6137
- term.dim.italic.noFormat(text);
6161
+ term.brightBlack.dim.noFormat(text);
6138
6162
  return;
6139
6163
  case "tool":
6140
6164
  term.brightBlue.noFormat(text);
@@ -7353,24 +7377,22 @@ uncaught: ${err.stack ?? err.message}
7353
7377
  this.writeProgressIndicator(this.banner.status === "busy" ? 3 : 0);
7354
7378
  this.syncedPartialRepaint(() => this.drawBanner());
7355
7379
  }
7356
- // Wrap a partial repaint (banner-only, indicator-only, etc.) in a
7380
+ // Wrap a partial repaint (banner-only, prompt-only, etc.) in a
7357
7381
  // synchronized-output bracket so the row swap is atomic on terminals
7358
- // that support DEC 2026, and hide the cursor across the paint so it
7359
- // doesn't visibly jump to the row being repainted before placeCursor
7360
- // snaps it back. placeCursor re-asserts visibility for normal /
7361
- // scrollback-search / readonly; modal modes only moveTo, so we
7362
- // re-show explicitly when one of them is active.
7382
+ // that support DEC 2026. Cursor movement (moveTo) is buffered inside
7383
+ // BSU/ESU, so the cursor appears at its final placeCursor position
7384
+ // without visibly visiting intermediate rows. We intentionally do NOT
7385
+ // hide the cursor here: ?25l/h (cursor visibility) is terminal *state*
7386
+ // applied immediately rather than buffered, so hiding inside a BSU/ESU
7387
+ // block causes a visible blink (cursor disappears → frame commits →
7388
+ // cursor reappears) on every banner tick — worse than any skitter.
7363
7389
  syncedPartialRepaint(paint) {
7364
7390
  if (!this.started) {
7365
7391
  return;
7366
7392
  }
7367
7393
  withSync(() => {
7368
- this.term.hideCursor();
7369
7394
  paint();
7370
7395
  this.placeCursor();
7371
- if (this.permissionPrompt || this.confirmPrompt || this.helpPrompt) {
7372
- this.term.hideCursor(false);
7373
- }
7374
7396
  });
7375
7397
  }
7376
7398
  currentModeId() {
@@ -8462,7 +8484,7 @@ uncaught: ${err.stack ?? err.message}
8462
8484
  const body = ` ${marker} ${i + 1}. ${truncate(opt.label, w - 8)}`;
8463
8485
  writeRow(`perm|o|${w}|${i}|${isSel ? "1" : "0"}|${opt.label}`, () => {
8464
8486
  if (isSel) {
8465
- this.term.brightCyan(body);
8487
+ this.term.brightYellow(body);
8466
8488
  } else {
8467
8489
  this.term.dim(body);
8468
8490
  }
@@ -8891,14 +8913,11 @@ async function pickSession(term, opts) {
8891
8913
  const composerBoxInner = () => Math.max(2, termWidth - 2);
8892
8914
  const paintComposerTopBorder = () => {
8893
8915
  const inner = composerBoxInner();
8894
- const focused = selectedIdx === 0;
8895
8916
  const titleFragment = `\u2500 ${composerTitle} `;
8896
8917
  const dashCount = Math.max(1, inner - titleFragment.length);
8897
8918
  const dashes = "\u2500".repeat(dashCount);
8898
- if (focused) {
8899
- term.brightCyan.noFormat("\u256D");
8900
- term.brightCyan.bold.noFormat(titleFragment);
8901
- term.brightCyan.noFormat(`${dashes}\u256E`);
8919
+ if (selectedIdx === 0) {
8920
+ term.brightBlue.noFormat(`\u256D${titleFragment}${dashes}\u256E`);
8902
8921
  } else {
8903
8922
  term.dim.noFormat(`\u256D${titleFragment}${dashes}\u256E`);
8904
8923
  }
@@ -8907,15 +8926,13 @@ async function pickSession(term, opts) {
8907
8926
  const inner = composerBoxInner();
8908
8927
  const dashes = "\u2500".repeat(inner);
8909
8928
  if (selectedIdx === 0) {
8910
- term.brightCyan.noFormat(`\u2570${dashes}\u256F`);
8929
+ term.brightBlue.noFormat(`\u2570${dashes}\u256F`);
8911
8930
  } else {
8912
8931
  term.dim.noFormat(`\u2570${dashes}\u256F`);
8913
8932
  }
8914
8933
  };
8915
8934
  const paintComposerBodyRow = (visualIdx) => {
8916
8935
  const inner = composerBoxInner();
8917
- const sideStyle = selectedIdx === 0 ? term.brightCyan : term.dim;
8918
- sideStyle.noFormat("\u2502");
8919
8936
  const vr = composerVisualRows[visualIdx];
8920
8937
  let slice = "";
8921
8938
  if (vr) {
@@ -8924,13 +8941,17 @@ async function pickSession(term, opts) {
8924
8941
  vr.endCol
8925
8942
  );
8926
8943
  }
8927
- term.noFormat(" ");
8928
- term.noFormat(slice);
8929
8944
  const padWidth = Math.max(0, inner - 1 - slice.length);
8930
- if (padWidth > 0) {
8931
- term.noFormat(" ".repeat(padWidth));
8945
+ const pad = " ".repeat(padWidth);
8946
+ if (selectedIdx === 0) {
8947
+ term.brightBlue.noFormat("\u2502");
8948
+ term.noFormat(` ${slice}${pad}`);
8949
+ term.brightBlue.noFormat("\u2502");
8950
+ } else {
8951
+ term.dim.noFormat("\u2502");
8952
+ term.noFormat(` ${slice}${pad}`);
8953
+ term.dim.noFormat("\u2502");
8932
8954
  }
8933
- sideStyle.noFormat("\u2502");
8934
8955
  };
8935
8956
  const paintSessionRow = (sessionIdx) => {
8936
8957
  const label = sessionLines[sessionIdx] ?? "";
@@ -11020,11 +11041,13 @@ function toolIconStyle(status) {
11020
11041
  }
11021
11042
  function formatPlan(event) {
11022
11043
  const stopped = event.stopped === true;
11044
+ const amended = event.amended === true;
11045
+ const stoppedStyle = amended ? "tool-status-cancelled" : "tool-status-fail";
11023
11046
  if (event.entries.length === 0) {
11024
11047
  return [
11025
11048
  {
11026
11049
  prefix: "\u25A3 ",
11027
- prefixStyle: stopped ? "tool-status-fail" : "plan",
11050
+ prefixStyle: stopped ? stoppedStyle : "plan",
11028
11051
  body: "(empty plan)",
11029
11052
  bodyStyle: "dim"
11030
11053
  }
@@ -11033,7 +11056,7 @@ function formatPlan(event) {
11033
11056
  const allComplete = event.entries.every(
11034
11057
  (e) => (e.status ?? "pending") === "completed"
11035
11058
  );
11036
- const headerStyle = allComplete ? "plan-done" : stopped ? "tool-status-fail" : "plan";
11059
+ const headerStyle = allComplete ? "plan-done" : stopped ? stoppedStyle : "plan";
11037
11060
  const lines = [
11038
11061
  {
11039
11062
  prefix: "\u25A3 ",
@@ -11657,9 +11680,35 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
11657
11680
  initialQueue = hydraMeta.queue;
11658
11681
  }
11659
11682
  const historyFile = paths.tuiHistoryFile(resolvedSessionId);
11683
+ const globalHistoryFile = paths.globalTuiHistoryFile();
11660
11684
  let history = await loadHistory(historyFile).catch(() => []);
11661
- const dispatcher = new InputDispatcher({ history });
11685
+ let globalHistory = await loadHistory(globalHistoryFile).catch(() => []);
11686
+ if (globalHistory.length > GLOBAL_HISTORY_CAP) {
11687
+ globalHistory = globalHistory.slice(globalHistory.length - GLOBAL_HISTORY_CAP);
11688
+ }
11689
+ const dispatcher = new InputDispatcher({
11690
+ history: buildCombinedHistory(globalHistory, history)
11691
+ });
11662
11692
  dispatcherRef = dispatcher;
11693
+ const recordHistoryEntry = (entry) => {
11694
+ const trimmed = entry.replace(/\n+$/, "");
11695
+ if (trimmed.length === 0) {
11696
+ return;
11697
+ }
11698
+ const nextSession = appendEntry(history, trimmed);
11699
+ const sessionChanged = nextSession !== history;
11700
+ history = nextSession;
11701
+ const nextGlobal = appendEntry(globalHistory, trimmed, GLOBAL_HISTORY_CAP);
11702
+ const globalChanged = nextGlobal !== globalHistory;
11703
+ globalHistory = nextGlobal;
11704
+ dispatcher.setHistory(buildCombinedHistory(globalHistory, history));
11705
+ if (sessionChanged) {
11706
+ saveHistory(historyFile, history).catch(() => void 0);
11707
+ }
11708
+ if (globalChanged) {
11709
+ appendHistoryLine(globalHistoryFile, trimmed).catch(() => void 0);
11710
+ }
11711
+ };
11663
11712
  if (pendingTurns > 0) {
11664
11713
  dispatcher.setTurnRunning(true);
11665
11714
  }
@@ -12036,8 +12085,7 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12036
12085
  }
12037
12086
  const pendingDraft = dispatcher.state().buffer.join("\n");
12038
12087
  if (pendingDraft.replace(/\s+$/, "").length > 0) {
12039
- history = appendEntry(history, pendingDraft);
12040
- dispatcher.setHistory(history);
12088
+ recordHistoryEntry(pendingDraft);
12041
12089
  }
12042
12090
  screen.pauseRepaint();
12043
12091
  screen.stop({ keepFullscreen: true });
@@ -12416,9 +12464,7 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12416
12464
  if (handleBuiltinCommand(text)) {
12417
12465
  return;
12418
12466
  }
12419
- history = appendEntry(history, text);
12420
- dispatcher.setHistory(history);
12421
- saveHistory(historyFile, history).catch(() => void 0);
12467
+ recordHistoryEntry(text);
12422
12468
  void runPrompt(text, attachments);
12423
12469
  };
12424
12470
  const amendPrompt = (text, attachments) => {
@@ -12426,9 +12472,7 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12426
12472
  if (handleBuiltinCommand(text)) {
12427
12473
  return;
12428
12474
  }
12429
- history = appendEntry(history, text);
12430
- dispatcher.setHistory(history);
12431
- saveHistory(historyFile, history).catch(() => void 0);
12475
+ recordHistoryEntry(text);
12432
12476
  if (!daemonSupportsAmend || currentHeadMessageId === void 0) {
12433
12477
  void runPrompt(text, attachments);
12434
12478
  return;
@@ -12788,16 +12832,18 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12788
12832
  const end = toolsBlockEndedAt ?? Date.now();
12789
12833
  const elapsed = end - toolsBlockStartedAt;
12790
12834
  const stoppedReason = !inProgress && toolsBlockStopReason !== null && toolsBlockStopReason !== "end_turn" ? toolsBlockStopReason : null;
12835
+ const isAmended = stoppedReason === "amended";
12836
+ const stoppedLabel = isAmended ? `amended \xB7 ${formatElapsed(elapsed)}` : `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}`;
12791
12837
  let summary;
12792
12838
  if (total === 0) {
12793
12839
  if (stoppedReason !== null) {
12794
- summary = `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}`;
12840
+ summary = stoppedLabel;
12795
12841
  } else {
12796
12842
  summary = inProgress ? `thinking \xB7 ${formatElapsed(elapsed)}` : `thought \xB7 ${formatElapsed(elapsed)}`;
12797
12843
  }
12798
12844
  } else {
12799
12845
  const noun = total === 1 ? "tool" : "tools";
12800
- const timing = stoppedReason !== null ? `stopped (${stoppedReason}) \xB7 ${formatElapsed(elapsed)}` : inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
12846
+ const timing = stoppedReason !== null ? stoppedLabel : inProgress ? formatElapsed(elapsed) : `took ${formatElapsed(elapsed)}`;
12801
12847
  const parts = [`${total} ${noun}`, timing];
12802
12848
  if (inProgress) {
12803
12849
  if (hidden > 0) {
@@ -12809,8 +12855,9 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12809
12855
  summary = parts.join(" \xB7 ");
12810
12856
  }
12811
12857
  const pureThinking = total === 0 && inProgress;
12812
- const frozenStyle = stoppedReason !== null ? "tool-status-fail" : "tool";
12813
- const frozenBodyStyle = stoppedReason !== null ? "tool-status-fail" : "dim";
12858
+ const stoppedHeaderStyle = isAmended ? "tool-status-cancelled" : "tool-status-fail";
12859
+ const frozenStyle = stoppedReason !== null ? stoppedHeaderStyle : "tool";
12860
+ const frozenBodyStyle = stoppedReason !== null ? stoppedHeaderStyle : "dim";
12814
12861
  const lines = [
12815
12862
  {
12816
12863
  prefix: "\u2699 ",
@@ -12986,7 +13033,11 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12986
13033
  effectiveStopReason = "error";
12987
13034
  }
12988
13035
  if (lastPlanEvent !== null && effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn") {
12989
- const lines = formatEvent({ ...lastPlanEvent, stopped: true });
13036
+ const lines = formatEvent({
13037
+ ...lastPlanEvent,
13038
+ stopped: true,
13039
+ amended: event.amended === true
13040
+ });
12990
13041
  if (lines.length > 0) {
12991
13042
  screen.upsertLines("plan", lines);
12992
13043
  }
@@ -12998,7 +13049,7 @@ async function runSession(term, config, target, opts, exitHint, viewPrefs) {
12998
13049
  toolsBlockStopReason = effectiveStopReason ?? null;
12999
13050
  renderToolsBlock();
13000
13051
  screen.clearKey("tools");
13001
- } else if (effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn") {
13052
+ } else if (effectiveStopReason !== void 0 && effectiveStopReason !== "end_turn" && effectiveStopReason !== "amended") {
13002
13053
  screen.appendLines([
13003
13054
  {
13004
13055
  prefix: "\u26A0 ",
@@ -14694,6 +14745,12 @@ var SessionRecord = z4.object({
14694
14745
  agentCommands: z4.array(PersistedAgentCommand).optional(),
14695
14746
  agentModes: z4.array(PersistedAgentMode).optional(),
14696
14747
  agentModels: z4.array(PersistedAgentModel).optional(),
14748
+ // One-shot flag set when `hydra agent sync` mints a row from an
14749
+ // agent-side session/list entry: signals that the first resurrect
14750
+ // should *keep* the agent's session/load replay (instead of draining
14751
+ // it) so the local history.jsonl gets populated from the agent's
14752
+ // memory. Cleared after that first resurrect completes.
14753
+ pendingHistorySync: z4.boolean().optional(),
14697
14754
  createdAt: z4.string(),
14698
14755
  updatedAt: z4.string()
14699
14756
  });
@@ -14814,6 +14871,7 @@ function recordFromMemorySession(args) {
14814
14871
  agentCommands: args.agentCommands,
14815
14872
  agentModes: args.agentModes,
14816
14873
  agentModels: args.agentModels,
14874
+ pendingHistorySync: args.pendingHistorySync,
14817
14875
  createdAt: args.createdAt ?? now,
14818
14876
  updatedAt: args.updatedAt ?? now
14819
14877
  };
@@ -15119,7 +15177,13 @@ var SessionManager = class {
15119
15177
  await agent.kill().catch(() => void 0);
15120
15178
  return this.doResurrectFromImport(params);
15121
15179
  }
15122
- agent.connection.drainBuffered("session/update");
15180
+ if (params.pendingHistorySync === true) {
15181
+ void this.clearPendingHistorySync(params.hydraSessionId).catch(
15182
+ () => void 0
15183
+ );
15184
+ } else {
15185
+ agent.connection.drainBuffered("session/update");
15186
+ }
15123
15187
  const session = new Session({
15124
15188
  sessionId: params.hydraSessionId,
15125
15189
  cwd: params.cwd,
@@ -15209,6 +15273,133 @@ var SessionManager = class {
15209
15273
  }
15210
15274
  return os3.homedir();
15211
15275
  }
15276
+ // Pull every session the agent itself remembers (across all cwds) and
15277
+ // persist a cold hydra record for each one we don't already track.
15278
+ // Used by `hydra agent sync <id>` to surface sessions created outside
15279
+ // hydra — or by other tools — in `hydra session list` so the picker
15280
+ // can resurrect them. Spawns a throwaway agent process for the
15281
+ // initialize + session/list pair, then kills it. Records are minted
15282
+ // with pendingHistorySync:true so the first resurrect records the
15283
+ // agent's session/load replay into history.jsonl rather than dropping
15284
+ // it.
15285
+ async syncFromAgent(agentId) {
15286
+ const agentDef = await this.registry.getAgent(agentId);
15287
+ if (!agentDef) {
15288
+ const err = new Error(
15289
+ `agent ${agentId} not found in registry`
15290
+ );
15291
+ err.code = JsonRpcErrorCodes.AgentNotInstalled;
15292
+ throw err;
15293
+ }
15294
+ const plan = await planSpawn(agentDef, [], {
15295
+ npmRegistry: this.npmRegistry
15296
+ });
15297
+ const agent = this.spawner({
15298
+ agentId,
15299
+ cwd: os3.homedir(),
15300
+ plan
15301
+ });
15302
+ let initResult;
15303
+ try {
15304
+ initResult = await agent.connection.request(
15305
+ "initialize",
15306
+ {
15307
+ protocolVersion: ACP_PROTOCOL_VERSION,
15308
+ clientCapabilities: {},
15309
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
15310
+ }
15311
+ );
15312
+ } catch (err) {
15313
+ await agent.kill().catch(() => void 0);
15314
+ throw err;
15315
+ }
15316
+ const caps = initResult.agentCapabilities ?? {};
15317
+ if (caps.sessionCapabilities?.list === void 0) {
15318
+ await agent.kill().catch(() => void 0);
15319
+ throw new Error(
15320
+ `agent ${agentId} does not advertise sessionCapabilities.list; cannot sync`
15321
+ );
15322
+ }
15323
+ let entries;
15324
+ try {
15325
+ entries = await this.collectAgentSessions(agent);
15326
+ } catch (err) {
15327
+ await agent.kill().catch(() => void 0);
15328
+ throw err;
15329
+ }
15330
+ await agent.kill().catch(() => void 0);
15331
+ const existing = /* @__PURE__ */ new Set();
15332
+ for (const live of this.sessions.values()) {
15333
+ existing.add(`${live.agentId}::${live.upstreamSessionId}`);
15334
+ }
15335
+ const stored = await this.store.list().catch(() => []);
15336
+ for (const rec of stored) {
15337
+ existing.add(`${rec.agentId}::${rec.upstreamSessionId}`);
15338
+ }
15339
+ const synced = [];
15340
+ let skipped = 0;
15341
+ for (const entry of entries) {
15342
+ const dedupeKey = `${agentId}::${entry.sessionId}`;
15343
+ if (existing.has(dedupeKey)) {
15344
+ skipped += 1;
15345
+ continue;
15346
+ }
15347
+ existing.add(dedupeKey);
15348
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
15349
+ const now = (/* @__PURE__ */ new Date()).toISOString();
15350
+ const ts = entry.updatedAt ?? now;
15351
+ const recordArgs = {
15352
+ sessionId: newId,
15353
+ lineageId: generateLineageId(),
15354
+ upstreamSessionId: entry.sessionId,
15355
+ agentId,
15356
+ cwd: entry.cwd,
15357
+ pendingHistorySync: true,
15358
+ createdAt: ts,
15359
+ updatedAt: ts
15360
+ };
15361
+ if (entry.title !== void 0) {
15362
+ recordArgs.title = entry.title;
15363
+ }
15364
+ const record = recordFromMemorySession(recordArgs);
15365
+ await this.store.write(record);
15366
+ synced.push({ version: 1, ...record });
15367
+ }
15368
+ return { synced, skipped };
15369
+ }
15370
+ // Paginate the agent's session/list, threading nextCursor until the
15371
+ // agent stops returning one. Each entry the spec guarantees has
15372
+ // { sessionId, cwd }; title and updatedAt are optional.
15373
+ async collectAgentSessions(agent) {
15374
+ const out = [];
15375
+ let cursor;
15376
+ for (let page = 0; page < 100; page += 1) {
15377
+ const params = {};
15378
+ if (cursor !== void 0) {
15379
+ params.cursor = cursor;
15380
+ }
15381
+ const result = await agent.connection.request("session/list", params);
15382
+ const rows = Array.isArray(result.sessions) ? result.sessions : [];
15383
+ for (const row of rows) {
15384
+ if (typeof row.sessionId !== "string" || typeof row.cwd !== "string") {
15385
+ continue;
15386
+ }
15387
+ const entry = { sessionId: row.sessionId, cwd: row.cwd };
15388
+ if (typeof row.title === "string") {
15389
+ entry.title = row.title;
15390
+ }
15391
+ if (typeof row.updatedAt === "string") {
15392
+ entry.updatedAt = row.updatedAt;
15393
+ }
15394
+ out.push(entry);
15395
+ }
15396
+ if (typeof result.nextCursor !== "string" || result.nextCursor.length === 0) {
15397
+ break;
15398
+ }
15399
+ cursor = result.nextCursor;
15400
+ }
15401
+ return out;
15402
+ }
15212
15403
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
15213
15404
  // → session/new. Shared by create() and the /hydra agent path so both
15214
15405
  // go through the same env / capabilities / error-handling.
@@ -15401,9 +15592,21 @@ var SessionManager = class {
15401
15592
  agentCommands: record.agentCommands,
15402
15593
  agentModes: record.agentModes,
15403
15594
  agentModels: record.agentModels,
15404
- createdAt: record.createdAt
15595
+ createdAt: record.createdAt,
15596
+ pendingHistorySync: record.pendingHistorySync
15405
15597
  };
15406
15598
  }
15599
+ async clearPendingHistorySync(sessionId) {
15600
+ await this.enqueueMetaWrite(sessionId, async () => {
15601
+ const record = await this.store.read(sessionId);
15602
+ if (!record || record.pendingHistorySync !== true) {
15603
+ return;
15604
+ }
15605
+ const next = { ...record };
15606
+ delete next.pendingHistorySync;
15607
+ await this.store.write(next);
15608
+ });
15609
+ }
15407
15610
  // Best-effort: peek at the persisted history's first prompt and use
15408
15611
  // its first line (capped to 200 chars) as a session title. Returns
15409
15612
  // undefined if no usable prompt is found or any I/O fails.
@@ -17452,7 +17655,8 @@ function registerSessionRoutes(app, manager, defaults) {
17452
17655
  }
17453
17656
 
17454
17657
  // src/daemon/routes/agents.ts
17455
- function registerAgentRoutes(app, registry) {
17658
+ init_types();
17659
+ function registerAgentRoutes(app, registry, manager, opts = {}) {
17456
17660
  app.get("/v1/agents", async () => {
17457
17661
  const doc = await registry.load();
17458
17662
  return {
@@ -17473,6 +17677,61 @@ function registerAgentRoutes(app, registry) {
17473
17677
  const doc = await registry.refresh();
17474
17678
  return { version: doc.version, agentCount: doc.agents.length };
17475
17679
  });
17680
+ app.post("/v1/agents/:id/install", async (request, reply) => {
17681
+ const id = request.params.id;
17682
+ const agent = await registry.getAgent(id);
17683
+ if (!agent) {
17684
+ reply.code(404).send({ error: `agent ${id} not found in registry` });
17685
+ return;
17686
+ }
17687
+ if (agent.distribution.uvx && !agent.distribution.npx && !agent.distribution.binary) {
17688
+ reply.send({
17689
+ agentId: agent.id,
17690
+ version: agent.version ?? "current",
17691
+ distribution: "uvx",
17692
+ installed: false,
17693
+ message: "uvx agents resolve on first run; nothing to pre-install."
17694
+ });
17695
+ return;
17696
+ }
17697
+ try {
17698
+ const plan = await planSpawn(agent, [], { npmRegistry: opts.npmRegistry });
17699
+ const distribution = agent.distribution.npx ? "npx" : agent.distribution.binary ? "binary" : "unknown";
17700
+ reply.send({
17701
+ agentId: agent.id,
17702
+ version: plan.version,
17703
+ distribution,
17704
+ installed: true,
17705
+ command: plan.command
17706
+ });
17707
+ } catch (err) {
17708
+ reply.code(500).send({ error: err.message });
17709
+ }
17710
+ });
17711
+ app.post("/v1/agents/:id/sync", async (request, reply) => {
17712
+ const agentId = request.params.id;
17713
+ try {
17714
+ const { synced, skipped } = await manager.syncFromAgent(agentId);
17715
+ return {
17716
+ synced: synced.map((r) => ({
17717
+ sessionId: r.sessionId,
17718
+ upstreamSessionId: r.upstreamSessionId,
17719
+ agentId: r.agentId,
17720
+ cwd: r.cwd,
17721
+ title: r.title,
17722
+ updatedAt: r.updatedAt
17723
+ })),
17724
+ skipped
17725
+ };
17726
+ } catch (err) {
17727
+ const e = err;
17728
+ if (e.code === JsonRpcErrorCodes.AgentNotInstalled) {
17729
+ reply.code(404).send({ error: e.message });
17730
+ return;
17731
+ }
17732
+ reply.code(409).send({ error: e.message });
17733
+ }
17734
+ });
17476
17735
  }
17477
17736
 
17478
17737
  // src/daemon/routes/health.ts
@@ -18543,7 +18802,7 @@ async function startDaemon(config, serviceToken) {
18543
18802
  agentId: config.defaultAgent,
18544
18803
  cwd: config.defaultCwd
18545
18804
  });
18546
- registerAgentRoutes(app, registry);
18805
+ registerAgentRoutes(app, registry, manager, { npmRegistry: config.npmRegistry });
18547
18806
  registerExtensionRoutes(app, extensions);
18548
18807
  registerConfigRoutes(app, {
18549
18808
  defaultAgent: config.defaultAgent,
@@ -19887,6 +20146,136 @@ async function runAgentsList() {
19887
20146
  Registry version: ${body.version}
19888
20147
  `);
19889
20148
  }
20149
+ async function runAgentsInstall(agentId) {
20150
+ if (!agentId) {
20151
+ process.stderr.write("Usage: hydra-acp agent install <agent-id>\n");
20152
+ process.exit(2);
20153
+ return;
20154
+ }
20155
+ const config = await loadConfig();
20156
+ const serviceToken = await loadServiceToken();
20157
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
20158
+ process.stdout.write(`Installing ${agentId}\u2026
20159
+ `);
20160
+ let body;
20161
+ try {
20162
+ const r = await fetch(
20163
+ `${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/install`,
20164
+ {
20165
+ method: "POST",
20166
+ headers: { Authorization: `Bearer ${serviceToken}` }
20167
+ }
20168
+ );
20169
+ if (!r.ok) {
20170
+ let detail = `HTTP ${r.status}`;
20171
+ try {
20172
+ const j = await r.json();
20173
+ if (j.error) {
20174
+ detail = j.error;
20175
+ }
20176
+ } catch {
20177
+ }
20178
+ process.stderr.write(`hydra agent install ${agentId}: ${detail}
20179
+ `);
20180
+ process.exit(1);
20181
+ }
20182
+ body = await r.json();
20183
+ } catch (err) {
20184
+ process.stderr.write(
20185
+ `Could not reach daemon at ${baseUrl}: ${err.message}
20186
+ `
20187
+ );
20188
+ process.exit(1);
20189
+ return;
20190
+ }
20191
+ if (!body.installed) {
20192
+ process.stdout.write(
20193
+ `${body.agentId} (${body.version}, ${body.distribution}): ${body.message ?? "nothing to install"}
20194
+ `
20195
+ );
20196
+ return;
20197
+ }
20198
+ process.stdout.write(
20199
+ `Installed ${body.agentId} (${body.version}, ${body.distribution})
20200
+ `
20201
+ );
20202
+ if (body.command) {
20203
+ process.stdout.write(` \u2192 ${body.command}
20204
+ `);
20205
+ }
20206
+ }
20207
+ async function runAgentsSync(agentId) {
20208
+ if (!agentId) {
20209
+ process.stderr.write("Usage: hydra-acp agent sync <agent-id>\n");
20210
+ process.exit(2);
20211
+ return;
20212
+ }
20213
+ const config = await loadConfig();
20214
+ const serviceToken = await loadServiceToken();
20215
+ const baseUrl = httpBase(config.daemon.host, config.daemon.port, !!config.daemon.tls);
20216
+ let body;
20217
+ try {
20218
+ const r = await fetch(`${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/sync`, {
20219
+ method: "POST",
20220
+ headers: { Authorization: `Bearer ${serviceToken}` }
20221
+ });
20222
+ if (!r.ok) {
20223
+ let detail = `HTTP ${r.status}`;
20224
+ try {
20225
+ const j = await r.json();
20226
+ if (j.error) {
20227
+ detail = j.error;
20228
+ }
20229
+ } catch {
20230
+ }
20231
+ process.stderr.write(`hydra agent sync ${agentId}: ${detail}
20232
+ `);
20233
+ process.exit(1);
20234
+ }
20235
+ body = await r.json();
20236
+ } catch (err) {
20237
+ process.stderr.write(
20238
+ `Could not reach daemon at ${baseUrl}: ${err.message}
20239
+ `
20240
+ );
20241
+ process.exit(1);
20242
+ return;
20243
+ }
20244
+ if (body.synced.length === 0) {
20245
+ process.stdout.write(
20246
+ `Nothing new to sync (${body.skipped} already tracked).
20247
+ `
20248
+ );
20249
+ return;
20250
+ }
20251
+ const rows = body.synced.map((s) => ({
20252
+ id: s.sessionId,
20253
+ upstream: s.upstreamSessionId,
20254
+ cwd: s.cwd,
20255
+ title: s.title ?? "-"
20256
+ }));
20257
+ const header = { id: "ID", upstream: "UPSTREAM", cwd: "CWD", title: "TITLE" };
20258
+ const widths = {
20259
+ id: maxLen3(header.id, rows.map((r) => r.id)),
20260
+ upstream: maxLen3(header.upstream, rows.map((r) => r.upstream)),
20261
+ cwd: maxLen3(header.cwd, rows.map((r) => r.cwd))
20262
+ };
20263
+ const fmt = (r) => [
20264
+ r.id.padEnd(widths.id),
20265
+ r.upstream.padEnd(widths.upstream),
20266
+ r.cwd.padEnd(widths.cwd),
20267
+ r.title
20268
+ ].join(" ");
20269
+ process.stdout.write(fmt(header) + "\n");
20270
+ for (const r of rows) {
20271
+ process.stdout.write(fmt(r) + "\n");
20272
+ }
20273
+ process.stdout.write(
20274
+ `
20275
+ Synced ${body.synced.length} session(s); skipped ${body.skipped} already tracked.
20276
+ `
20277
+ );
20278
+ }
19890
20279
  async function runAgentsRefresh() {
19891
20280
  const config = await loadConfig();
19892
20281
  const serviceToken = await loadServiceToken();
@@ -20943,7 +21332,11 @@ async function main() {
20943
21332
  const daemonIdx = argv.indexOf("daemon");
20944
21333
  const tail = argv.slice(daemonIdx + 1);
20945
21334
  const sub = tail[0];
20946
- if (sub === "start" || sub === void 0) {
21335
+ if (sub === void 0 || sub === "status") {
21336
+ await runDaemonStatus();
21337
+ return;
21338
+ }
21339
+ if (sub === "start") {
20947
21340
  await runDaemonStart(flags);
20948
21341
  return;
20949
21342
  }
@@ -20955,10 +21348,6 @@ async function main() {
20955
21348
  await runDaemonRestart();
20956
21349
  return;
20957
21350
  }
20958
- if (sub === "status") {
20959
- await runDaemonStatus();
20960
- return;
20961
- }
20962
21351
  if (sub === "logs") {
20963
21352
  await runDaemonLogs(tail.slice(1));
20964
21353
  return;
@@ -21071,6 +21460,14 @@ async function main() {
21071
21460
  await runAgentsRefresh();
21072
21461
  return;
21073
21462
  }
21463
+ if (sub === "install") {
21464
+ await runAgentsInstall(positional[2]);
21465
+ return;
21466
+ }
21467
+ if (sub === "sync") {
21468
+ await runAgentsSync(positional[2]);
21469
+ return;
21470
+ }
21074
21471
  process.stderr.write(`Unknown agent subcommand: ${sub}
21075
21472
  `);
21076
21473
  process.exit(2);
@@ -21227,8 +21624,9 @@ function printHelp() {
21227
21624
  " --readonly Open a session as a transcript viewer (requires --session).",
21228
21625
  " HYDRA_ACP_SESSION Env var equivalent of --session (flag wins).",
21229
21626
  " hydra-acp init [--rotate-token] Initialize ~/.hydra-acp/config.json",
21627
+ " hydra-acp daemon [status] Show daemon pid/version (default when no subcommand)",
21230
21628
  " hydra-acp daemon start [--foreground] Start daemon (detached by default; --foreground to attach)",
21231
- " hydra-acp daemon stop|restart|status",
21629
+ " hydra-acp daemon stop|restart",
21232
21630
  " hydra-acp daemon logs [-f] [-n N] Tail or follow the daemon log",
21233
21631
  " hydra-acp session [list] [--all] [--json] [--host=<host>]",
21234
21632
  " List sessions (live + 20 most-recent cold; --all for everything; --json emits JSON for scripts).",
@@ -21250,6 +21648,8 @@ function printHelp() {
21250
21648
  " hydra-acp extension logs <name> [-f] [-n N] Tail or follow an extension's log",
21251
21649
  " hydra-acp agent [list] List agents in the cached registry",
21252
21650
  " hydra-acp agent refresh Force a registry re-fetch",
21651
+ " hydra-acp agent install <id> Pre-install <id> from the registry (else lazy on first session)",
21652
+ " hydra-acp agent sync <id> Spawn <id> just long enough to ACP session/list it, then persist any sessions it remembers (across every cwd) as cold rows in `session list`",
21253
21653
  " hydra-acp auth password [--force] Set the daemon's master password",
21254
21654
  " hydra-acp auth [list] List active session tokens",
21255
21655
  " hydra-acp auth revoke <id> Revoke a session token",
package/dist/index.d.ts CHANGED
@@ -1984,6 +1984,7 @@ declare const SessionRecord: z.ZodObject<{
1984
1984
  name?: string | undefined;
1985
1985
  description?: string | undefined;
1986
1986
  }>, "many">>;
1987
+ pendingHistorySync: z.ZodOptional<z.ZodBoolean>;
1987
1988
  createdAt: z.ZodString;
1988
1989
  updatedAt: z.ZodString;
1989
1990
  }, "strip", z.ZodTypeAny, {
@@ -2022,6 +2023,7 @@ declare const SessionRecord: z.ZodObject<{
2022
2023
  name?: string | undefined;
2023
2024
  description?: string | undefined;
2024
2025
  }[] | undefined;
2026
+ pendingHistorySync?: boolean | undefined;
2025
2027
  }, {
2026
2028
  sessionId: string;
2027
2029
  version: 1;
@@ -2058,6 +2060,7 @@ declare const SessionRecord: z.ZodObject<{
2058
2060
  name?: string | undefined;
2059
2061
  description?: string | undefined;
2060
2062
  }[] | undefined;
2063
+ pendingHistorySync?: boolean | undefined;
2061
2064
  }>;
2062
2065
  type SessionRecord = z.infer<typeof SessionRecord>;
2063
2066
  declare class SessionStore {
@@ -2305,6 +2308,7 @@ interface ResurrectParams {
2305
2308
  agentModes?: AdvertisedMode[];
2306
2309
  agentModels?: AdvertisedModel[];
2307
2310
  createdAt?: string;
2311
+ pendingHistorySync?: boolean;
2308
2312
  }
2309
2313
  type AgentSpawner = (opts: AgentInstanceOptions) => AgentInstance;
2310
2314
  interface SessionManagerOptions {
@@ -2333,11 +2337,17 @@ declare class SessionManager {
2333
2337
  private doResurrect;
2334
2338
  private doResurrectFromImport;
2335
2339
  private resolveImportCwd;
2340
+ syncFromAgent(agentId: string): Promise<{
2341
+ synced: SessionRecord[];
2342
+ skipped: number;
2343
+ }>;
2344
+ private collectAgentSessions;
2336
2345
  private bootstrapAgent;
2337
2346
  private attachManagerHooks;
2338
2347
  getHistory(sessionId: string): Promise<HistoryEntry[] | undefined>;
2339
2348
  loadHistory(sessionId: string): Promise<HistoryEntry[]>;
2340
2349
  loadFromDisk(sessionId: string): Promise<ResurrectParams | undefined>;
2350
+ private clearPendingHistorySync;
2341
2351
  private deriveTitleFromHistory;
2342
2352
  get(sessionId: string): Session | undefined;
2343
2353
  activeAgentVersions(): Map<string, Set<string>>;
@@ -2459,6 +2469,7 @@ declare const paths: {
2459
2469
  extensionLogFile: (name: string) => string;
2460
2470
  extensionPidFile: (name: string) => string;
2461
2471
  tuiHistoryFile: (id: string) => string;
2472
+ globalTuiHistoryFile: () => string;
2462
2473
  tuiLogFile: () => string;
2463
2474
  };
2464
2475
 
package/dist/index.js CHANGED
@@ -87,6 +87,11 @@ var paths = {
87
87
  extensionLogFile: (name) => path.join(hydraHome(), "extensions", `${name}.log`),
88
88
  extensionPidFile: (name) => path.join(hydraHome(), "extensions", `${name}.pid`),
89
89
  tuiHistoryFile: (id) => path.join(hydraHome(), "sessions", id, "prompt-history"),
90
+ // Cross-session prompt history. Up-arrow / ^R fall through to this
91
+ // after the per-session list is exhausted. JSONL, one entry per
92
+ // line, append-only so concurrent TUIs don't lose each other's
93
+ // writes.
94
+ globalTuiHistoryFile: () => path.join(hydraHome(), "prompt-history"),
90
95
  tuiLogFile: () => path.join(hydraHome(), "tui.log")
91
96
  };
92
97
 
@@ -4325,6 +4330,12 @@ var SessionRecord = z4.object({
4325
4330
  agentCommands: z4.array(PersistedAgentCommand).optional(),
4326
4331
  agentModes: z4.array(PersistedAgentMode).optional(),
4327
4332
  agentModels: z4.array(PersistedAgentModel).optional(),
4333
+ // One-shot flag set when `hydra agent sync` mints a row from an
4334
+ // agent-side session/list entry: signals that the first resurrect
4335
+ // should *keep* the agent's session/load replay (instead of draining
4336
+ // it) so the local history.jsonl gets populated from the agent's
4337
+ // memory. Cleared after that first resurrect completes.
4338
+ pendingHistorySync: z4.boolean().optional(),
4328
4339
  createdAt: z4.string(),
4329
4340
  updatedAt: z4.string()
4330
4341
  });
@@ -4445,6 +4456,7 @@ function recordFromMemorySession(args) {
4445
4456
  agentCommands: args.agentCommands,
4446
4457
  agentModes: args.agentModes,
4447
4458
  agentModels: args.agentModels,
4459
+ pendingHistorySync: args.pendingHistorySync,
4448
4460
  createdAt: args.createdAt ?? now,
4449
4461
  updatedAt: args.updatedAt ?? now
4450
4462
  };
@@ -4780,7 +4792,13 @@ var SessionManager = class {
4780
4792
  await agent.kill().catch(() => void 0);
4781
4793
  return this.doResurrectFromImport(params);
4782
4794
  }
4783
- agent.connection.drainBuffered("session/update");
4795
+ if (params.pendingHistorySync === true) {
4796
+ void this.clearPendingHistorySync(params.hydraSessionId).catch(
4797
+ () => void 0
4798
+ );
4799
+ } else {
4800
+ agent.connection.drainBuffered("session/update");
4801
+ }
4784
4802
  const session = new Session({
4785
4803
  sessionId: params.hydraSessionId,
4786
4804
  cwd: params.cwd,
@@ -4870,6 +4888,133 @@ var SessionManager = class {
4870
4888
  }
4871
4889
  return os2.homedir();
4872
4890
  }
4891
+ // Pull every session the agent itself remembers (across all cwds) and
4892
+ // persist a cold hydra record for each one we don't already track.
4893
+ // Used by `hydra agent sync <id>` to surface sessions created outside
4894
+ // hydra — or by other tools — in `hydra session list` so the picker
4895
+ // can resurrect them. Spawns a throwaway agent process for the
4896
+ // initialize + session/list pair, then kills it. Records are minted
4897
+ // with pendingHistorySync:true so the first resurrect records the
4898
+ // agent's session/load replay into history.jsonl rather than dropping
4899
+ // it.
4900
+ async syncFromAgent(agentId) {
4901
+ const agentDef = await this.registry.getAgent(agentId);
4902
+ if (!agentDef) {
4903
+ const err = new Error(
4904
+ `agent ${agentId} not found in registry`
4905
+ );
4906
+ err.code = JsonRpcErrorCodes.AgentNotInstalled;
4907
+ throw err;
4908
+ }
4909
+ const plan = await planSpawn(agentDef, [], {
4910
+ npmRegistry: this.npmRegistry
4911
+ });
4912
+ const agent = this.spawner({
4913
+ agentId,
4914
+ cwd: os2.homedir(),
4915
+ plan
4916
+ });
4917
+ let initResult;
4918
+ try {
4919
+ initResult = await agent.connection.request(
4920
+ "initialize",
4921
+ {
4922
+ protocolVersion: ACP_PROTOCOL_VERSION,
4923
+ clientCapabilities: {},
4924
+ clientInfo: { name: "hydra", version: HYDRA_VERSION }
4925
+ }
4926
+ );
4927
+ } catch (err) {
4928
+ await agent.kill().catch(() => void 0);
4929
+ throw err;
4930
+ }
4931
+ const caps = initResult.agentCapabilities ?? {};
4932
+ if (caps.sessionCapabilities?.list === void 0) {
4933
+ await agent.kill().catch(() => void 0);
4934
+ throw new Error(
4935
+ `agent ${agentId} does not advertise sessionCapabilities.list; cannot sync`
4936
+ );
4937
+ }
4938
+ let entries;
4939
+ try {
4940
+ entries = await this.collectAgentSessions(agent);
4941
+ } catch (err) {
4942
+ await agent.kill().catch(() => void 0);
4943
+ throw err;
4944
+ }
4945
+ await agent.kill().catch(() => void 0);
4946
+ const existing = /* @__PURE__ */ new Set();
4947
+ for (const live of this.sessions.values()) {
4948
+ existing.add(`${live.agentId}::${live.upstreamSessionId}`);
4949
+ }
4950
+ const stored = await this.store.list().catch(() => []);
4951
+ for (const rec of stored) {
4952
+ existing.add(`${rec.agentId}::${rec.upstreamSessionId}`);
4953
+ }
4954
+ const synced = [];
4955
+ let skipped = 0;
4956
+ for (const entry of entries) {
4957
+ const dedupeKey = `${agentId}::${entry.sessionId}`;
4958
+ if (existing.has(dedupeKey)) {
4959
+ skipped += 1;
4960
+ continue;
4961
+ }
4962
+ existing.add(dedupeKey);
4963
+ const newId = `${HYDRA_SESSION_PREFIX}${generateRawSessionId()}`;
4964
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4965
+ const ts = entry.updatedAt ?? now;
4966
+ const recordArgs = {
4967
+ sessionId: newId,
4968
+ lineageId: generateLineageId(),
4969
+ upstreamSessionId: entry.sessionId,
4970
+ agentId,
4971
+ cwd: entry.cwd,
4972
+ pendingHistorySync: true,
4973
+ createdAt: ts,
4974
+ updatedAt: ts
4975
+ };
4976
+ if (entry.title !== void 0) {
4977
+ recordArgs.title = entry.title;
4978
+ }
4979
+ const record = recordFromMemorySession(recordArgs);
4980
+ await this.store.write(record);
4981
+ synced.push({ version: 1, ...record });
4982
+ }
4983
+ return { synced, skipped };
4984
+ }
4985
+ // Paginate the agent's session/list, threading nextCursor until the
4986
+ // agent stops returning one. Each entry the spec guarantees has
4987
+ // { sessionId, cwd }; title and updatedAt are optional.
4988
+ async collectAgentSessions(agent) {
4989
+ const out = [];
4990
+ let cursor;
4991
+ for (let page = 0; page < 100; page += 1) {
4992
+ const params = {};
4993
+ if (cursor !== void 0) {
4994
+ params.cursor = cursor;
4995
+ }
4996
+ const result = await agent.connection.request("session/list", params);
4997
+ const rows = Array.isArray(result.sessions) ? result.sessions : [];
4998
+ for (const row of rows) {
4999
+ if (typeof row.sessionId !== "string" || typeof row.cwd !== "string") {
5000
+ continue;
5001
+ }
5002
+ const entry = { sessionId: row.sessionId, cwd: row.cwd };
5003
+ if (typeof row.title === "string") {
5004
+ entry.title = row.title;
5005
+ }
5006
+ if (typeof row.updatedAt === "string") {
5007
+ entry.updatedAt = row.updatedAt;
5008
+ }
5009
+ out.push(entry);
5010
+ }
5011
+ if (typeof result.nextCursor !== "string" || result.nextCursor.length === 0) {
5012
+ break;
5013
+ }
5014
+ cursor = result.nextCursor;
5015
+ }
5016
+ return out;
5017
+ }
4873
5018
  // Bootstrap a fresh agent process: registry resolve → spawn → initialize
4874
5019
  // → session/new. Shared by create() and the /hydra agent path so both
4875
5020
  // go through the same env / capabilities / error-handling.
@@ -5062,9 +5207,21 @@ var SessionManager = class {
5062
5207
  agentCommands: record.agentCommands,
5063
5208
  agentModes: record.agentModes,
5064
5209
  agentModels: record.agentModels,
5065
- createdAt: record.createdAt
5210
+ createdAt: record.createdAt,
5211
+ pendingHistorySync: record.pendingHistorySync
5066
5212
  };
5067
5213
  }
5214
+ async clearPendingHistorySync(sessionId) {
5215
+ await this.enqueueMetaWrite(sessionId, async () => {
5216
+ const record = await this.store.read(sessionId);
5217
+ if (!record || record.pendingHistorySync !== true) {
5218
+ return;
5219
+ }
5220
+ const next = { ...record };
5221
+ delete next.pendingHistorySync;
5222
+ await this.store.write(next);
5223
+ });
5224
+ }
5068
5225
  // Best-effort: peek at the persisted history's first prompt and use
5069
5226
  // its first line (capped to 200 chars) as a session title. Returns
5070
5227
  // undefined if no usable prompt is found or any I/O fails.
@@ -7461,7 +7618,7 @@ function registerSessionRoutes(app, manager, defaults) {
7461
7618
  }
7462
7619
 
7463
7620
  // src/daemon/routes/agents.ts
7464
- function registerAgentRoutes(app, registry) {
7621
+ function registerAgentRoutes(app, registry, manager, opts = {}) {
7465
7622
  app.get("/v1/agents", async () => {
7466
7623
  const doc = await registry.load();
7467
7624
  return {
@@ -7482,6 +7639,61 @@ function registerAgentRoutes(app, registry) {
7482
7639
  const doc = await registry.refresh();
7483
7640
  return { version: doc.version, agentCount: doc.agents.length };
7484
7641
  });
7642
+ app.post("/v1/agents/:id/install", async (request, reply) => {
7643
+ const id = request.params.id;
7644
+ const agent = await registry.getAgent(id);
7645
+ if (!agent) {
7646
+ reply.code(404).send({ error: `agent ${id} not found in registry` });
7647
+ return;
7648
+ }
7649
+ if (agent.distribution.uvx && !agent.distribution.npx && !agent.distribution.binary) {
7650
+ reply.send({
7651
+ agentId: agent.id,
7652
+ version: agent.version ?? "current",
7653
+ distribution: "uvx",
7654
+ installed: false,
7655
+ message: "uvx agents resolve on first run; nothing to pre-install."
7656
+ });
7657
+ return;
7658
+ }
7659
+ try {
7660
+ const plan = await planSpawn(agent, [], { npmRegistry: opts.npmRegistry });
7661
+ const distribution = agent.distribution.npx ? "npx" : agent.distribution.binary ? "binary" : "unknown";
7662
+ reply.send({
7663
+ agentId: agent.id,
7664
+ version: plan.version,
7665
+ distribution,
7666
+ installed: true,
7667
+ command: plan.command
7668
+ });
7669
+ } catch (err) {
7670
+ reply.code(500).send({ error: err.message });
7671
+ }
7672
+ });
7673
+ app.post("/v1/agents/:id/sync", async (request, reply) => {
7674
+ const agentId = request.params.id;
7675
+ try {
7676
+ const { synced, skipped } = await manager.syncFromAgent(agentId);
7677
+ return {
7678
+ synced: synced.map((r) => ({
7679
+ sessionId: r.sessionId,
7680
+ upstreamSessionId: r.upstreamSessionId,
7681
+ agentId: r.agentId,
7682
+ cwd: r.cwd,
7683
+ title: r.title,
7684
+ updatedAt: r.updatedAt
7685
+ })),
7686
+ skipped
7687
+ };
7688
+ } catch (err) {
7689
+ const e = err;
7690
+ if (e.code === JsonRpcErrorCodes.AgentNotInstalled) {
7691
+ reply.code(404).send({ error: e.message });
7692
+ return;
7693
+ }
7694
+ reply.code(409).send({ error: e.message });
7695
+ }
7696
+ });
7485
7697
  }
7486
7698
 
7487
7699
  // src/daemon/routes/health.ts
@@ -8597,7 +8809,7 @@ async function startDaemon(config, serviceToken) {
8597
8809
  agentId: config.defaultAgent,
8598
8810
  cwd: config.defaultCwd
8599
8811
  });
8600
- registerAgentRoutes(app, registry);
8812
+ registerAgentRoutes(app, registry, manager, { npmRegistry: config.npmRegistry });
8601
8813
  registerExtensionRoutes(app, extensions);
8602
8814
  registerConfigRoutes(app, {
8603
8815
  defaultAgent: config.defaultAgent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Multi-client ACP session daemon: spawn agents, attach over WSS, multiplex sessions across editors.",
5
5
  "license": "MIT",
6
6
  "type": "module",