@hydra-acp/cli 0.1.29 → 0.1.31

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
@@ -259,6 +259,20 @@ hydra-acp --name "$BUFFER_NAME" launch claude-acp
259
259
 
260
260
  After the first user prompt lands, hydra automatically replaces the label with the first line of that prompt (truncated, ≤80 chars) and emits a `session_info_update` so every attached client (TUI, slack, browser) refreshes its header. Agents that emit their own `session_info_update` override that — last write wins.
261
261
 
262
+ ### Read-only viewer (`--readonly`)
263
+
264
+ Sometimes you want to scroll through a session's transcript — usually one imported from another machine — without spawning the underlying agent. Pass `--readonly` to `tui` to attach in view-only mode:
265
+
266
+ ```text
267
+ hydra-acp tui --resume <id> --readonly
268
+ ```
269
+
270
+ The daemon enforces the contract: a `readonly: true` attach to a *cold* session takes a viewer path that streams history straight from disk — no `manager.resurrect`, no agent process. Any mutating JSON-RPC method (`session/prompt`, `session/cancel`, `session/set_model`, and the `hydra-acp/*` prompt-mutation methods) sent from a read-only connection is refused with `-32011 PermissionDenied`. History replay and `session/update` deliveries are unchanged, so the existing scrollback search (`^R` when scrolled back) works over the full transcript.
271
+
272
+ The TUI suppresses the composer entirely — those rows go to scrollback so you see more of the conversation. The window title is suffixed `[VIEW ONLY]` so the mode is unambiguous. Prompt-shaped keys (Enter, Shift+Enter, Shift+Tab) are inert; `^P`, `^G`, `^L`, `^R`, `PgUp/PgDn`, `^C`, `^D` work as usual.
273
+
274
+ From inside the TUI's session picker, **`v`** on a selected row enters view-only mode for that session. Enter still attaches normally. The mode is per-session: `^P` → pick another with Enter drops out of read-only; `v` re-enters it.
275
+
262
276
  ### Slash commands (typed in any composer)
263
277
 
264
278
  Slash commands of the form `/hydra <verb> [args]` are intercepted by hydra before the prompt reaches the agent. They never appear in the conversation log; the only client-visible signal is the notification(s) the verb implies.
package/dist/cli.js CHANGED
@@ -584,6 +584,12 @@ var init_types = __esm({
584
584
  name: z3.string(),
585
585
  version: z3.string().optional()
586
586
  }).optional(),
587
+ // When true, the connection observes the session but cannot mutate
588
+ // it: state-changing methods (session/prompt, session/cancel,
589
+ // session/set_model, etc.) are rejected with -32011, and attaching
590
+ // to a cold session does not resurrect or spawn an agent — just
591
+ // streams history from disk. Used by the TUI's view-only mode.
592
+ readonly: z3.boolean().optional(),
587
593
  _meta: z3.record(z3.unknown()).optional()
588
594
  });
589
595
  HYDRA_META_KEY = "hydra-acp";
@@ -5413,6 +5419,15 @@ var init_discovery = __esm({
5413
5419
 
5414
5420
  // src/tui/picker.ts
5415
5421
  async function pickSession(term, opts) {
5422
+ process.stdout.write("\x1B[<u");
5423
+ process.stdout.write("\x1B[?2004l");
5424
+ process.stdout.write("\x1B[>4;0m");
5425
+ process.stdout.write("\x1B[>5;0m");
5426
+ process.stdout.write("\x1B[?1000l");
5427
+ process.stdout.write("\x1B[?1002l");
5428
+ process.stdout.write("\x1B[?1006l");
5429
+ process.stdout.write("\x1B[?1l");
5430
+ process.stdout.write("\x1B>");
5416
5431
  if (opts.sessions.length === 0) {
5417
5432
  return { kind: "new" };
5418
5433
  }
@@ -5947,6 +5962,23 @@ async function pickSession(term, opts) {
5947
5962
  void refresh(currentId);
5948
5963
  return;
5949
5964
  }
5965
+ if ((name === "v" || name === "V") && selectedIdx > 0) {
5966
+ const session = visible[selectedIdx - 1];
5967
+ if (!session) {
5968
+ return;
5969
+ }
5970
+ cleanup();
5971
+ const result = {
5972
+ kind: "attach",
5973
+ sessionId: session.sessionId,
5974
+ readonly: true
5975
+ };
5976
+ if (session.agentId !== void 0) {
5977
+ result.agentId = session.agentId;
5978
+ }
5979
+ resolve5(result);
5980
+ return;
5981
+ }
5950
5982
  if ((name === "k" || name === "K") && selectedIdx > 0) {
5951
5983
  const session = visible[selectedIdx - 1];
5952
5984
  if (!session) {
@@ -6134,6 +6166,7 @@ var init_picker = __esm({
6134
6166
  ["PgUp / PgDn", "page up / page down"],
6135
6167
  ["Home / End", "first / last"],
6136
6168
  ["Enter", "open selected session (or create new)"],
6169
+ ["v", "view-only (open transcript without spawning the agent)"],
6137
6170
  null,
6138
6171
  ["/", "search sessions"],
6139
6172
  ["o", "toggle cwd-only filter"],
@@ -6807,6 +6840,110 @@ function mapKeyName(name) {
6807
6840
  return null;
6808
6841
  }
6809
6842
  }
6843
+ function emergencyTerminalReset() {
6844
+ const seq = [
6845
+ "\x1B[?1000l",
6846
+ // mouse button reporting off
6847
+ "\x1B[?1002l",
6848
+ // mouse drag reporting off
6849
+ "\x1B[?1003l",
6850
+ // mouse any-motion reporting off
6851
+ "\x1B[?1006l",
6852
+ // SGR mouse mode off
6853
+ "\x1B[?1015l",
6854
+ // urxvt mouse mode off
6855
+ "\x1B[?2004l",
6856
+ // bracketed paste off
6857
+ "\x1B[>4;0m",
6858
+ // xterm modifyOtherKeys off
6859
+ "\x1B[>5;0m",
6860
+ // xterm formatOtherKeys off
6861
+ "\x1B[<u",
6862
+ // pop kitty keyboard stack
6863
+ "\x1B[?1l",
6864
+ // DECCKM off: arrows send CSI A/B/C/D not SS3 O A/B/C/D
6865
+ "\x1B>",
6866
+ // DECPAM off: numeric keypad mode
6867
+ "\x1B[?7h",
6868
+ // auto-wrap on
6869
+ "\x1B[?25h",
6870
+ // show cursor
6871
+ "\x1B]9;4;0\x07",
6872
+ // clear OSC 9;4 progress indicator
6873
+ "\x1B[?1049l"
6874
+ // leave alternate screen
6875
+ ].join("");
6876
+ try {
6877
+ process.stdout.write(seq);
6878
+ } catch {
6879
+ }
6880
+ }
6881
+ function mapCsiUToKeyName(code, mod) {
6882
+ const CTRL_LETTERS = {
6883
+ 97: "ctrl-a",
6884
+ 98: "ctrl-b",
6885
+ 99: "ctrl-c",
6886
+ 100: "ctrl-d",
6887
+ 101: "ctrl-e",
6888
+ 102: "ctrl-f",
6889
+ 103: "ctrl-g",
6890
+ 107: "ctrl-k",
6891
+ 108: "ctrl-l",
6892
+ 110: "ctrl-n",
6893
+ 111: "ctrl-o",
6894
+ 112: "ctrl-p",
6895
+ 114: "ctrl-r",
6896
+ 115: "ctrl-s",
6897
+ 116: "ctrl-t",
6898
+ 117: "ctrl-u",
6899
+ 118: "ctrl-v",
6900
+ 119: "ctrl-w",
6901
+ 121: "ctrl-y"
6902
+ };
6903
+ if (mod === 5) {
6904
+ return CTRL_LETTERS[code] ?? null;
6905
+ }
6906
+ if (code === 27) {
6907
+ return "escape";
6908
+ }
6909
+ if (code === 9) {
6910
+ if (mod === 2) {
6911
+ return "shift-tab";
6912
+ }
6913
+ if (mod === 1) {
6914
+ return "tab";
6915
+ }
6916
+ return null;
6917
+ }
6918
+ if (code === 13) {
6919
+ if (mod === 2) {
6920
+ return "shift-enter";
6921
+ }
6922
+ if (mod === 3) {
6923
+ return "alt-enter";
6924
+ }
6925
+ if (mod === 5) {
6926
+ return "ctrl-enter";
6927
+ }
6928
+ if (mod === 1) {
6929
+ return "enter";
6930
+ }
6931
+ return null;
6932
+ }
6933
+ if (code === 127 && mod === 1) {
6934
+ return "backspace";
6935
+ }
6936
+ if (mod === 3) {
6937
+ if (code === 98 || code === 66) {
6938
+ return "alt-b";
6939
+ }
6940
+ if (code === 102 || code === 70) {
6941
+ return "alt-f";
6942
+ }
6943
+ return null;
6944
+ }
6945
+ return null;
6946
+ }
6810
6947
  var SESSIONBAR_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_HELP_ROWS, MAX_COMPLETION_ROWS, MAX_CHIP_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, BARE_URL_RE, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
6811
6948
  var init_screen = __esm({
6812
6949
  "src/tui/screen.ts"() {
@@ -6935,11 +7072,25 @@ var init_screen = __esm({
6935
7072
  rawStdinHandler;
6936
7073
  mouseEnabled;
6937
7074
  progressIndicatorEnabled;
7075
+ // Listeners registered on process via installEmergencyCleanup so an
7076
+ // ungraceful exit (SIGTERM, SIGHUP, uncaughtException) still restores
7077
+ // mouse capture / alt-screen / kitty stack / cursor visibility — the
7078
+ // graceful stop() path isn't guaranteed to run in those cases and
7079
+ // would otherwise leave the host terminal wedged.
7080
+ emergencyCleanupInstalled = false;
7081
+ onProcessExit = null;
7082
+ onProcessSignal = null;
7083
+ onProcessUncaught = null;
6938
7084
  // Last OSC 9;4 state we wrote (3 = indeterminate, 0 = remove). Used to
6939
7085
  // suppress redundant writes when setBanner runs but `status` didn't
6940
7086
  // actually change, and to re-emit on start() if a picker round-trip
6941
7087
  // cleared the host terminal's indicator.
6942
7088
  lastProgressState = 0;
7089
+ // View-only mode. Set once at construction. When true, promptRows()
7090
+ // returns 0 (composer collapses, scrollback expands), drawPrompt()
7091
+ // bails before computing layout, and syncWindowTitle() appends
7092
+ // "[VIEW ONLY]" so the chrome makes the mode obvious.
7093
+ readonly;
6943
7094
  constructor(opts) {
6944
7095
  this.term = opts.term;
6945
7096
  this.dispatcher = opts.dispatcher;
@@ -6948,6 +7099,7 @@ var init_screen = __esm({
6948
7099
  this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
6949
7100
  this.mouseEnabled = opts.mouse ?? true;
6950
7101
  this.progressIndicatorEnabled = opts.progressIndicator ?? true;
7102
+ this.readonly = opts.readonly ?? false;
6951
7103
  this.resizeHandler = () => this.repaint();
6952
7104
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
6953
7105
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -6976,6 +7128,7 @@ var init_screen = __esm({
6976
7128
  }
6977
7129
  this.term.on("resize", this.resizeHandler);
6978
7130
  this.installBracketedPaste();
7131
+ this.installEmergencyCleanup();
6979
7132
  this.lastProgressState = 0;
6980
7133
  this.writeProgressIndicator(this.banner.status === "busy" ? 3 : 0);
6981
7134
  this.repaint();
@@ -6993,6 +7146,7 @@ var init_screen = __esm({
6993
7146
  this.throttledRepaintTimer = null;
6994
7147
  }
6995
7148
  this.uninstallBracketedPaste();
7149
+ this.uninstallEmergencyCleanup();
6996
7150
  this.term.off("key", this.keyHandler);
6997
7151
  if (this.mouseEnabled) {
6998
7152
  this.term.off("mouse", this.mouseHandler);
@@ -7028,6 +7182,8 @@ var init_screen = __esm({
7028
7182
  process.stdout.write("\x1B[>4;0m");
7029
7183
  process.stdout.write("\x1B[>5;0m");
7030
7184
  process.stdout.write("\x1B[<u");
7185
+ process.stdout.write("\x1B[?1l");
7186
+ process.stdout.write("\x1B>");
7031
7187
  const t = this.term;
7032
7188
  if (!t.stdin || this.terminalKitStdinHandler === null) {
7033
7189
  return;
@@ -7038,72 +7194,109 @@ var init_screen = __esm({
7038
7194
  this.pasteActive = false;
7039
7195
  this.pasteBuffer = "";
7040
7196
  }
7197
+ installEmergencyCleanup() {
7198
+ if (this.emergencyCleanupInstalled) {
7199
+ return;
7200
+ }
7201
+ this.emergencyCleanupInstalled = true;
7202
+ this.onProcessExit = () => emergencyTerminalReset();
7203
+ this.onProcessSignal = (sig) => {
7204
+ emergencyTerminalReset();
7205
+ process.off(sig, this.onProcessSignal);
7206
+ process.kill(process.pid, sig);
7207
+ };
7208
+ this.onProcessUncaught = (err) => {
7209
+ emergencyTerminalReset();
7210
+ process.stderr.write(`
7211
+ uncaught: ${err.stack ?? err.message}
7212
+ `);
7213
+ process.exit(1);
7214
+ };
7215
+ process.on("exit", this.onProcessExit);
7216
+ process.on("SIGTERM", this.onProcessSignal);
7217
+ process.on("SIGHUP", this.onProcessSignal);
7218
+ process.on("uncaughtException", this.onProcessUncaught);
7219
+ }
7220
+ uninstallEmergencyCleanup() {
7221
+ if (!this.emergencyCleanupInstalled) {
7222
+ return;
7223
+ }
7224
+ this.emergencyCleanupInstalled = false;
7225
+ if (this.onProcessExit) {
7226
+ process.off("exit", this.onProcessExit);
7227
+ this.onProcessExit = null;
7228
+ }
7229
+ if (this.onProcessSignal) {
7230
+ process.off("SIGTERM", this.onProcessSignal);
7231
+ process.off("SIGHUP", this.onProcessSignal);
7232
+ this.onProcessSignal = null;
7233
+ }
7234
+ if (this.onProcessUncaught) {
7235
+ process.off("uncaughtException", this.onProcessUncaught);
7236
+ this.onProcessUncaught = null;
7237
+ }
7238
+ }
7041
7239
  handleRawStdin(chunk) {
7042
- let text = chunk.toString("binary");
7043
- if (!this.pasteActive) {
7044
- const markers = [
7045
- { seq: "\x1B[13;2u", name: "shift-enter" },
7046
- { seq: "\x1B[27;2;13~", name: "shift-enter" },
7047
- { seq: "\x1B[13;5u", name: "ctrl-enter" },
7048
- { seq: "\x1B[27;5;13~", name: "ctrl-enter" },
7049
- // Bare LF universal fallback for terminals without
7050
- // modifyOtherKeys / kitty protocol. Last so the longer escape
7051
- // sequences match first and we don't double-fire.
7052
- { seq: "\n", name: "ctrl-enter" }
7053
- ];
7054
- for (const { seq, name } of markers) {
7055
- if (text.includes(seq)) {
7056
- const parts = text.split(seq);
7057
- for (let i = 0; i < parts.length; i++) {
7058
- if (parts[i].length > 0) {
7059
- this.handleRawStdin(Buffer.from(parts[i], "binary"));
7060
- }
7061
- if (i < parts.length - 1) {
7062
- this.onKey([{ type: "key", name }]);
7063
- }
7240
+ const text = chunk.toString("binary");
7241
+ if (this.pasteActive) {
7242
+ this.handleRawStdinSegment(text);
7243
+ return;
7244
+ }
7245
+ const legacyMarkers = [
7246
+ { seq: "\x1B[27;2;13~", name: "shift-enter" },
7247
+ { seq: "\x1B[27;5;13~", name: "ctrl-enter" },
7248
+ // Bare LF universal fallback for terminals without
7249
+ // modifyOtherKeys / kitty protocol. Last so the longer escape
7250
+ // sequences match first and we don't double-fire.
7251
+ { seq: "\n", name: "ctrl-enter" }
7252
+ ];
7253
+ for (const { seq, name } of legacyMarkers) {
7254
+ if (text.includes(seq)) {
7255
+ const parts = text.split(seq);
7256
+ for (let i = 0; i < parts.length; i++) {
7257
+ if (parts[i].length > 0) {
7258
+ this.handleRawStdin(Buffer.from(parts[i], "binary"));
7064
7259
  }
7065
- return;
7066
- }
7067
- }
7068
- const csiUCtrlMap = {
7069
- 97: "ctrl-a",
7070
- 98: "ctrl-b",
7071
- 99: "ctrl-c",
7072
- 100: "ctrl-d",
7073
- 101: "ctrl-e",
7074
- 102: "ctrl-f",
7075
- 103: "ctrl-g",
7076
- 107: "ctrl-k",
7077
- 108: "ctrl-l",
7078
- 110: "ctrl-n",
7079
- 111: "ctrl-o",
7080
- 112: "ctrl-p",
7081
- 114: "ctrl-r",
7082
- 115: "ctrl-s",
7083
- 116: "ctrl-t",
7084
- 117: "ctrl-u",
7085
- 118: "ctrl-v",
7086
- 119: "ctrl-w",
7087
- 121: "ctrl-y"
7088
- };
7089
- const csiUCtrlRe = /\x1b\[(\d+);5u/;
7090
- const m = csiUCtrlRe.exec(text);
7091
- if (m !== null) {
7092
- const keyName = csiUCtrlMap[parseInt(m[1], 10)];
7093
- if (keyName !== void 0) {
7094
- const parts = text.split(m[0]);
7095
- for (let i = 0; i < parts.length; i++) {
7096
- if (parts[i].length > 0)
7097
- this.handleRawStdin(Buffer.from(parts[i], "binary"));
7098
- if (i < parts.length - 1)
7099
- this.onKey([{ type: "key", name: keyName }]);
7260
+ if (i < parts.length - 1) {
7261
+ this.onKey([{ type: "key", name }]);
7100
7262
  }
7101
- return;
7102
7263
  }
7264
+ return;
7103
7265
  }
7104
7266
  }
7267
+ if (text.includes("\x1B[") && /\x1b\[\d+(?:;\d+)?u/.test(text)) {
7268
+ this.handleCsiUStdin(text);
7269
+ return;
7270
+ }
7105
7271
  this.handleRawStdinSegment(text);
7106
7272
  }
7273
+ // Walk `text` extracting every kitty CSI-u sequence. Each non-CSI-u
7274
+ // span is recursed back into handleRawStdin so paste markers and
7275
+ // legacy-modifyOtherKeys sequences in the same chunk still get
7276
+ // handled; each matched CSI-u is mapped to a KeyEvent (or dropped if
7277
+ // unmapped). Caller has already verified at least one match exists.
7278
+ handleCsiUStdin(text) {
7279
+ const csiU = /\x1b\[(\d+)(?:;(\d+))?u/g;
7280
+ let lastEnd = 0;
7281
+ let m;
7282
+ while ((m = csiU.exec(text)) !== null) {
7283
+ if (m.index > lastEnd) {
7284
+ this.handleRawStdin(
7285
+ Buffer.from(text.slice(lastEnd, m.index), "binary")
7286
+ );
7287
+ }
7288
+ const code = parseInt(m[1], 10);
7289
+ const mod = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
7290
+ const name = mapCsiUToKeyName(code, mod);
7291
+ if (name !== null) {
7292
+ this.onKey([{ type: "key", name }]);
7293
+ }
7294
+ lastEnd = m.index + m[0].length;
7295
+ }
7296
+ if (lastEnd < text.length) {
7297
+ this.handleRawStdin(Buffer.from(text.slice(lastEnd), "binary"));
7298
+ }
7299
+ }
7107
7300
  // Inner stdin-segment handler — paste-marker detection and forwarding
7108
7301
  // to terminal-kit. Split out so shift-enter interception can call it
7109
7302
  // for the non-shift-enter portions of a mixed chunk.
@@ -7360,7 +7553,8 @@ var init_screen = __esm({
7360
7553
  const title = this.sessionbar.title?.trim();
7361
7554
  const fallback = shortId(this.sessionbar.sessionId) || "hydra";
7362
7555
  const raw = title && title.length > 0 ? title : fallback;
7363
- const clean = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
7556
+ const tagged = this.readonly ? `${raw} [VIEW ONLY]` : raw;
7557
+ const clean = tagged.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
7364
7558
  if (clean === this.lastWindowTitle) {
7365
7559
  return;
7366
7560
  }
@@ -8243,6 +8437,9 @@ var init_screen = __esm({
8243
8437
  this.drawHelpPrompt();
8244
8438
  return;
8245
8439
  }
8440
+ if (this.readonly) {
8441
+ return;
8442
+ }
8246
8443
  const w = this.term.width;
8247
8444
  const room = Math.max(1, w - 2);
8248
8445
  const state = this.dispatcher.state();
@@ -8493,6 +8690,9 @@ var init_screen = __esm({
8493
8690
  if (this.helpPrompt) {
8494
8691
  return this.helpRows();
8495
8692
  }
8693
+ if (this.readonly) {
8694
+ return 0;
8695
+ }
8496
8696
  const w = this.term.width;
8497
8697
  const room = Math.max(1, w - 2);
8498
8698
  const state = this.dispatcher.state();
@@ -10248,6 +10448,19 @@ import { nanoid as nanoid3 } from "nanoid";
10248
10448
  import termkit from "terminal-kit";
10249
10449
  import fs19 from "fs/promises";
10250
10450
  import path14 from "path";
10451
+ function isReadonlyForbiddenEffect(effect) {
10452
+ switch (effect.type) {
10453
+ case "send":
10454
+ case "amend":
10455
+ case "queue-edit":
10456
+ case "queue-remove":
10457
+ case "plan-toggle":
10458
+ case "attachment-request":
10459
+ return true;
10460
+ default:
10461
+ return false;
10462
+ }
10463
+ }
10251
10464
  async function runTuiApp(opts) {
10252
10465
  const config = await loadConfig();
10253
10466
  const serviceToken = await ensureServiceToken();
@@ -10266,8 +10479,11 @@ async function runTuiApp(opts) {
10266
10479
  }
10267
10480
  if (exitHint.sessionId) {
10268
10481
  const short = stripHydraSessionPrefix(exitHint.sessionId);
10269
- process.stdout.write(`To resume: hydra-acp tui --resume ${short}
10270
- `);
10482
+ const flags = exitHint.readonly ? " --readonly" : "";
10483
+ process.stdout.write(
10484
+ `To resume: hydra-acp tui --resume ${short}${flags}
10485
+ `
10486
+ );
10271
10487
  }
10272
10488
  }
10273
10489
  async function runSession(term, config, serviceToken, opts, exitHint) {
@@ -10695,6 +10911,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10695
10911
  ownClientId = created.clientId;
10696
10912
  }
10697
10913
  exitHint.sessionId = resolvedSessionId;
10914
+ exitHint.readonly = false;
10698
10915
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
10699
10916
  upstreamSessionId = hydraMeta.upstreamSessionId;
10700
10917
  if (hydraMeta.agentId) {
@@ -10721,13 +10938,15 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10721
10938
  const attached = await conn.request("session/attach", {
10722
10939
  sessionId: ctx.sessionId,
10723
10940
  historyPolicy: "full",
10724
- clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
10941
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
10942
+ ...opts.readonly === true ? { readonly: true } : {}
10725
10943
  });
10726
10944
  resolvedSessionId = attached.sessionId;
10727
10945
  if (attached.clientId) {
10728
10946
  ownClientId = attached.clientId;
10729
10947
  }
10730
10948
  exitHint.sessionId = resolvedSessionId;
10949
+ exitHint.readonly = opts.readonly === true;
10731
10950
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
10732
10951
  upstreamSessionId = hydraMeta.upstreamSessionId;
10733
10952
  if (hydraMeta.agentId) {
@@ -10767,6 +10986,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10767
10986
  maxScrollbackLines: config.tui.maxScrollbackLines,
10768
10987
  mouse: config.tui.mouse,
10769
10988
  progressIndicator: config.tui.progressIndicator,
10989
+ readonly: opts.readonly === true,
10770
10990
  onKey: (events) => {
10771
10991
  for (const ev of events) {
10772
10992
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -10790,6 +11010,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10790
11010
  }
10791
11011
  const effects = dispatcher.feed(ev);
10792
11012
  for (const effect of effects) {
11013
+ if (opts.readonly === true && isReadonlyForbiddenEffect(effect)) {
11014
+ continue;
11015
+ }
10793
11016
  handleEffect(effect);
10794
11017
  }
10795
11018
  }
@@ -11152,13 +11375,14 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11152
11375
  if (choice.kind === "new") {
11153
11376
  const { sessionId: _drop, ...rest } = opts;
11154
11377
  void _drop;
11155
- resume({ ...rest, cwd: resolvedCwd, forceNew: true });
11378
+ resume({ ...rest, cwd: resolvedCwd, forceNew: true, readonly: false });
11156
11379
  return;
11157
11380
  }
11158
11381
  const nextOpts = {
11159
11382
  ...opts,
11160
11383
  sessionId: choice.sessionId,
11161
- cwd: resolvedCwd
11384
+ cwd: resolvedCwd,
11385
+ readonly: choice.readonly === true
11162
11386
  };
11163
11387
  if (choice.agentId !== void 0) {
11164
11388
  nextOpts.agentId = choice.agentId;
@@ -11178,7 +11402,12 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11178
11402
  finishSession = null;
11179
11403
  process.off("SIGINT", sigintHandler);
11180
11404
  void stream.close().catch(() => void 0);
11181
- const nextOpts = { ...opts, sessionId: next.sessionId, cwd: resolvedCwd };
11405
+ const nextOpts = {
11406
+ ...opts,
11407
+ sessionId: next.sessionId,
11408
+ cwd: resolvedCwd,
11409
+ readonly: false
11410
+ };
11182
11411
  if (next.agentId !== void 0)
11183
11412
  nextOpts.agentId = next.agentId;
11184
11413
  resume(nextOpts);
@@ -12235,6 +12464,7 @@ async function resolveSession(term, config, serviceToken, opts) {
12235
12464
  if (choice.kind === "new") {
12236
12465
  return newCtx(opts, cwd, config);
12237
12466
  }
12467
+ opts.readonly = choice.readonly === true;
12238
12468
  return {
12239
12469
  sessionId: choice.sessionId,
12240
12470
  agentId: choice.agentId ?? "",
@@ -12454,6 +12684,7 @@ var KNOWN_BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
12454
12684
  "json",
12455
12685
  "new",
12456
12686
  "reattach",
12687
+ "readonly",
12457
12688
  "replace",
12458
12689
  "rotate-token",
12459
12690
  "version"
@@ -13998,6 +14229,13 @@ var SessionManager = class {
13998
14229
  }
13999
14230
  return this.histories.load(sessionId).catch(() => []);
14000
14231
  }
14232
+ // Read the on-disk history.jsonl for a session without constructing a
14233
+ // Session instance. Used by the daemon's read-only viewer attach path
14234
+ // (cli/src/daemon/acp-ws.ts) to stream replay events to a client for
14235
+ // a cold session without spawning an agent.
14236
+ async loadHistory(sessionId) {
14237
+ return this.histories.load(sessionId);
14238
+ }
14001
14239
  async loadFromDisk(sessionId) {
14002
14240
  const record = await this.store.read(sessionId);
14003
14241
  if (!record) {
@@ -14224,6 +14462,10 @@ var SessionManager = class {
14224
14462
  args.sessionId,
14225
14463
  args.bundle.history
14226
14464
  );
14465
+ const sourceMtime = new Date(args.bundle.session.updatedAt);
14466
+ if (!Number.isNaN(sourceMtime.getTime())) {
14467
+ await fs11.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
14468
+ }
14227
14469
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
14228
14470
  await saveHistory(
14229
14471
  paths.tuiHistoryFile(args.sessionId),
@@ -14247,7 +14489,9 @@ var SessionManager = class {
14247
14489
  currentUsage: args.bundle.session.currentUsage,
14248
14490
  agentCommands: args.bundle.session.agentCommands,
14249
14491
  createdAt: args.preservedCreatedAt ?? now,
14250
- updatedAt: now
14492
+ // Fallback path for historyMtimeIso (used when the history file
14493
+ // is missing). Keep this consistent with the utimes stamp above.
14494
+ updatedAt: args.bundle.session.updatedAt
14251
14495
  });
14252
14496
  });
14253
14497
  }
@@ -15170,6 +15414,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
15170
15414
  if (activeVersions.has(version)) {
15171
15415
  continue;
15172
15416
  }
15417
+ if (version.includes(".partial-")) {
15418
+ continue;
15419
+ }
15173
15420
  const versionDir = path8.join(agentDir, version);
15174
15421
  try {
15175
15422
  await fsp4.rm(versionDir, { recursive: true, force: true });
@@ -16094,6 +16341,16 @@ function registerAcpWsEndpoint(app, deps) {
16094
16341
  }
16095
16342
  state.attached.clear();
16096
16343
  });
16344
+ const denyIfReadonly = (sessionId, method) => {
16345
+ const att = state.attached.get(sessionId);
16346
+ if (att?.readonly) {
16347
+ const err = new Error(
16348
+ `${method} not permitted on a read-only attachment`
16349
+ );
16350
+ err.code = JsonRpcErrorCodes.PermissionDenied;
16351
+ throw err;
16352
+ }
16353
+ };
16097
16354
  connection.onRequest("initialize", async (raw) => {
16098
16355
  InitializeParams.parse(raw ?? {});
16099
16356
  return buildInitializeResult();
@@ -16120,7 +16377,8 @@ function registerAcpWsEndpoint(app, deps) {
16120
16377
  const { entries: replay } = await session.attach(client, "full");
16121
16378
  state.attached.set(session.sessionId, {
16122
16379
  sessionId: session.sessionId,
16123
- clientId: client.clientId
16380
+ clientId: client.clientId,
16381
+ readonly: false
16124
16382
  });
16125
16383
  setImmediate(() => {
16126
16384
  void (async () => {
@@ -16146,11 +16404,46 @@ function registerAcpWsEndpoint(app, deps) {
16146
16404
  connection.onRequest("session/attach", async (raw) => {
16147
16405
  const params = SessionAttachParams.parse(raw);
16148
16406
  const hydraHints = extractHydraMeta(params._meta).resume;
16407
+ const readonly = params.readonly === true;
16149
16408
  app.log.info(
16150
- `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints}`
16409
+ `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
16151
16410
  );
16152
16411
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
16153
16412
  let session = deps.manager.get(lookupId);
16413
+ if (!session && readonly) {
16414
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
16415
+ if (!fromDisk) {
16416
+ const err = new Error(
16417
+ `session ${params.sessionId} not found`
16418
+ );
16419
+ err.code = JsonRpcErrorCodes.SessionNotFound;
16420
+ throw err;
16421
+ }
16422
+ const history = await deps.manager.loadHistory(lookupId);
16423
+ const viewerClientId = params.clientId ?? `cli_${nanoid2(8)}`;
16424
+ state.attached.set(fromDisk.hydraSessionId, {
16425
+ sessionId: fromDisk.hydraSessionId,
16426
+ clientId: viewerClientId,
16427
+ readonly: true
16428
+ });
16429
+ app.log.info(
16430
+ `session/attach OK (viewer) sessionId=${fromDisk.hydraSessionId} clientId=${viewerClientId} attachedCount=${state.attached.size} replayed=${history.length}`
16431
+ );
16432
+ for (const entry of history) {
16433
+ await connection.notify(entry.method, entry.params).catch(() => void 0);
16434
+ }
16435
+ return {
16436
+ sessionId: fromDisk.hydraSessionId,
16437
+ clientId: viewerClientId,
16438
+ connectedClients: [viewerClientId],
16439
+ // No Session.attach() ran, so no history policy was applied —
16440
+ // the viewer always gets full history. Report "full" so the
16441
+ // wire shape matches the normal attach response.
16442
+ historyPolicy: "full",
16443
+ replayed: history.length,
16444
+ _meta: buildViewerResponseMeta(fromDisk)
16445
+ };
16446
+ }
16154
16447
  if (!session) {
16155
16448
  const fromDisk = await deps.manager.loadFromDisk(lookupId);
16156
16449
  let resurrectParams = fromDisk;
@@ -16194,10 +16487,11 @@ function registerAcpWsEndpoint(app, deps) {
16194
16487
  );
16195
16488
  state.attached.set(session.sessionId, {
16196
16489
  sessionId: session.sessionId,
16197
- clientId: client.clientId
16490
+ clientId: client.clientId,
16491
+ readonly
16198
16492
  });
16199
16493
  app.log.info(
16200
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
16494
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length} readonly=${readonly}`
16201
16495
  );
16202
16496
  for (const note of replay) {
16203
16497
  await connection.notify(note.method, note.params);
@@ -16241,6 +16535,7 @@ function registerAcpWsEndpoint(app, deps) {
16241
16535
  });
16242
16536
  connection.onRequest("session/prompt", async (raw) => {
16243
16537
  const params = SessionPromptParams.parse(raw);
16538
+ denyIfReadonly(params.sessionId, "session/prompt");
16244
16539
  const att = state.attached.get(params.sessionId);
16245
16540
  if (!att) {
16246
16541
  app.log.warn(
@@ -16289,6 +16584,12 @@ function registerAcpWsEndpoint(app, deps) {
16289
16584
  if (!att) {
16290
16585
  return;
16291
16586
  }
16587
+ if (att.readonly) {
16588
+ app.log.warn(
16589
+ `session/cancel dropped (readonly attachment) sessionId=${params.sessionId}`
16590
+ );
16591
+ return;
16592
+ }
16292
16593
  const session = deps.manager.get(params.sessionId);
16293
16594
  if (!session) {
16294
16595
  return;
@@ -16301,11 +16602,14 @@ function registerAcpWsEndpoint(app, deps) {
16301
16602
  };
16302
16603
  connection.onNotification("session/cancel", handleCancelParams);
16303
16604
  connection.onRequest("session/cancel", async (raw) => {
16605
+ const params = SessionCancelParams.parse(raw);
16606
+ denyIfReadonly(params.sessionId, "session/cancel");
16304
16607
  handleCancelParams(raw);
16305
16608
  return null;
16306
16609
  });
16307
16610
  connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
16308
16611
  const params = CancelPromptParams.parse(raw);
16612
+ denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
16309
16613
  const session = deps.manager.get(params.sessionId);
16310
16614
  if (!session) {
16311
16615
  const err = new Error(`session ${params.sessionId} not found`);
@@ -16316,6 +16620,7 @@ function registerAcpWsEndpoint(app, deps) {
16316
16620
  });
16317
16621
  connection.onRequest("hydra-acp/update_prompt", async (raw) => {
16318
16622
  const params = UpdatePromptParams.parse(raw);
16623
+ denyIfReadonly(params.sessionId, "hydra-acp/update_prompt");
16319
16624
  const session = deps.manager.get(params.sessionId);
16320
16625
  if (!session) {
16321
16626
  const err = new Error(`session ${params.sessionId} not found`);
@@ -16326,6 +16631,7 @@ function registerAcpWsEndpoint(app, deps) {
16326
16631
  });
16327
16632
  connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
16328
16633
  const params = AmendPromptParams.parse(raw);
16634
+ denyIfReadonly(params.sessionId, "hydra-acp/amend_prompt");
16329
16635
  const att = state.attached.get(params.sessionId);
16330
16636
  if (!att) {
16331
16637
  const err = new Error("not attached to session");
@@ -16365,7 +16671,8 @@ function registerAcpWsEndpoint(app, deps) {
16365
16671
  const { entries: replay } = await session.attach(client, "pending_only");
16366
16672
  state.attached.set(session.sessionId, {
16367
16673
  sessionId: session.sessionId,
16368
- clientId: client.clientId
16674
+ clientId: client.clientId,
16675
+ readonly: false
16369
16676
  });
16370
16677
  for (const note of replay) {
16371
16678
  await connection.notify(note.method, note.params);
@@ -16384,6 +16691,10 @@ function registerAcpWsEndpoint(app, deps) {
16384
16691
  };
16385
16692
  });
16386
16693
  connection.onRequest("session/set_model", async (rawParams) => {
16694
+ const sessionIdField = rawParams?.sessionId;
16695
+ if (typeof sessionIdField === "string") {
16696
+ denyIfReadonly(sessionIdField, "session/set_model");
16697
+ }
16387
16698
  const decision = decideSetModel(rawParams, deps.manager);
16388
16699
  if (decision.kind === "error") {
16389
16700
  app.log.warn(decision.logMessage);
@@ -16417,6 +16728,7 @@ function registerAcpWsEndpoint(app, deps) {
16417
16728
  err.code = JsonRpcErrorCodes.MethodNotFound;
16418
16729
  throw err;
16419
16730
  }
16731
+ denyIfReadonly(sessionId, method);
16420
16732
  const session = deps.manager.get(sessionId);
16421
16733
  if (!session) {
16422
16734
  const err = new Error(`session ${sessionId} not found`);
@@ -16555,6 +16867,38 @@ function decideSetModel(rawParams, manager) {
16555
16867
  logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
16556
16868
  };
16557
16869
  }
16870
+ function buildViewerResponseMeta(fromDisk) {
16871
+ const ours = {
16872
+ upstreamSessionId: fromDisk.upstreamSessionId,
16873
+ agentId: fromDisk.agentId,
16874
+ cwd: fromDisk.cwd
16875
+ };
16876
+ if (fromDisk.title !== void 0) {
16877
+ ours.name = fromDisk.title;
16878
+ }
16879
+ if (fromDisk.agentArgs && fromDisk.agentArgs.length > 0) {
16880
+ ours.agentArgs = fromDisk.agentArgs;
16881
+ }
16882
+ if (fromDisk.currentModel !== void 0) {
16883
+ ours.currentModel = fromDisk.currentModel;
16884
+ }
16885
+ if (fromDisk.currentMode !== void 0) {
16886
+ ours.currentMode = fromDisk.currentMode;
16887
+ }
16888
+ if (fromDisk.currentUsage !== void 0) {
16889
+ ours.currentUsage = fromDisk.currentUsage;
16890
+ }
16891
+ if (fromDisk.agentCommands && fromDisk.agentCommands.length > 0) {
16892
+ ours.availableCommands = fromDisk.agentCommands;
16893
+ }
16894
+ if (fromDisk.agentModes && fromDisk.agentModes.length > 0) {
16895
+ ours.availableModes = fromDisk.agentModes;
16896
+ }
16897
+ if (fromDisk.agentModels && fromDisk.agentModels.length > 0) {
16898
+ ours.availableModels = fromDisk.agentModels;
16899
+ }
16900
+ return { [HYDRA_META_KEY]: ours };
16901
+ }
16558
16902
  function buildResponseMeta(session) {
16559
16903
  const ours = {
16560
16904
  upstreamSessionId: session.upstreamSessionId,
@@ -18536,8 +18880,15 @@ async function dispatchTui(flags, base) {
18536
18880
  const cwd = resolveOption(flags, "cwd");
18537
18881
  const resume = flags.reattach === true;
18538
18882
  const forceNew = flags.new === true;
18883
+ const readonly = flags.readonly === true;
18884
+ if (readonly && base.sessionId === void 0) {
18885
+ process.stderr.write(
18886
+ "hydra-acp: --readonly requires a session id. Pass --resume <id> --readonly, or open the picker and press `v` on a session.\n"
18887
+ );
18888
+ process.exit(2);
18889
+ }
18539
18890
  const { runTui } = await Promise.resolve().then(() => (init_tui(), tui_exports));
18540
- const tuiOpts = { resume, forceNew };
18891
+ const tuiOpts = { resume, forceNew, readonly };
18541
18892
  if (base.sessionId !== void 0) {
18542
18893
  tuiOpts.sessionId = base.sessionId;
18543
18894
  }
@@ -18612,8 +18963,9 @@ function printHelp() {
18612
18963
  " hydra-acp auth password [--force] Set the daemon's master password",
18613
18964
  " hydra-acp auth [list] List active session tokens",
18614
18965
  " hydra-acp auth revoke <id> Revoke a session token",
18615
- " hydra-acp tui flags: [--resume <id>] [--reattach] [--new] [--agent <id>] [--model <id>] [--cwd <path>] [--name <label>]",
18966
+ " hydra-acp tui flags: [--resume <id>] [--reattach] [--new] [--readonly] [--agent <id>] [--model <id>] [--cwd <path>] [--name <label>]",
18616
18967
  " --resume <id> attaches to a specific session; --reattach picks the most-recent in cwd.",
18968
+ " --readonly opens a session as a transcript viewer (no agent spawn, no prompting). Requires --resume.",
18617
18969
  " Smart default (no flags): shows a picker when sessions exist, else new.",
18618
18970
  " hydra-acp --version Print version",
18619
18971
  " hydra-acp --help Show this help",
package/dist/index.d.ts CHANGED
@@ -1320,10 +1320,12 @@ declare const SessionAttachParams: z.ZodObject<{
1320
1320
  name: string;
1321
1321
  version?: string | undefined;
1322
1322
  }>>;
1323
+ readonly: z.ZodOptional<z.ZodBoolean>;
1323
1324
  _meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
1324
1325
  }, "strip", z.ZodTypeAny, {
1325
1326
  sessionId: string;
1326
1327
  historyPolicy: "full" | "pending_only" | "none" | "after_message";
1328
+ readonly?: boolean | undefined;
1327
1329
  clientInfo?: {
1328
1330
  name: string;
1329
1331
  version?: string | undefined;
@@ -1333,6 +1335,7 @@ declare const SessionAttachParams: z.ZodObject<{
1333
1335
  _meta?: Record<string, unknown> | undefined;
1334
1336
  }, {
1335
1337
  sessionId: string;
1338
+ readonly?: boolean | undefined;
1336
1339
  clientInfo?: {
1337
1340
  name: string;
1338
1341
  version?: string | undefined;
@@ -1779,6 +1782,7 @@ interface AttachedClient {
1779
1782
  name: string;
1780
1783
  version?: string;
1781
1784
  };
1785
+ readonly?: boolean;
1782
1786
  }
1783
1787
  type CachedNotification = HistoryEntry;
1784
1788
  interface SpawnReplacementAgentParams {
@@ -2400,6 +2404,7 @@ declare class SessionManager {
2400
2404
  private bootstrapAgent;
2401
2405
  private attachManagerHooks;
2402
2406
  getHistory(sessionId: string): Promise<HistoryEntry[] | undefined>;
2407
+ loadHistory(sessionId: string): Promise<HistoryEntry[]>;
2403
2408
  loadFromDisk(sessionId: string): Promise<ResurrectParams | undefined>;
2404
2409
  private deriveTitleFromHistory;
2405
2410
  get(sessionId: string): Session | undefined;
package/dist/index.js CHANGED
@@ -1075,6 +1075,12 @@ var SessionAttachParams = z3.object({
1075
1075
  name: z3.string(),
1076
1076
  version: z3.string().optional()
1077
1077
  }).optional(),
1078
+ // When true, the connection observes the session but cannot mutate
1079
+ // it: state-changing methods (session/prompt, session/cancel,
1080
+ // session/set_model, etc.) are rejected with -32011, and attaching
1081
+ // to a cold session does not resurrect or spawn an agent — just
1082
+ // streams history from disk. Used by the TUI's view-only mode.
1083
+ readonly: z3.boolean().optional(),
1078
1084
  _meta: z3.record(z3.unknown()).optional()
1079
1085
  });
1080
1086
  var HYDRA_META_KEY = "hydra-acp";
@@ -4893,6 +4899,13 @@ var SessionManager = class {
4893
4899
  }
4894
4900
  return this.histories.load(sessionId).catch(() => []);
4895
4901
  }
4902
+ // Read the on-disk history.jsonl for a session without constructing a
4903
+ // Session instance. Used by the daemon's read-only viewer attach path
4904
+ // (cli/src/daemon/acp-ws.ts) to stream replay events to a client for
4905
+ // a cold session without spawning an agent.
4906
+ async loadHistory(sessionId) {
4907
+ return this.histories.load(sessionId);
4908
+ }
4896
4909
  async loadFromDisk(sessionId) {
4897
4910
  const record = await this.store.read(sessionId);
4898
4911
  if (!record) {
@@ -5119,6 +5132,10 @@ var SessionManager = class {
5119
5132
  args.sessionId,
5120
5133
  args.bundle.history
5121
5134
  );
5135
+ const sourceMtime = new Date(args.bundle.session.updatedAt);
5136
+ if (!Number.isNaN(sourceMtime.getTime())) {
5137
+ await fs10.utimes(paths.historyFile(args.sessionId), sourceMtime, sourceMtime).catch(() => void 0);
5138
+ }
5122
5139
  if (args.bundle.promptHistory && args.bundle.promptHistory.length > 0) {
5123
5140
  await saveHistory(
5124
5141
  paths.tuiHistoryFile(args.sessionId),
@@ -5142,7 +5159,9 @@ var SessionManager = class {
5142
5159
  currentUsage: args.bundle.session.currentUsage,
5143
5160
  agentCommands: args.bundle.session.agentCommands,
5144
5161
  createdAt: args.preservedCreatedAt ?? now,
5145
- updatedAt: now
5162
+ // Fallback path for historyMtimeIso (used when the history file
5163
+ // is missing). Keep this consistent with the utimes stamp above.
5164
+ updatedAt: args.bundle.session.updatedAt
5146
5165
  });
5147
5166
  });
5148
5167
  }
@@ -6060,6 +6079,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
6060
6079
  if (activeVersions.has(version)) {
6061
6080
  continue;
6062
6081
  }
6082
+ if (version.includes(".partial-")) {
6083
+ continue;
6084
+ }
6063
6085
  const versionDir = path8.join(agentDir, version);
6064
6086
  try {
6065
6087
  await fsp4.rm(versionDir, { recursive: true, force: true });
@@ -7708,6 +7730,16 @@ function registerAcpWsEndpoint(app, deps) {
7708
7730
  }
7709
7731
  state.attached.clear();
7710
7732
  });
7733
+ const denyIfReadonly = (sessionId, method) => {
7734
+ const att = state.attached.get(sessionId);
7735
+ if (att?.readonly) {
7736
+ const err = new Error(
7737
+ `${method} not permitted on a read-only attachment`
7738
+ );
7739
+ err.code = JsonRpcErrorCodes.PermissionDenied;
7740
+ throw err;
7741
+ }
7742
+ };
7711
7743
  connection.onRequest("initialize", async (raw) => {
7712
7744
  InitializeParams.parse(raw ?? {});
7713
7745
  return buildInitializeResult();
@@ -7734,7 +7766,8 @@ function registerAcpWsEndpoint(app, deps) {
7734
7766
  const { entries: replay } = await session.attach(client, "full");
7735
7767
  state.attached.set(session.sessionId, {
7736
7768
  sessionId: session.sessionId,
7737
- clientId: client.clientId
7769
+ clientId: client.clientId,
7770
+ readonly: false
7738
7771
  });
7739
7772
  setImmediate(() => {
7740
7773
  void (async () => {
@@ -7760,11 +7793,46 @@ function registerAcpWsEndpoint(app, deps) {
7760
7793
  connection.onRequest("session/attach", async (raw) => {
7761
7794
  const params = SessionAttachParams.parse(raw);
7762
7795
  const hydraHints = extractHydraMeta(params._meta).resume;
7796
+ const readonly = params.readonly === true;
7763
7797
  app.log.info(
7764
- `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints}`
7798
+ `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
7765
7799
  );
7766
7800
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
7767
7801
  let session = deps.manager.get(lookupId);
7802
+ if (!session && readonly) {
7803
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
7804
+ if (!fromDisk) {
7805
+ const err = new Error(
7806
+ `session ${params.sessionId} not found`
7807
+ );
7808
+ err.code = JsonRpcErrorCodes.SessionNotFound;
7809
+ throw err;
7810
+ }
7811
+ const history = await deps.manager.loadHistory(lookupId);
7812
+ const viewerClientId = params.clientId ?? `cli_${nanoid2(8)}`;
7813
+ state.attached.set(fromDisk.hydraSessionId, {
7814
+ sessionId: fromDisk.hydraSessionId,
7815
+ clientId: viewerClientId,
7816
+ readonly: true
7817
+ });
7818
+ app.log.info(
7819
+ `session/attach OK (viewer) sessionId=${fromDisk.hydraSessionId} clientId=${viewerClientId} attachedCount=${state.attached.size} replayed=${history.length}`
7820
+ );
7821
+ for (const entry of history) {
7822
+ await connection.notify(entry.method, entry.params).catch(() => void 0);
7823
+ }
7824
+ return {
7825
+ sessionId: fromDisk.hydraSessionId,
7826
+ clientId: viewerClientId,
7827
+ connectedClients: [viewerClientId],
7828
+ // No Session.attach() ran, so no history policy was applied —
7829
+ // the viewer always gets full history. Report "full" so the
7830
+ // wire shape matches the normal attach response.
7831
+ historyPolicy: "full",
7832
+ replayed: history.length,
7833
+ _meta: buildViewerResponseMeta(fromDisk)
7834
+ };
7835
+ }
7768
7836
  if (!session) {
7769
7837
  const fromDisk = await deps.manager.loadFromDisk(lookupId);
7770
7838
  let resurrectParams = fromDisk;
@@ -7808,10 +7876,11 @@ function registerAcpWsEndpoint(app, deps) {
7808
7876
  );
7809
7877
  state.attached.set(session.sessionId, {
7810
7878
  sessionId: session.sessionId,
7811
- clientId: client.clientId
7879
+ clientId: client.clientId,
7880
+ readonly
7812
7881
  });
7813
7882
  app.log.info(
7814
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
7883
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length} readonly=${readonly}`
7815
7884
  );
7816
7885
  for (const note of replay) {
7817
7886
  await connection.notify(note.method, note.params);
@@ -7855,6 +7924,7 @@ function registerAcpWsEndpoint(app, deps) {
7855
7924
  });
7856
7925
  connection.onRequest("session/prompt", async (raw) => {
7857
7926
  const params = SessionPromptParams.parse(raw);
7927
+ denyIfReadonly(params.sessionId, "session/prompt");
7858
7928
  const att = state.attached.get(params.sessionId);
7859
7929
  if (!att) {
7860
7930
  app.log.warn(
@@ -7903,6 +7973,12 @@ function registerAcpWsEndpoint(app, deps) {
7903
7973
  if (!att) {
7904
7974
  return;
7905
7975
  }
7976
+ if (att.readonly) {
7977
+ app.log.warn(
7978
+ `session/cancel dropped (readonly attachment) sessionId=${params.sessionId}`
7979
+ );
7980
+ return;
7981
+ }
7906
7982
  const session = deps.manager.get(params.sessionId);
7907
7983
  if (!session) {
7908
7984
  return;
@@ -7915,11 +7991,14 @@ function registerAcpWsEndpoint(app, deps) {
7915
7991
  };
7916
7992
  connection.onNotification("session/cancel", handleCancelParams);
7917
7993
  connection.onRequest("session/cancel", async (raw) => {
7994
+ const params = SessionCancelParams.parse(raw);
7995
+ denyIfReadonly(params.sessionId, "session/cancel");
7918
7996
  handleCancelParams(raw);
7919
7997
  return null;
7920
7998
  });
7921
7999
  connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
7922
8000
  const params = CancelPromptParams.parse(raw);
8001
+ denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
7923
8002
  const session = deps.manager.get(params.sessionId);
7924
8003
  if (!session) {
7925
8004
  const err = new Error(`session ${params.sessionId} not found`);
@@ -7930,6 +8009,7 @@ function registerAcpWsEndpoint(app, deps) {
7930
8009
  });
7931
8010
  connection.onRequest("hydra-acp/update_prompt", async (raw) => {
7932
8011
  const params = UpdatePromptParams.parse(raw);
8012
+ denyIfReadonly(params.sessionId, "hydra-acp/update_prompt");
7933
8013
  const session = deps.manager.get(params.sessionId);
7934
8014
  if (!session) {
7935
8015
  const err = new Error(`session ${params.sessionId} not found`);
@@ -7940,6 +8020,7 @@ function registerAcpWsEndpoint(app, deps) {
7940
8020
  });
7941
8021
  connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
7942
8022
  const params = AmendPromptParams.parse(raw);
8023
+ denyIfReadonly(params.sessionId, "hydra-acp/amend_prompt");
7943
8024
  const att = state.attached.get(params.sessionId);
7944
8025
  if (!att) {
7945
8026
  const err = new Error("not attached to session");
@@ -7979,7 +8060,8 @@ function registerAcpWsEndpoint(app, deps) {
7979
8060
  const { entries: replay } = await session.attach(client, "pending_only");
7980
8061
  state.attached.set(session.sessionId, {
7981
8062
  sessionId: session.sessionId,
7982
- clientId: client.clientId
8063
+ clientId: client.clientId,
8064
+ readonly: false
7983
8065
  });
7984
8066
  for (const note of replay) {
7985
8067
  await connection.notify(note.method, note.params);
@@ -7998,6 +8080,10 @@ function registerAcpWsEndpoint(app, deps) {
7998
8080
  };
7999
8081
  });
8000
8082
  connection.onRequest("session/set_model", async (rawParams) => {
8083
+ const sessionIdField = rawParams?.sessionId;
8084
+ if (typeof sessionIdField === "string") {
8085
+ denyIfReadonly(sessionIdField, "session/set_model");
8086
+ }
8001
8087
  const decision = decideSetModel(rawParams, deps.manager);
8002
8088
  if (decision.kind === "error") {
8003
8089
  app.log.warn(decision.logMessage);
@@ -8031,6 +8117,7 @@ function registerAcpWsEndpoint(app, deps) {
8031
8117
  err.code = JsonRpcErrorCodes.MethodNotFound;
8032
8118
  throw err;
8033
8119
  }
8120
+ denyIfReadonly(sessionId, method);
8034
8121
  const session = deps.manager.get(sessionId);
8035
8122
  if (!session) {
8036
8123
  const err = new Error(`session ${sessionId} not found`);
@@ -8169,6 +8256,38 @@ function decideSetModel(rawParams, manager) {
8169
8256
  logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
8170
8257
  };
8171
8258
  }
8259
+ function buildViewerResponseMeta(fromDisk) {
8260
+ const ours = {
8261
+ upstreamSessionId: fromDisk.upstreamSessionId,
8262
+ agentId: fromDisk.agentId,
8263
+ cwd: fromDisk.cwd
8264
+ };
8265
+ if (fromDisk.title !== void 0) {
8266
+ ours.name = fromDisk.title;
8267
+ }
8268
+ if (fromDisk.agentArgs && fromDisk.agentArgs.length > 0) {
8269
+ ours.agentArgs = fromDisk.agentArgs;
8270
+ }
8271
+ if (fromDisk.currentModel !== void 0) {
8272
+ ours.currentModel = fromDisk.currentModel;
8273
+ }
8274
+ if (fromDisk.currentMode !== void 0) {
8275
+ ours.currentMode = fromDisk.currentMode;
8276
+ }
8277
+ if (fromDisk.currentUsage !== void 0) {
8278
+ ours.currentUsage = fromDisk.currentUsage;
8279
+ }
8280
+ if (fromDisk.agentCommands && fromDisk.agentCommands.length > 0) {
8281
+ ours.availableCommands = fromDisk.agentCommands;
8282
+ }
8283
+ if (fromDisk.agentModes && fromDisk.agentModes.length > 0) {
8284
+ ours.availableModes = fromDisk.agentModes;
8285
+ }
8286
+ if (fromDisk.agentModels && fromDisk.agentModels.length > 0) {
8287
+ ours.availableModels = fromDisk.agentModels;
8288
+ }
8289
+ return { [HYDRA_META_KEY]: ours };
8290
+ }
8172
8291
  function buildResponseMeta(session) {
8173
8292
  const ours = {
8174
8293
  upstreamSessionId: session.upstreamSessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
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",