@hydra-acp/cli 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -271,12 +271,12 @@ var init_config = __esm({
271
271
  // buffer. Oldest lines are dropped on overflow. The on-disk session
272
272
  // history is unaffected; this only bounds the TUI's local view buffer.
273
273
  maxScrollbackLines: z.number().int().positive().default(1e4),
274
- // When true (default), the TUI captures mouse events so the wheel can
275
- // drive scrollback. The cost: terminals route clicks to the app, so
276
- // text selection requires shift+drag to bypass mouse reporting. Set
277
- // false to disable capture — wheel scrollback stops working, but
278
- // plain click-drag selects text via the terminal emulator.
279
- mouse: z.boolean().default(true),
274
+ // When true, the TUI captures mouse events so the wheel can drive
275
+ // scrollback. The cost: terminals route clicks to the app, so text
276
+ // selection requires shift+drag to bypass mouse reporting. Default
277
+ // false — wheel scrollback stops working, but plain click-drag
278
+ // selects text via the terminal emulator. Set true to opt back in.
279
+ mouse: z.boolean().default(false),
280
280
  // Size at which the TUI's session/update debug log (tui.log) rotates
281
281
  // to tui.log.0 and resets. Bounds on-disk use at ~2x this value.
282
282
  logMaxBytes: z.number().int().positive().default(5 * 1024 * 1024),
@@ -291,13 +291,13 @@ var init_config = __esm({
291
291
  // just don't want it.
292
292
  progressIndicator: z.boolean().default(true),
293
293
  // What the unmodified Enter key does in the prompt composer.
294
- // "enqueue" (default) — Enter enqueues the prompt (sends immediately
295
- // when idle, queues behind an in-flight turn); Shift+Enter amends
296
- // the in-flight turn.
297
- // "amend" — flips the two: Enter amends the in-flight turn,
298
- // Shift+Enter enqueues. With no turn in flight either key just
299
- // enqueues, since there's nothing to amend.
300
- defaultEnterAction: z.enum(["enqueue", "amend"]).default("enqueue")
294
+ // "amend" (default) — Enter amends the in-flight turn; Shift+Enter
295
+ // enqueues. With no turn in flight either key just enqueues,
296
+ // since there's nothing to amend.
297
+ // "enqueue" — flips the two: Enter enqueues the prompt (sends
298
+ // immediately when idle, queues behind an in-flight turn);
299
+ // Shift+Enter amends the in-flight turn.
300
+ defaultEnterAction: z.enum(["enqueue", "amend"]).default("amend")
301
301
  });
302
302
  ExtensionName = z.string().min(1).regex(/^[A-Za-z0-9._-]+$/, "extension name must be filename-safe");
303
303
  ExtensionBody = z.object({
@@ -338,11 +338,11 @@ var init_config = __esm({
338
338
  tui: TuiConfig.default({
339
339
  repaintThrottleMs: 1e3,
340
340
  maxScrollbackLines: 1e4,
341
- mouse: true,
341
+ mouse: false,
342
342
  logMaxBytes: 5 * 1024 * 1024,
343
343
  cwdColumnMaxWidth: 24,
344
344
  progressIndicator: true,
345
- defaultEnterAction: "enqueue"
345
+ defaultEnterAction: "amend"
346
346
  })
347
347
  });
348
348
  }
@@ -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";
@@ -813,9 +819,9 @@ var init_connection = __esm({
813
819
  }
814
820
  const id = nanoid();
815
821
  const message = { jsonrpc: "2.0", id, method, params };
816
- const response = new Promise((resolve5, reject) => {
822
+ const response = new Promise((resolve6, reject) => {
817
823
  this.pending.set(id, {
818
- resolve: (result) => resolve5(result),
824
+ resolve: (result) => resolve6(result),
819
825
  reject
820
826
  });
821
827
  this.stream.send(message).catch((err) => {
@@ -3068,7 +3074,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3068
3074
  }
3069
3075
  const clientParams = this.rewriteForClient(params);
3070
3076
  const toolCallId = extractToolCallId(clientParams);
3071
- return new Promise((resolve5, reject) => {
3077
+ return new Promise((resolve6, reject) => {
3072
3078
  let settled = false;
3073
3079
  const outbound = [];
3074
3080
  const entry = { addClient: sendTo };
@@ -3107,7 +3113,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3107
3113
  update
3108
3114
  }).catch(() => void 0);
3109
3115
  }
3110
- resolve5(result);
3116
+ resolve6(result);
3111
3117
  });
3112
3118
  }).catch((err) => {
3113
3119
  settle(() => reject(err));
@@ -3123,14 +3129,14 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3123
3129
  // in flight, but doesn't emit prompt_queue_* broadcasts — clients
3124
3130
  // shouldn't see hydra's housekeeping in their chip list.
3125
3131
  async enqueuePrompt(task) {
3126
- return new Promise((resolve5, reject) => {
3132
+ return new Promise((resolve6, reject) => {
3127
3133
  const entry = {
3128
3134
  kind: "internal",
3129
3135
  messageId: generateMessageId(),
3130
3136
  enqueuedAt: Date.now(),
3131
3137
  cancelled: false,
3132
3138
  task,
3133
- resolve: resolve5,
3139
+ resolve: resolve6,
3134
3140
  reject
3135
3141
  };
3136
3142
  this.promptQueue.push(entry);
@@ -3149,7 +3155,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3149
3155
  if (client.clientInfo?.name) originator.name = client.clientInfo.name;
3150
3156
  if (client.clientInfo?.version)
3151
3157
  originator.version = client.clientInfo.version;
3152
- return new Promise((resolve5, reject) => {
3158
+ return new Promise((resolve6, reject) => {
3153
3159
  const entry = {
3154
3160
  kind: "user",
3155
3161
  messageId,
@@ -3158,7 +3164,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
3158
3164
  prompt: promptArray,
3159
3165
  enqueuedAt: Date.now(),
3160
3166
  cancelled: false,
3161
- resolve: resolve5,
3167
+ resolve: resolve6,
3162
3168
  reject
3163
3169
  };
3164
3170
  this.promptQueue.push(entry);
@@ -4346,13 +4352,13 @@ function wsToMessageStream(ws) {
4346
4352
  throw new Error("ws is closed");
4347
4353
  }
4348
4354
  const text = JSON.stringify(message);
4349
- await new Promise((resolve5, reject) => {
4355
+ await new Promise((resolve6, reject) => {
4350
4356
  ws.send(text, (err) => {
4351
4357
  if (err) {
4352
4358
  reject(err);
4353
4359
  return;
4354
4360
  }
4355
- resolve5();
4361
+ resolve6();
4356
4362
  });
4357
4363
  });
4358
4364
  },
@@ -4840,8 +4846,8 @@ async function runSessionsTranscript(idOrFile, outPath) {
4840
4846
  }
4841
4847
  async function readBundleFileIfExists(arg) {
4842
4848
  try {
4843
- const stat4 = await fs17.stat(arg);
4844
- if (!stat4.isFile()) {
4849
+ const stat5 = await fs17.stat(arg);
4850
+ if (!stat5.isFile()) {
4845
4851
  return null;
4846
4852
  }
4847
4853
  } catch {
@@ -4876,8 +4882,8 @@ async function runSessionsImport(file, opts = {}) {
4876
4882
  if (opts.cwd !== void 0) {
4877
4883
  const resolved = path11.resolve(opts.cwd);
4878
4884
  try {
4879
- const stat4 = await fs17.stat(resolved);
4880
- if (!stat4.isDirectory()) {
4885
+ const stat5 = await fs17.stat(resolved);
4886
+ if (!stat5.isDirectory()) {
4881
4887
  process.stderr.write(`--cwd ${resolved} is not a directory
4882
4888
  `);
4883
4889
  process.exit(1);
@@ -5023,11 +5029,11 @@ function isResponse(msg) {
5023
5029
  return !("method" in msg) && "id" in msg && msg.id !== void 0;
5024
5030
  }
5025
5031
  async function openWs(url, subprotocols) {
5026
- return new Promise((resolve5, reject) => {
5032
+ return new Promise((resolve6, reject) => {
5027
5033
  const ws = new WebSocket(url, subprotocols);
5028
5034
  const onOpen = () => {
5029
5035
  ws.off("error", onError);
5030
- resolve5(wsToMessageStream(ws));
5036
+ resolve6(wsToMessageStream(ws));
5031
5037
  };
5032
5038
  const onError = (err) => {
5033
5039
  ws.off("open", onOpen);
@@ -5098,8 +5104,8 @@ var init_resilient_ws = __esm({
5098
5104
  throw new Error("resilient ws stream not connected");
5099
5105
  }
5100
5106
  const id = message.id;
5101
- const promise = new Promise((resolve5, reject) => {
5102
- this.pendingRequests.set(id, { resolve: resolve5, reject });
5107
+ const promise = new Promise((resolve6, reject) => {
5108
+ this.pendingRequests.set(id, { resolve: resolve6, reject });
5103
5109
  });
5104
5110
  try {
5105
5111
  await this.current.send(message);
@@ -5127,8 +5133,8 @@ var init_resilient_ws = __esm({
5127
5133
  this.bindStream(stream);
5128
5134
  const wasFirst = this.firstConnect;
5129
5135
  this.firstConnect = false;
5130
- this.connectGate = new Promise((resolve5) => {
5131
- this.releaseConnectGate = resolve5;
5136
+ this.connectGate = new Promise((resolve6) => {
5137
+ this.releaseConnectGate = resolve6;
5132
5138
  });
5133
5139
  try {
5134
5140
  if (this.opts.onConnect) {
@@ -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
  }
@@ -5652,7 +5667,7 @@ async function pickSession(term, opts) {
5652
5667
  };
5653
5668
  renderFromScratch();
5654
5669
  term.hideCursor();
5655
- return await new Promise((resolve5) => {
5670
+ return await new Promise((resolve6) => {
5656
5671
  let resolved = false;
5657
5672
  const onResize = () => {
5658
5673
  if (resolved) {
@@ -5792,7 +5807,7 @@ async function pickSession(term, opts) {
5792
5807
  if (mode === "help") {
5793
5808
  if (name === "CTRL_C") {
5794
5809
  cleanup();
5795
- resolve5({ kind: "abort" });
5810
+ resolve6({ kind: "abort" });
5796
5811
  return;
5797
5812
  }
5798
5813
  mode = "normal";
@@ -5906,12 +5921,12 @@ async function pickSession(term, opts) {
5906
5921
  }
5907
5922
  if (name === "c" || name === "C") {
5908
5923
  cleanup();
5909
- resolve5({ kind: "new" });
5924
+ resolve6({ kind: "new" });
5910
5925
  return;
5911
5926
  }
5912
5927
  if (name === "q" || name === "Q") {
5913
5928
  cleanup();
5914
- resolve5({ kind: "abort" });
5929
+ resolve6({ kind: "abort" });
5915
5930
  return;
5916
5931
  }
5917
5932
  if (name === "o" || name === "O") {
@@ -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
+ resolve6(result);
5980
+ return;
5981
+ }
5950
5982
  if ((name === "k" || name === "K") && selectedIdx > 0) {
5951
5983
  const session = visible[selectedIdx - 1];
5952
5984
  if (!session) {
@@ -6030,12 +6062,12 @@ async function pickSession(term, opts) {
6030
6062
  case "KP_ENTER": {
6031
6063
  cleanup();
6032
6064
  if (selectedIdx === 0) {
6033
- resolve5({ kind: "new" });
6065
+ resolve6({ kind: "new" });
6034
6066
  return;
6035
6067
  }
6036
6068
  const session = visible[selectedIdx - 1];
6037
6069
  if (!session) {
6038
- resolve5({ kind: "abort" });
6070
+ resolve6({ kind: "abort" });
6039
6071
  return;
6040
6072
  }
6041
6073
  const result = {
@@ -6045,14 +6077,14 @@ async function pickSession(term, opts) {
6045
6077
  if (session.agentId !== void 0) {
6046
6078
  result.agentId = session.agentId;
6047
6079
  }
6048
- resolve5(result);
6080
+ resolve6(result);
6049
6081
  return;
6050
6082
  }
6051
6083
  case "ESCAPE":
6052
6084
  case "CTRL_C":
6053
6085
  case "CTRL_D":
6054
6086
  cleanup();
6055
- resolve5({ kind: "abort" });
6087
+ resolve6({ kind: "abort" });
6056
6088
  return;
6057
6089
  }
6058
6090
  };
@@ -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"],
@@ -6152,10 +6185,184 @@ var init_picker = __esm({
6152
6185
  }
6153
6186
  });
6154
6187
 
6188
+ // src/core/cwd.ts
6189
+ import * as fs18 from "fs/promises";
6190
+ import * as path12 from "path";
6191
+ async function validateLocalCwd(input) {
6192
+ const trimmed = input.trim();
6193
+ if (trimmed.length === 0) {
6194
+ return { ok: false, reason: "path is empty" };
6195
+ }
6196
+ const resolved = path12.resolve(expandHome(trimmed));
6197
+ let stat5;
6198
+ try {
6199
+ stat5 = await fs18.stat(resolved);
6200
+ } catch {
6201
+ return { ok: false, reason: `${resolved} does not exist` };
6202
+ }
6203
+ if (!stat5.isDirectory()) {
6204
+ return { ok: false, reason: `${resolved} is not a directory` };
6205
+ }
6206
+ return { ok: true, path: resolved };
6207
+ }
6208
+ var init_cwd = __esm({
6209
+ "src/core/cwd.ts"() {
6210
+ "use strict";
6211
+ init_config();
6212
+ }
6213
+ });
6214
+
6215
+ // src/tui/import-cwd-prompt.ts
6216
+ import * as os4 from "os";
6217
+ async function promptForImportCwd(term, session, opts = {}) {
6218
+ const defaultCwd = opts.defaultCwd ?? os4.homedir();
6219
+ process.stdout.write("\x1B[<u");
6220
+ process.stdout.write("\x1B[?2004l");
6221
+ process.stdout.write("\x1B[>4;0m");
6222
+ process.stdout.write("\x1B[>5;0m");
6223
+ process.stdout.write("\x1B[?1000l");
6224
+ process.stdout.write("\x1B[?1002l");
6225
+ process.stdout.write("\x1B[?1006l");
6226
+ process.stdout.write("\x1B[?1l");
6227
+ process.stdout.write("\x1B>");
6228
+ const shortId2 = stripHydraSessionPrefix(session.sessionId);
6229
+ const fromMachine = session.importedFromMachine ?? "another machine";
6230
+ const originalCwd = session.cwd;
6231
+ let buffer = defaultCwd;
6232
+ let errorLine = null;
6233
+ let busy = false;
6234
+ const render = () => {
6235
+ term("\n");
6236
+ term.bold.cyan("Imported session: ");
6237
+ term(`${shortId2}
6238
+ `);
6239
+ term.dim(` from machine: `);
6240
+ term(`${fromMachine}
6241
+ `);
6242
+ term.dim(` original cwd: `);
6243
+ term(`${shortenHomePath(originalCwd)}
6244
+ `);
6245
+ term("\n");
6246
+ term(
6247
+ "This session has never been launched on this machine. Pick a local\n"
6248
+ );
6249
+ term("cwd for the agent (Enter to accept, Esc to cancel):\n\n");
6250
+ paintInput();
6251
+ if (errorLine) {
6252
+ term("\n");
6253
+ term.red(` ${errorLine}
6254
+ `);
6255
+ }
6256
+ };
6257
+ const paintInput = () => {
6258
+ term.bold("cwd: ");
6259
+ term(buffer);
6260
+ if (!busy) {
6261
+ term.bgWhite(" ");
6262
+ }
6263
+ };
6264
+ const repaintInput = () => {
6265
+ term.column(1);
6266
+ term.eraseLine();
6267
+ paintInput();
6268
+ if (errorLine !== null) {
6269
+ term("\n");
6270
+ term.eraseLine();
6271
+ term.red(` ${errorLine}`);
6272
+ term.up(1);
6273
+ term.column(1);
6274
+ }
6275
+ };
6276
+ render();
6277
+ return await new Promise((resolve6) => {
6278
+ let resolved = false;
6279
+ const cleanup = () => {
6280
+ if (resolved) {
6281
+ return;
6282
+ }
6283
+ resolved = true;
6284
+ term.off("key", onKey);
6285
+ term.grabInput(false);
6286
+ term.hideCursor(false);
6287
+ term("\n\n");
6288
+ };
6289
+ const finish = (value) => {
6290
+ cleanup();
6291
+ resolve6(value);
6292
+ };
6293
+ const onKey = (name, _matches, data) => {
6294
+ if (busy) {
6295
+ return;
6296
+ }
6297
+ if (name === "ENTER" || name === "KP_ENTER") {
6298
+ const candidate = buffer;
6299
+ busy = true;
6300
+ errorLine = null;
6301
+ repaintInput();
6302
+ void validateLocalCwd(candidate).then((result) => {
6303
+ busy = false;
6304
+ if (result.ok) {
6305
+ finish(result.path);
6306
+ return;
6307
+ }
6308
+ errorLine = result.reason;
6309
+ repaintInput();
6310
+ });
6311
+ return;
6312
+ }
6313
+ if (name === "ESCAPE" || name === "CTRL_C" || name === "CTRL_D") {
6314
+ finish(null);
6315
+ return;
6316
+ }
6317
+ if (name === "BACKSPACE") {
6318
+ if (buffer.length > 0) {
6319
+ buffer = buffer.slice(0, -1);
6320
+ errorLine = null;
6321
+ repaintInput();
6322
+ }
6323
+ return;
6324
+ }
6325
+ if (name === "CTRL_U") {
6326
+ buffer = "";
6327
+ errorLine = null;
6328
+ repaintInput();
6329
+ return;
6330
+ }
6331
+ if (name === "CTRL_W") {
6332
+ const trimmedRight = buffer.replace(/[/\s]+$/, "");
6333
+ const lastSep = Math.max(
6334
+ trimmedRight.lastIndexOf("/"),
6335
+ trimmedRight.lastIndexOf(" ")
6336
+ );
6337
+ buffer = lastSep >= 0 ? trimmedRight.slice(0, lastSep + 1) : "";
6338
+ errorLine = null;
6339
+ repaintInput();
6340
+ return;
6341
+ }
6342
+ if (data?.isCharacter) {
6343
+ buffer += name;
6344
+ errorLine = null;
6345
+ repaintInput();
6346
+ return;
6347
+ }
6348
+ };
6349
+ term.grabInput({});
6350
+ term.on("key", onKey);
6351
+ });
6352
+ }
6353
+ var init_import_cwd_prompt = __esm({
6354
+ "src/tui/import-cwd-prompt.ts"() {
6355
+ "use strict";
6356
+ init_paths();
6357
+ init_session();
6358
+ init_cwd();
6359
+ }
6360
+ });
6361
+
6155
6362
  // src/tui/attachments.ts
6156
- import path12 from "path";
6363
+ import path13 from "path";
6157
6364
  function mimeFromExtension(p) {
6158
- return EXTENSION_TO_MIME[path12.extname(p).toLowerCase()] ?? null;
6365
+ return EXTENSION_TO_MIME[path13.extname(p).toLowerCase()] ?? null;
6159
6366
  }
6160
6367
  function isSupportedImagePath(p) {
6161
6368
  return mimeFromExtension(p) !== null;
@@ -6799,6 +7006,8 @@ function mapKeyName(name) {
6799
7006
  return "ctrl-v";
6800
7007
  case "CTRL_W":
6801
7008
  return "ctrl-w";
7009
+ case "CTRL_X":
7010
+ return "ctrl-x";
6802
7011
  case "CTRL_Y":
6803
7012
  return "ctrl-y";
6804
7013
  case "ESCAPE":
@@ -6807,6 +7016,110 @@ function mapKeyName(name) {
6807
7016
  return null;
6808
7017
  }
6809
7018
  }
7019
+ function emergencyTerminalReset() {
7020
+ const seq = [
7021
+ "\x1B[?1000l",
7022
+ // mouse button reporting off
7023
+ "\x1B[?1002l",
7024
+ // mouse drag reporting off
7025
+ "\x1B[?1003l",
7026
+ // mouse any-motion reporting off
7027
+ "\x1B[?1006l",
7028
+ // SGR mouse mode off
7029
+ "\x1B[?1015l",
7030
+ // urxvt mouse mode off
7031
+ "\x1B[?2004l",
7032
+ // bracketed paste off
7033
+ "\x1B[>4;0m",
7034
+ // xterm modifyOtherKeys off
7035
+ "\x1B[>5;0m",
7036
+ // xterm formatOtherKeys off
7037
+ "\x1B[<u",
7038
+ // pop kitty keyboard stack
7039
+ "\x1B[?1l",
7040
+ // DECCKM off: arrows send CSI A/B/C/D not SS3 O A/B/C/D
7041
+ "\x1B>",
7042
+ // DECPAM off: numeric keypad mode
7043
+ "\x1B[?7h",
7044
+ // auto-wrap on
7045
+ "\x1B[?25h",
7046
+ // show cursor
7047
+ "\x1B]9;4;0\x07",
7048
+ // clear OSC 9;4 progress indicator
7049
+ "\x1B[?1049l"
7050
+ // leave alternate screen
7051
+ ].join("");
7052
+ try {
7053
+ process.stdout.write(seq);
7054
+ } catch {
7055
+ }
7056
+ }
7057
+ function mapCsiUToKeyName(code, mod) {
7058
+ const CTRL_LETTERS = {
7059
+ 97: "ctrl-a",
7060
+ 98: "ctrl-b",
7061
+ 99: "ctrl-c",
7062
+ 100: "ctrl-d",
7063
+ 101: "ctrl-e",
7064
+ 102: "ctrl-f",
7065
+ 103: "ctrl-g",
7066
+ 107: "ctrl-k",
7067
+ 108: "ctrl-l",
7068
+ 110: "ctrl-n",
7069
+ 111: "ctrl-o",
7070
+ 112: "ctrl-p",
7071
+ 114: "ctrl-r",
7072
+ 115: "ctrl-s",
7073
+ 116: "ctrl-t",
7074
+ 117: "ctrl-u",
7075
+ 118: "ctrl-v",
7076
+ 119: "ctrl-w",
7077
+ 121: "ctrl-y"
7078
+ };
7079
+ if (mod === 5) {
7080
+ return CTRL_LETTERS[code] ?? null;
7081
+ }
7082
+ if (code === 27) {
7083
+ return "escape";
7084
+ }
7085
+ if (code === 9) {
7086
+ if (mod === 2) {
7087
+ return "shift-tab";
7088
+ }
7089
+ if (mod === 1) {
7090
+ return "tab";
7091
+ }
7092
+ return null;
7093
+ }
7094
+ if (code === 13) {
7095
+ if (mod === 2) {
7096
+ return "shift-enter";
7097
+ }
7098
+ if (mod === 3) {
7099
+ return "alt-enter";
7100
+ }
7101
+ if (mod === 5) {
7102
+ return "ctrl-enter";
7103
+ }
7104
+ if (mod === 1) {
7105
+ return "enter";
7106
+ }
7107
+ return null;
7108
+ }
7109
+ if (code === 127 && mod === 1) {
7110
+ return "backspace";
7111
+ }
7112
+ if (mod === 3) {
7113
+ if (code === 98 || code === 66) {
7114
+ return "alt-b";
7115
+ }
7116
+ if (code === 102 || code === 70) {
7117
+ return "alt-f";
7118
+ }
7119
+ return null;
7120
+ }
7121
+ return null;
7122
+ }
6810
7123
  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
7124
  var init_screen = __esm({
6812
7125
  "src/tui/screen.ts"() {
@@ -6935,19 +7248,34 @@ var init_screen = __esm({
6935
7248
  rawStdinHandler;
6936
7249
  mouseEnabled;
6937
7250
  progressIndicatorEnabled;
7251
+ // Listeners registered on process via installEmergencyCleanup so an
7252
+ // ungraceful exit (SIGTERM, SIGHUP, uncaughtException) still restores
7253
+ // mouse capture / alt-screen / kitty stack / cursor visibility — the
7254
+ // graceful stop() path isn't guaranteed to run in those cases and
7255
+ // would otherwise leave the host terminal wedged.
7256
+ emergencyCleanupInstalled = false;
7257
+ onProcessExit = null;
7258
+ onProcessSignal = null;
7259
+ onProcessUncaught = null;
6938
7260
  // Last OSC 9;4 state we wrote (3 = indeterminate, 0 = remove). Used to
6939
7261
  // suppress redundant writes when setBanner runs but `status` didn't
6940
7262
  // actually change, and to re-emit on start() if a picker round-trip
6941
7263
  // cleared the host terminal's indicator.
6942
7264
  lastProgressState = 0;
7265
+ // View-only mode. Set once at construction. When true, promptRows()
7266
+ // returns 0 (composer collapses, scrollback expands), drawPrompt()
7267
+ // bails before computing layout, and syncWindowTitle() appends
7268
+ // "[VIEW ONLY]" so the chrome makes the mode obvious.
7269
+ readonly;
6943
7270
  constructor(opts) {
6944
7271
  this.term = opts.term;
6945
7272
  this.dispatcher = opts.dispatcher;
6946
7273
  this.onKey = opts.onKey;
6947
7274
  this.contentRepaintThrottleMs = opts.repaintThrottleMs ?? DEFAULT_CONTENT_REPAINT_THROTTLE_MS;
6948
7275
  this.maxScrollbackLines = opts.maxScrollbackLines ?? DEFAULT_MAX_SCROLLBACK_LINES;
6949
- this.mouseEnabled = opts.mouse ?? true;
7276
+ this.mouseEnabled = opts.mouse ?? false;
6950
7277
  this.progressIndicatorEnabled = opts.progressIndicator ?? true;
7278
+ this.readonly = opts.readonly ?? false;
6951
7279
  this.resizeHandler = () => this.repaint();
6952
7280
  this.keyHandler = (name, _matches, data) => this.handleKey(name, data);
6953
7281
  this.mouseHandler = (name) => this.handleMouse(name);
@@ -6976,6 +7304,7 @@ var init_screen = __esm({
6976
7304
  }
6977
7305
  this.term.on("resize", this.resizeHandler);
6978
7306
  this.installBracketedPaste();
7307
+ this.installEmergencyCleanup();
6979
7308
  this.lastProgressState = 0;
6980
7309
  this.writeProgressIndicator(this.banner.status === "busy" ? 3 : 0);
6981
7310
  this.repaint();
@@ -6993,6 +7322,7 @@ var init_screen = __esm({
6993
7322
  this.throttledRepaintTimer = null;
6994
7323
  }
6995
7324
  this.uninstallBracketedPaste();
7325
+ this.uninstallEmergencyCleanup();
6996
7326
  this.term.off("key", this.keyHandler);
6997
7327
  if (this.mouseEnabled) {
6998
7328
  this.term.off("mouse", this.mouseHandler);
@@ -7028,6 +7358,8 @@ var init_screen = __esm({
7028
7358
  process.stdout.write("\x1B[>4;0m");
7029
7359
  process.stdout.write("\x1B[>5;0m");
7030
7360
  process.stdout.write("\x1B[<u");
7361
+ process.stdout.write("\x1B[?1l");
7362
+ process.stdout.write("\x1B>");
7031
7363
  const t = this.term;
7032
7364
  if (!t.stdin || this.terminalKitStdinHandler === null) {
7033
7365
  return;
@@ -7038,72 +7370,109 @@ var init_screen = __esm({
7038
7370
  this.pasteActive = false;
7039
7371
  this.pasteBuffer = "";
7040
7372
  }
7373
+ installEmergencyCleanup() {
7374
+ if (this.emergencyCleanupInstalled) {
7375
+ return;
7376
+ }
7377
+ this.emergencyCleanupInstalled = true;
7378
+ this.onProcessExit = () => emergencyTerminalReset();
7379
+ this.onProcessSignal = (sig) => {
7380
+ emergencyTerminalReset();
7381
+ process.off(sig, this.onProcessSignal);
7382
+ process.kill(process.pid, sig);
7383
+ };
7384
+ this.onProcessUncaught = (err) => {
7385
+ emergencyTerminalReset();
7386
+ process.stderr.write(`
7387
+ uncaught: ${err.stack ?? err.message}
7388
+ `);
7389
+ process.exit(1);
7390
+ };
7391
+ process.on("exit", this.onProcessExit);
7392
+ process.on("SIGTERM", this.onProcessSignal);
7393
+ process.on("SIGHUP", this.onProcessSignal);
7394
+ process.on("uncaughtException", this.onProcessUncaught);
7395
+ }
7396
+ uninstallEmergencyCleanup() {
7397
+ if (!this.emergencyCleanupInstalled) {
7398
+ return;
7399
+ }
7400
+ this.emergencyCleanupInstalled = false;
7401
+ if (this.onProcessExit) {
7402
+ process.off("exit", this.onProcessExit);
7403
+ this.onProcessExit = null;
7404
+ }
7405
+ if (this.onProcessSignal) {
7406
+ process.off("SIGTERM", this.onProcessSignal);
7407
+ process.off("SIGHUP", this.onProcessSignal);
7408
+ this.onProcessSignal = null;
7409
+ }
7410
+ if (this.onProcessUncaught) {
7411
+ process.off("uncaughtException", this.onProcessUncaught);
7412
+ this.onProcessUncaught = null;
7413
+ }
7414
+ }
7041
7415
  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
- }
7416
+ const text = chunk.toString("binary");
7417
+ if (this.pasteActive) {
7418
+ this.handleRawStdinSegment(text);
7419
+ return;
7420
+ }
7421
+ const legacyMarkers = [
7422
+ { seq: "\x1B[27;2;13~", name: "shift-enter" },
7423
+ { seq: "\x1B[27;5;13~", name: "ctrl-enter" },
7424
+ // Bare LF universal fallback for terminals without
7425
+ // modifyOtherKeys / kitty protocol. Last so the longer escape
7426
+ // sequences match first and we don't double-fire.
7427
+ { seq: "\n", name: "ctrl-enter" }
7428
+ ];
7429
+ for (const { seq, name } of legacyMarkers) {
7430
+ if (text.includes(seq)) {
7431
+ const parts = text.split(seq);
7432
+ for (let i = 0; i < parts.length; i++) {
7433
+ if (parts[i].length > 0) {
7434
+ this.handleRawStdin(Buffer.from(parts[i], "binary"));
7064
7435
  }
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 }]);
7436
+ if (i < parts.length - 1) {
7437
+ this.onKey([{ type: "key", name }]);
7100
7438
  }
7101
- return;
7102
7439
  }
7440
+ return;
7103
7441
  }
7104
7442
  }
7443
+ if (text.includes("\x1B[") && /\x1b\[\d+(?:;\d+)?u/.test(text)) {
7444
+ this.handleCsiUStdin(text);
7445
+ return;
7446
+ }
7105
7447
  this.handleRawStdinSegment(text);
7106
7448
  }
7449
+ // Walk `text` extracting every kitty CSI-u sequence. Each non-CSI-u
7450
+ // span is recursed back into handleRawStdin so paste markers and
7451
+ // legacy-modifyOtherKeys sequences in the same chunk still get
7452
+ // handled; each matched CSI-u is mapped to a KeyEvent (or dropped if
7453
+ // unmapped). Caller has already verified at least one match exists.
7454
+ handleCsiUStdin(text) {
7455
+ const csiU = /\x1b\[(\d+)(?:;(\d+))?u/g;
7456
+ let lastEnd = 0;
7457
+ let m;
7458
+ while ((m = csiU.exec(text)) !== null) {
7459
+ if (m.index > lastEnd) {
7460
+ this.handleRawStdin(
7461
+ Buffer.from(text.slice(lastEnd, m.index), "binary")
7462
+ );
7463
+ }
7464
+ const code = parseInt(m[1], 10);
7465
+ const mod = m[2] !== void 0 ? parseInt(m[2], 10) : 1;
7466
+ const name = mapCsiUToKeyName(code, mod);
7467
+ if (name !== null) {
7468
+ this.onKey([{ type: "key", name }]);
7469
+ }
7470
+ lastEnd = m.index + m[0].length;
7471
+ }
7472
+ if (lastEnd < text.length) {
7473
+ this.handleRawStdin(Buffer.from(text.slice(lastEnd), "binary"));
7474
+ }
7475
+ }
7107
7476
  // Inner stdin-segment handler — paste-marker detection and forwarding
7108
7477
  // to terminal-kit. Split out so shift-enter interception can call it
7109
7478
  // for the non-shift-enter portions of a mixed chunk.
@@ -7360,7 +7729,8 @@ var init_screen = __esm({
7360
7729
  const title = this.sessionbar.title?.trim();
7361
7730
  const fallback = shortId(this.sessionbar.sessionId) || "hydra";
7362
7731
  const raw = title && title.length > 0 ? title : fallback;
7363
- const clean = raw.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
7732
+ const tagged = this.readonly ? `${raw} [VIEW ONLY]` : raw;
7733
+ const clean = tagged.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 200);
7364
7734
  if (clean === this.lastWindowTitle) {
7365
7735
  return;
7366
7736
  }
@@ -7420,6 +7790,57 @@ var init_screen = __esm({
7420
7790
  this.drawBanner();
7421
7791
  this.placeCursor();
7422
7792
  }
7793
+ // Runtime toggle for terminal mouse capture. With capture on, the
7794
+ // wheel drives scrollback but text selection requires shift+drag
7795
+ // (terminals route mouse events to the app). With capture off, plain
7796
+ // click-drag selects text but the wheel does nothing in the app —
7797
+ // use PgUp/PgDn for scrollback instead. Bound to ^X so users can
7798
+ // flip on demand without a config reload + restart. Idempotent.
7799
+ //
7800
+ // Re-issuing grabInput() reinstalls terminal-kit's own stdin "data"
7801
+ // listener, so we have to redo the same listener swap that
7802
+ // installBracketedPaste() did at startup — otherwise our raw handler
7803
+ // and terminal-kit's both fire for every keystroke (each character
7804
+ // appears twice in the prompt).
7805
+ setMouseEnabled(enabled) {
7806
+ if (this.mouseEnabled === enabled) {
7807
+ return;
7808
+ }
7809
+ this.mouseEnabled = enabled;
7810
+ if (!this.started) {
7811
+ return;
7812
+ }
7813
+ if (enabled) {
7814
+ this.term.grabInput({ mouse: "button" });
7815
+ this.term.on("mouse", this.mouseHandler);
7816
+ } else {
7817
+ this.term.off("mouse", this.mouseHandler);
7818
+ this.term.grabInput(true);
7819
+ }
7820
+ this.reclaimStdinAfterGrabInput();
7821
+ }
7822
+ // After a grabInput() re-issue, terminal-kit has put its own "data"
7823
+ // listener back on stdin. Pull it back off and reinstall hydra's
7824
+ // rawStdinHandler — keeping the captured terminal-kit handler so our
7825
+ // bracketed-paste extractor can still delegate non-paste bytes to it.
7826
+ // No-op if installBracketedPaste() hasn't run yet (start() does it
7827
+ // before any toggle path can reach here).
7828
+ reclaimStdinAfterGrabInput() {
7829
+ if (this.terminalKitStdinHandler === null) {
7830
+ return;
7831
+ }
7832
+ const t = this.term;
7833
+ if (!t.stdin || typeof t.onStdin !== "function") {
7834
+ return;
7835
+ }
7836
+ this.terminalKitStdinHandler = t.onStdin;
7837
+ t.stdin.removeListener("data", t.onStdin);
7838
+ t.stdin.removeListener("data", this.rawStdinHandler);
7839
+ t.stdin.on("data", this.rawStdinHandler);
7840
+ }
7841
+ isMouseEnabled() {
7842
+ return this.mouseEnabled;
7843
+ }
7423
7844
  // Pushed by the app each onKey tick to reflect prompt-history
7424
7845
  // reverse-search state in the banner — the only place that mode's
7425
7846
  // query is visible. Pass null when not searching.
@@ -8243,6 +8664,9 @@ var init_screen = __esm({
8243
8664
  this.drawHelpPrompt();
8244
8665
  return;
8245
8666
  }
8667
+ if (this.readonly) {
8668
+ return;
8669
+ }
8246
8670
  const w = this.term.width;
8247
8671
  const room = Math.max(1, w - 2);
8248
8672
  const state = this.dispatcher.state();
@@ -8493,6 +8917,9 @@ var init_screen = __esm({
8493
8917
  if (this.helpPrompt) {
8494
8918
  return this.helpRows();
8495
8919
  }
8920
+ if (this.readonly) {
8921
+ return 0;
8922
+ }
8496
8923
  const w = this.term.width;
8497
8924
  const room = Math.max(1, w - 2);
8498
8925
  const state = this.dispatcher.state();
@@ -8901,6 +9328,8 @@ var init_input = __esm({
8901
9328
  case "ctrl-w":
8902
9329
  this.killWord();
8903
9330
  return [];
9331
+ case "ctrl-x":
9332
+ return [{ type: "toggle-mouse" }];
8904
9333
  case "ctrl-y":
8905
9334
  this.yank();
8906
9335
  return [];
@@ -9483,9 +9912,9 @@ var init_input = __esm({
9483
9912
 
9484
9913
  // src/tui/clipboard.ts
9485
9914
  import { spawn as nodeSpawn } from "child_process";
9486
- import fs18 from "fs/promises";
9487
- import os4 from "os";
9488
- import path13 from "path";
9915
+ import fs19 from "fs/promises";
9916
+ import os5 from "os";
9917
+ import path14 from "path";
9489
9918
  async function readClipboard(envIn = {}) {
9490
9919
  const env = { ...defaultEnv, ...envIn };
9491
9920
  if (env.platform === "darwin") {
@@ -9500,7 +9929,7 @@ async function readClipboard(envIn = {}) {
9500
9929
  };
9501
9930
  }
9502
9931
  async function readMacOS(env) {
9503
- const tmpPath = path13.join(
9932
+ const tmpPath = path14.join(
9504
9933
  env.tmpdir(),
9505
9934
  `hydra-clipboard-${Date.now()}-${process.pid}.png`
9506
9935
  );
@@ -9524,7 +9953,7 @@ async function readMacOS(env) {
9524
9953
  return img;
9525
9954
  }
9526
9955
  } catch {
9527
- await fs18.unlink(tmpPath).catch(() => void 0);
9956
+ await fs19.unlink(tmpPath).catch(() => void 0);
9528
9957
  }
9529
9958
  try {
9530
9959
  const buf = await runCapture(env.spawn, "pbpaste", []);
@@ -9639,9 +10068,9 @@ async function which(env, cmd) {
9639
10068
  }
9640
10069
  async function readFileAsAttachment(p, unlinkAfter) {
9641
10070
  try {
9642
- const buf = await fs18.readFile(p);
10071
+ const buf = await fs19.readFile(p);
9643
10072
  if (unlinkAfter) {
9644
- await fs18.unlink(p).catch(() => void 0);
10073
+ await fs19.unlink(p).catch(() => void 0);
9645
10074
  }
9646
10075
  if (buf.length === 0) {
9647
10076
  return { ok: false, reason: "no image on clipboard" };
@@ -9667,14 +10096,14 @@ async function readFileAsAttachment(p, unlinkAfter) {
9667
10096
  }
9668
10097
  }
9669
10098
  function run2(spawn6, cmd, args) {
9670
- return new Promise((resolve5, reject) => {
10099
+ return new Promise((resolve6, reject) => {
9671
10100
  const proc = spawn6(cmd, args);
9672
10101
  proc.stdout?.on("data", () => void 0);
9673
10102
  proc.stderr?.on("data", () => void 0);
9674
10103
  proc.on("error", reject);
9675
10104
  proc.on("close", (code) => {
9676
10105
  if (code === 0) {
9677
- resolve5();
10106
+ resolve6();
9678
10107
  } else {
9679
10108
  reject(new Error(`${cmd} exited ${code}`));
9680
10109
  }
@@ -9682,7 +10111,7 @@ function run2(spawn6, cmd, args) {
9682
10111
  });
9683
10112
  }
9684
10113
  function runCapture(spawn6, cmd, args) {
9685
- return new Promise((resolve5, reject) => {
10114
+ return new Promise((resolve6, reject) => {
9686
10115
  const proc = spawn6(cmd, args);
9687
10116
  const chunks = [];
9688
10117
  let stdoutEnded = proc.stdout === null;
@@ -9694,7 +10123,7 @@ function runCapture(spawn6, cmd, args) {
9694
10123
  }
9695
10124
  settled = true;
9696
10125
  if (closedCode === 0) {
9697
- resolve5(Buffer.concat(chunks));
10126
+ resolve6(Buffer.concat(chunks));
9698
10127
  } else {
9699
10128
  reject(new Error(`${cmd} exited ${closedCode}`));
9700
10129
  }
@@ -9729,7 +10158,7 @@ var init_clipboard = __esm({
9729
10158
  platform: process.platform,
9730
10159
  env: process.env,
9731
10160
  spawn: nodeSpawn,
9732
- tmpdir: os4.tmpdir
10161
+ tmpdir: os5.tmpdir
9733
10162
  };
9734
10163
  SUPPORTED_IMAGE_MIMES = [
9735
10164
  "image/png",
@@ -10246,8 +10675,21 @@ var init_format = __esm({
10246
10675
  import { appendFileSync, statSync, renameSync } from "fs";
10247
10676
  import { nanoid as nanoid3 } from "nanoid";
10248
10677
  import termkit from "terminal-kit";
10249
- import fs19 from "fs/promises";
10250
- import path14 from "path";
10678
+ import fs20 from "fs/promises";
10679
+ import path15 from "path";
10680
+ function isReadonlyForbiddenEffect(effect) {
10681
+ switch (effect.type) {
10682
+ case "send":
10683
+ case "amend":
10684
+ case "queue-edit":
10685
+ case "queue-remove":
10686
+ case "plan-toggle":
10687
+ case "attachment-request":
10688
+ return true;
10689
+ default:
10690
+ return false;
10691
+ }
10692
+ }
10251
10693
  async function runTuiApp(opts) {
10252
10694
  const config = await loadConfig();
10253
10695
  const serviceToken = await ensureServiceToken();
@@ -10266,8 +10708,11 @@ async function runTuiApp(opts) {
10266
10708
  }
10267
10709
  if (exitHint.sessionId) {
10268
10710
  const short = stripHydraSessionPrefix(exitHint.sessionId);
10269
- process.stdout.write(`To resume: hydra-acp tui --resume ${short}
10270
- `);
10711
+ const flags = exitHint.readonly ? " --readonly" : "";
10712
+ process.stdout.write(
10713
+ `To resume: hydra-acp tui --resume ${short}${flags}
10714
+ `
10715
+ );
10271
10716
  }
10272
10717
  }
10273
10718
  async function runSession(term, config, serviceToken, opts, exitHint) {
@@ -10559,10 +11004,10 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10559
11004
  if (pendingPermission.toolCallId && toolCallId && pendingPermission.toolCallId !== toolCallId) {
10560
11005
  return;
10561
11006
  }
10562
- const resolve5 = pendingPermission.resolve;
11007
+ const resolve6 = pendingPermission.resolve;
10563
11008
  pendingPermission = null;
10564
11009
  screen.setPermissionPrompt(null);
10565
- resolve5(result ?? { outcome: { outcome: "cancelled" } });
11010
+ resolve6(result ?? { outcome: { outcome: "cancelled" } });
10566
11011
  };
10567
11012
  const maybeDismissPermissionByToolUpdate = (update) => {
10568
11013
  if (!pendingPermission?.toolCallId) {
@@ -10595,14 +11040,14 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10595
11040
  if (!pendingPermission) {
10596
11041
  return;
10597
11042
  }
10598
- const { options, resolve: resolve5 } = pendingPermission;
11043
+ const { options, resolve: resolve6 } = pendingPermission;
10599
11044
  pendingPermission = null;
10600
11045
  screen.setPermissionPrompt(null);
10601
11046
  if (optionId === null) {
10602
- resolve5({ outcome: { outcome: "cancelled" } });
11047
+ resolve6({ outcome: { outcome: "cancelled" } });
10603
11048
  return;
10604
11049
  }
10605
- resolve5({ outcome: { outcome: "selected", optionId } });
11050
+ resolve6({ outcome: { outcome: "selected", optionId } });
10606
11051
  void options;
10607
11052
  };
10608
11053
  conn.onRequest("session/request_permission", async (params) => {
@@ -10629,12 +11074,12 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10629
11074
  ]);
10630
11075
  return { outcome: { outcome: "cancelled" } };
10631
11076
  }
10632
- return new Promise((resolve5) => {
11077
+ return new Promise((resolve6) => {
10633
11078
  pendingPermission = {
10634
11079
  title,
10635
11080
  options,
10636
11081
  selectedIndex: 0,
10637
- resolve: resolve5,
11082
+ resolve: resolve6,
10638
11083
  toolCallId
10639
11084
  };
10640
11085
  refreshPermissionPrompt();
@@ -10695,6 +11140,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10695
11140
  ownClientId = created.clientId;
10696
11141
  }
10697
11142
  exitHint.sessionId = resolvedSessionId;
11143
+ exitHint.readonly = false;
10698
11144
  const hydraMeta = extractHydraMeta(created._meta ?? void 0);
10699
11145
  upstreamSessionId = hydraMeta.upstreamSessionId;
10700
11146
  if (hydraMeta.agentId) {
@@ -10721,13 +11167,31 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10721
11167
  const attached = await conn.request("session/attach", {
10722
11168
  sessionId: ctx.sessionId,
10723
11169
  historyPolicy: "full",
10724
- clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
11170
+ clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION },
11171
+ ...opts.readonly === true ? { readonly: true } : {},
11172
+ // Forward the user-chosen cwd for first-launch imported sessions
11173
+ // via a full resume hint. upstreamSessionId is empty so the
11174
+ // daemon routes through doResurrectFromImport (session-manager.ts)
11175
+ // with the user-supplied cwd instead of silently falling back to
11176
+ // $HOME in resolveImportCwd.
11177
+ ...ctx.importAttachHint !== void 0 ? {
11178
+ _meta: {
11179
+ [HYDRA_META_KEY]: {
11180
+ resume: {
11181
+ upstreamSessionId: "",
11182
+ agentId: ctx.importAttachHint.agentId,
11183
+ cwd: ctx.importAttachHint.cwd
11184
+ }
11185
+ }
11186
+ }
11187
+ } : {}
10725
11188
  });
10726
11189
  resolvedSessionId = attached.sessionId;
10727
11190
  if (attached.clientId) {
10728
11191
  ownClientId = attached.clientId;
10729
11192
  }
10730
11193
  exitHint.sessionId = resolvedSessionId;
11194
+ exitHint.readonly = opts.readonly === true;
10731
11195
  const hydraMeta = extractHydraMeta(attached._meta ?? void 0);
10732
11196
  upstreamSessionId = hydraMeta.upstreamSessionId;
10733
11197
  if (hydraMeta.agentId) {
@@ -10767,6 +11231,7 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10767
11231
  maxScrollbackLines: config.tui.maxScrollbackLines,
10768
11232
  mouse: config.tui.mouse,
10769
11233
  progressIndicator: config.tui.progressIndicator,
11234
+ readonly: opts.readonly === true,
10770
11235
  onKey: (events) => {
10771
11236
  for (const ev of events) {
10772
11237
  if (pendingPermission && tryHandlePermissionKey(ev)) {
@@ -10790,6 +11255,9 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10790
11255
  }
10791
11256
  const effects = dispatcher.feed(ev);
10792
11257
  for (const effect of effects) {
11258
+ if (opts.readonly === true && isReadonlyForbiddenEffect(effect)) {
11259
+ continue;
11260
+ }
10793
11261
  handleEffect(effect);
10794
11262
  }
10795
11263
  }
@@ -10977,8 +11445,8 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
10977
11445
  }
10978
11446
  });
10979
11447
  let finishSession = null;
10980
- const sessionDone = new Promise((resolve5) => {
10981
- finishSession = resolve5;
11448
+ const sessionDone = new Promise((resolve6) => {
11449
+ finishSession = resolve6;
10982
11450
  });
10983
11451
  const cancelRemoteTurn = () => {
10984
11452
  conn.notify("session/cancel", { sessionId: resolvedSessionId }).catch(() => void 0);
@@ -11152,13 +11620,14 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11152
11620
  if (choice.kind === "new") {
11153
11621
  const { sessionId: _drop, ...rest } = opts;
11154
11622
  void _drop;
11155
- resume({ ...rest, cwd: resolvedCwd, forceNew: true });
11623
+ resume({ ...rest, cwd: resolvedCwd, forceNew: true, readonly: false });
11156
11624
  return;
11157
11625
  }
11158
11626
  const nextOpts = {
11159
11627
  ...opts,
11160
11628
  sessionId: choice.sessionId,
11161
- cwd: resolvedCwd
11629
+ cwd: resolvedCwd,
11630
+ readonly: choice.readonly === true
11162
11631
  };
11163
11632
  if (choice.agentId !== void 0) {
11164
11633
  nextOpts.agentId = choice.agentId;
@@ -11178,7 +11647,12 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11178
11647
  finishSession = null;
11179
11648
  process.off("SIGINT", sigintHandler);
11180
11649
  void stream.close().catch(() => void 0);
11181
- const nextOpts = { ...opts, sessionId: next.sessionId, cwd: resolvedCwd };
11650
+ const nextOpts = {
11651
+ ...opts,
11652
+ sessionId: next.sessionId,
11653
+ cwd: resolvedCwd,
11654
+ readonly: false
11655
+ };
11182
11656
  if (next.agentId !== void 0)
11183
11657
  nextOpts.agentId = next.agentId;
11184
11658
  resume(nextOpts);
@@ -11289,6 +11763,14 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11289
11763
  toolsExpanded = !toolsExpanded;
11290
11764
  renderToolsBlock();
11291
11765
  return;
11766
+ case "toggle-mouse": {
11767
+ const next = !screen.isMouseEnabled();
11768
+ screen.setMouseEnabled(next);
11769
+ screen.notify(
11770
+ next ? "mouse capture on \u2014 wheel scrolls; shift+drag to select text" : "mouse capture off \u2014 click-drag selects text; PgUp/PgDn scrolls"
11771
+ );
11772
+ return;
11773
+ }
11292
11774
  case "show-help":
11293
11775
  toggleHelpModal();
11294
11776
  return;
@@ -11331,11 +11813,11 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11331
11813
  }
11332
11814
  const mimeType = mimeFromExtension(token);
11333
11815
  if (!mimeType) {
11334
- screen.notify(`unsupported image type: ${path14.basename(token)}`);
11816
+ screen.notify(`unsupported image type: ${path15.basename(token)}`);
11335
11817
  continue;
11336
11818
  }
11337
11819
  try {
11338
- const buf = await fs19.readFile(token);
11820
+ const buf = await fs20.readFile(token);
11339
11821
  if (buf.length > MAX_ATTACHMENT_BYTES) {
11340
11822
  screen.notify(
11341
11823
  `image too large (${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)})`
@@ -11345,13 +11827,13 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
11345
11827
  dispatcher.addAttachment({
11346
11828
  mimeType,
11347
11829
  data: buf.toString("base64"),
11348
- name: path14.basename(token),
11830
+ name: path15.basename(token),
11349
11831
  sizeBytes: buf.length
11350
11832
  });
11351
11833
  added++;
11352
11834
  } catch (err) {
11353
11835
  screen.notify(
11354
- `cannot read ${path14.basename(token)}: ${err.message}`
11836
+ `cannot read ${path15.basename(token)}: ${err.message}`
11355
11837
  );
11356
11838
  }
11357
11839
  }
@@ -12068,10 +12550,10 @@ async function runSession(term, config, serviceToken, opts, exitHint) {
12068
12550
  }
12069
12551
  const resetInFlightUiState = () => {
12070
12552
  if (pendingPermission) {
12071
- const resolve5 = pendingPermission.resolve;
12553
+ const resolve6 = pendingPermission.resolve;
12072
12554
  pendingPermission = null;
12073
12555
  screen.setPermissionPrompt(null);
12074
- resolve5({ outcome: { outcome: "cancelled" } });
12556
+ resolve6({ outcome: { outcome: "cancelled" } });
12075
12557
  }
12076
12558
  closeAgentText();
12077
12559
  };
@@ -12235,6 +12717,22 @@ async function resolveSession(term, config, serviceToken, opts) {
12235
12717
  if (choice.kind === "new") {
12236
12718
  return newCtx(opts, cwd, config);
12237
12719
  }
12720
+ opts.readonly = choice.readonly === true;
12721
+ const chosen = sessions.find((s) => s.sessionId === choice.sessionId);
12722
+ const isImportedFirstLaunch = chosen !== void 0 && !!chosen.importedFromMachine && !chosen.upstreamSessionId && !opts.readonly;
12723
+ if (isImportedFirstLaunch) {
12724
+ const promptedCwd = await promptForImportCwd(term, chosen);
12725
+ if (promptedCwd === null) {
12726
+ return null;
12727
+ }
12728
+ const agentId = choice.agentId ?? chosen.agentId ?? "";
12729
+ return {
12730
+ sessionId: choice.sessionId,
12731
+ agentId,
12732
+ cwd: promptedCwd,
12733
+ importAttachHint: { agentId, cwd: promptedCwd }
12734
+ };
12735
+ }
12238
12736
  return {
12239
12737
  sessionId: choice.sessionId,
12240
12738
  agentId: choice.agentId ?? "",
@@ -12363,8 +12861,8 @@ function createInstallStatusLine(term, baseLabel) {
12363
12861
  }
12364
12862
  function rotateIfBig(target) {
12365
12863
  try {
12366
- const stat4 = statSync(target);
12367
- if (stat4.size < logMaxBytes) {
12864
+ const stat5 = statSync(target);
12865
+ if (stat5.size < logMaxBytes) {
12368
12866
  return;
12369
12867
  }
12370
12868
  renameSync(target, `${target}.0`);
@@ -12388,6 +12886,7 @@ var init_app = __esm({
12388
12886
  init_history();
12389
12887
  init_discovery();
12390
12888
  init_picker();
12889
+ init_import_cwd_prompt();
12391
12890
  init_screen();
12392
12891
  init_input();
12393
12892
  init_attachments();
@@ -12417,6 +12916,7 @@ var init_app = __esm({
12417
12916
  ["^R / ^S", "history reverse / forward search"],
12418
12917
  ["PgUp / PgDn", "scroll scrollback"],
12419
12918
  ["Mouse wheel", "scroll scrollback (when mouse capture is on)"],
12919
+ ["^X", "toggle mouse capture (wheel scroll vs. text selection)"],
12420
12920
  null,
12421
12921
  ["^C", "cancel turn (twice to exit)"],
12422
12922
  ["Esc", "cancel turn and prefill draft"],
@@ -12443,7 +12943,7 @@ var init_tui = __esm({
12443
12943
  // src/cli.ts
12444
12944
  import { readFileSync as readFileSync2 } from "fs";
12445
12945
  import { fileURLToPath as fileURLToPath2 } from "url";
12446
- import { dirname as dirname6, resolve as resolve4 } from "path";
12946
+ import { dirname as dirname6, resolve as resolve5 } from "path";
12447
12947
 
12448
12948
  // src/cli/parse-args.ts
12449
12949
  var KNOWN_BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
@@ -12454,6 +12954,7 @@ var KNOWN_BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
12454
12954
  "json",
12455
12955
  "new",
12456
12956
  "reattach",
12957
+ "readonly",
12457
12958
  "replace",
12458
12959
  "rotate-token",
12459
12960
  "version"
@@ -12740,10 +13241,10 @@ async function downloadTo(args) {
12740
13241
  logSink(formatProgress(args.agentId, received, total));
12741
13242
  }
12742
13243
  });
12743
- await new Promise((resolve5, reject) => {
13244
+ await new Promise((resolve6, reject) => {
12744
13245
  nodeStream.on("error", reject);
12745
13246
  out.on("error", reject);
12746
- out.on("finish", () => resolve5());
13247
+ out.on("finish", () => resolve6());
12747
13248
  nodeStream.pipe(out);
12748
13249
  });
12749
13250
  logSink(formatProgress(
@@ -12795,14 +13296,14 @@ async function extract(archivePath, dest) {
12795
13296
  throw new Error(`Unsupported archive format: ${archivePath}`);
12796
13297
  }
12797
13298
  function run(cmd, args) {
12798
- return new Promise((resolve5, reject) => {
13299
+ return new Promise((resolve6, reject) => {
12799
13300
  const child = spawn(cmd, args, {
12800
13301
  stdio: ["ignore", "ignore", "inherit"]
12801
13302
  });
12802
13303
  child.on("error", reject);
12803
13304
  child.on("exit", (code, signal) => {
12804
13305
  if (code === 0) {
12805
- resolve5();
13306
+ resolve6();
12806
13307
  return;
12807
13308
  }
12808
13309
  reject(
@@ -12814,11 +13315,11 @@ function run(cmd, args) {
12814
13315
  });
12815
13316
  }
12816
13317
  async function hasCommand(name) {
12817
- return new Promise((resolve5) => {
13318
+ return new Promise((resolve6) => {
12818
13319
  const finder = process.platform === "win32" ? "where" : "which";
12819
13320
  const child = spawn(finder, [name], { stdio: "ignore" });
12820
- child.on("error", () => resolve5(false));
12821
- child.on("exit", (code) => resolve5(code === 0));
13321
+ child.on("error", () => resolve6(false));
13322
+ child.on("exit", (code) => resolve6(code === 0));
12822
13323
  });
12823
13324
  }
12824
13325
  async function fileExists(p) {
@@ -12936,7 +13437,7 @@ function runNpmInstall(args) {
12936
13437
  }
12937
13438
  async function runNpmInstallOnce(args, attempt) {
12938
13439
  try {
12939
- await new Promise((resolve5, reject) => {
13440
+ await new Promise((resolve6, reject) => {
12940
13441
  const registryArgs = args.registry ? ["--registry", args.registry] : [];
12941
13442
  let child;
12942
13443
  try {
@@ -12978,7 +13479,7 @@ async function runNpmInstallOnce(args, attempt) {
12978
13479
  });
12979
13480
  child.on("exit", (code, signal) => {
12980
13481
  if (code === 0) {
12981
- resolve5();
13482
+ resolve6();
12982
13483
  return;
12983
13484
  }
12984
13485
  const reason = code !== null ? `exit code ${code}` : `signal ${signal ?? "unknown"}`;
@@ -13306,13 +13807,13 @@ function ndjsonStreamFromStdio(stdout, stdin) {
13306
13807
  throw new Error("stream is closed");
13307
13808
  }
13308
13809
  const line = JSON.stringify(message) + "\n";
13309
- await new Promise((resolve5, reject) => {
13810
+ await new Promise((resolve6, reject) => {
13310
13811
  stdin.write(line, (err) => {
13311
13812
  if (err) {
13312
13813
  reject(err);
13313
13814
  return;
13314
13815
  }
13315
- resolve5();
13816
+ resolve6();
13316
13817
  });
13317
13818
  });
13318
13819
  },
@@ -13827,8 +14328,8 @@ var SessionManager = class {
13827
14328
  }
13828
14329
  async resolveImportCwd(cwd) {
13829
14330
  try {
13830
- const stat4 = await fs11.stat(cwd);
13831
- if (stat4.isDirectory()) {
14331
+ const stat5 = await fs11.stat(cwd);
14332
+ if (stat5.isDirectory()) {
13832
14333
  return cwd;
13833
14334
  }
13834
14335
  } catch {
@@ -13998,6 +14499,13 @@ var SessionManager = class {
13998
14499
  }
13999
14500
  return this.histories.load(sessionId).catch(() => []);
14000
14501
  }
14502
+ // Read the on-disk history.jsonl for a session without constructing a
14503
+ // Session instance. Used by the daemon's read-only viewer attach path
14504
+ // (cli/src/daemon/acp-ws.ts) to stream replay events to a client for
14505
+ // a cold session without spawning an agent.
14506
+ async loadHistory(sessionId) {
14507
+ return this.histories.load(sessionId);
14508
+ }
14001
14509
  async loadFromDisk(sessionId) {
14002
14510
  const record = await this.store.read(sessionId);
14003
14511
  if (!record) {
@@ -14744,9 +15252,9 @@ var ExtensionManager = class {
14744
15252
  } catch {
14745
15253
  }
14746
15254
  tasks.push(
14747
- new Promise((resolve5) => {
15255
+ new Promise((resolve6) => {
14748
15256
  if (child.exitCode !== null || child.signalCode !== null) {
14749
- resolve5();
15257
+ resolve6();
14750
15258
  return;
14751
15259
  }
14752
15260
  const timer = setTimeout(() => {
@@ -14754,11 +15262,11 @@ var ExtensionManager = class {
14754
15262
  child.kill("SIGKILL");
14755
15263
  } catch {
14756
15264
  }
14757
- resolve5();
15265
+ resolve6();
14758
15266
  }, STOP_GRACE_MS);
14759
15267
  child.on("exit", () => {
14760
15268
  clearTimeout(timer);
14761
- resolve5();
15269
+ resolve6();
14762
15270
  });
14763
15271
  })
14764
15272
  );
@@ -14866,8 +15374,8 @@ var ExtensionManager = class {
14866
15374
  if (child.exitCode !== null || child.signalCode !== null) {
14867
15375
  return;
14868
15376
  }
14869
- const exited = new Promise((resolve5) => {
14870
- entry.exitWaiters.push(resolve5);
15377
+ const exited = new Promise((resolve6) => {
15378
+ entry.exitWaiters.push(resolve6);
14871
15379
  });
14872
15380
  try {
14873
15381
  child.kill("SIGTERM");
@@ -15064,8 +15572,8 @@ var ExtensionManager = class {
15064
15572
  entry.pid = void 0;
15065
15573
  entry.lastExitCode = typeof code === "number" ? code : void 0;
15066
15574
  const waiters = entry.exitWaiters.splice(0);
15067
- for (const resolve5 of waiters) {
15068
- resolve5();
15575
+ for (const resolve6 of waiters) {
15576
+ resolve6();
15069
15577
  }
15070
15578
  if (this.stopping || entry.manuallyStopped) {
15071
15579
  try {
@@ -15176,6 +15684,9 @@ async function pruneStaleAgentVersions(registry, sessionManager) {
15176
15684
  if (activeVersions.has(version)) {
15177
15685
  continue;
15178
15686
  }
15687
+ if (version.includes(".partial-")) {
15688
+ continue;
15689
+ }
15179
15690
  const versionDir = path8.join(agentDir, version);
15180
15691
  try {
15181
15692
  await fsp4.rm(versionDir, { recursive: true, force: true });
@@ -16100,6 +16611,16 @@ function registerAcpWsEndpoint(app, deps) {
16100
16611
  }
16101
16612
  state.attached.clear();
16102
16613
  });
16614
+ const denyIfReadonly = (sessionId, method) => {
16615
+ const att = state.attached.get(sessionId);
16616
+ if (att?.readonly) {
16617
+ const err = new Error(
16618
+ `${method} not permitted on a read-only attachment`
16619
+ );
16620
+ err.code = JsonRpcErrorCodes.PermissionDenied;
16621
+ throw err;
16622
+ }
16623
+ };
16103
16624
  connection.onRequest("initialize", async (raw) => {
16104
16625
  InitializeParams.parse(raw ?? {});
16105
16626
  return buildInitializeResult();
@@ -16126,7 +16647,8 @@ function registerAcpWsEndpoint(app, deps) {
16126
16647
  const { entries: replay } = await session.attach(client, "full");
16127
16648
  state.attached.set(session.sessionId, {
16128
16649
  sessionId: session.sessionId,
16129
- clientId: client.clientId
16650
+ clientId: client.clientId,
16651
+ readonly: false
16130
16652
  });
16131
16653
  setImmediate(() => {
16132
16654
  void (async () => {
@@ -16152,11 +16674,46 @@ function registerAcpWsEndpoint(app, deps) {
16152
16674
  connection.onRequest("session/attach", async (raw) => {
16153
16675
  const params = SessionAttachParams.parse(raw);
16154
16676
  const hydraHints = extractHydraMeta(params._meta).resume;
16677
+ const readonly = params.readonly === true;
16155
16678
  app.log.info(
16156
- `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints}`
16679
+ `session/attach sessionId=${params.sessionId} hasResumeHints=${!!hydraHints} readonly=${readonly}`
16157
16680
  );
16158
16681
  const lookupId = hydraHints ? params.sessionId : await deps.manager.resolveCanonicalId(params.sessionId) ?? params.sessionId;
16159
16682
  let session = deps.manager.get(lookupId);
16683
+ if (!session && readonly) {
16684
+ const fromDisk = await deps.manager.loadFromDisk(lookupId);
16685
+ if (!fromDisk) {
16686
+ const err = new Error(
16687
+ `session ${params.sessionId} not found`
16688
+ );
16689
+ err.code = JsonRpcErrorCodes.SessionNotFound;
16690
+ throw err;
16691
+ }
16692
+ const history = await deps.manager.loadHistory(lookupId);
16693
+ const viewerClientId = params.clientId ?? `cli_${nanoid2(8)}`;
16694
+ state.attached.set(fromDisk.hydraSessionId, {
16695
+ sessionId: fromDisk.hydraSessionId,
16696
+ clientId: viewerClientId,
16697
+ readonly: true
16698
+ });
16699
+ app.log.info(
16700
+ `session/attach OK (viewer) sessionId=${fromDisk.hydraSessionId} clientId=${viewerClientId} attachedCount=${state.attached.size} replayed=${history.length}`
16701
+ );
16702
+ for (const entry of history) {
16703
+ await connection.notify(entry.method, entry.params).catch(() => void 0);
16704
+ }
16705
+ return {
16706
+ sessionId: fromDisk.hydraSessionId,
16707
+ clientId: viewerClientId,
16708
+ connectedClients: [viewerClientId],
16709
+ // No Session.attach() ran, so no history policy was applied —
16710
+ // the viewer always gets full history. Report "full" so the
16711
+ // wire shape matches the normal attach response.
16712
+ historyPolicy: "full",
16713
+ replayed: history.length,
16714
+ _meta: buildViewerResponseMeta(fromDisk)
16715
+ };
16716
+ }
16160
16717
  if (!session) {
16161
16718
  const fromDisk = await deps.manager.loadFromDisk(lookupId);
16162
16719
  let resurrectParams = fromDisk;
@@ -16200,10 +16757,11 @@ function registerAcpWsEndpoint(app, deps) {
16200
16757
  );
16201
16758
  state.attached.set(session.sessionId, {
16202
16759
  sessionId: session.sessionId,
16203
- clientId: client.clientId
16760
+ clientId: client.clientId,
16761
+ readonly
16204
16762
  });
16205
16763
  app.log.info(
16206
- `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length}`
16764
+ `session/attach OK sessionId=${session.sessionId} clientId=${client.clientId} attachedCount=${state.attached.size} requestedPolicy=${params.historyPolicy} appliedPolicy=${appliedPolicy} replayed=${replay.length} readonly=${readonly}`
16207
16765
  );
16208
16766
  for (const note of replay) {
16209
16767
  await connection.notify(note.method, note.params);
@@ -16247,6 +16805,7 @@ function registerAcpWsEndpoint(app, deps) {
16247
16805
  });
16248
16806
  connection.onRequest("session/prompt", async (raw) => {
16249
16807
  const params = SessionPromptParams.parse(raw);
16808
+ denyIfReadonly(params.sessionId, "session/prompt");
16250
16809
  const att = state.attached.get(params.sessionId);
16251
16810
  if (!att) {
16252
16811
  app.log.warn(
@@ -16295,6 +16854,12 @@ function registerAcpWsEndpoint(app, deps) {
16295
16854
  if (!att) {
16296
16855
  return;
16297
16856
  }
16857
+ if (att.readonly) {
16858
+ app.log.warn(
16859
+ `session/cancel dropped (readonly attachment) sessionId=${params.sessionId}`
16860
+ );
16861
+ return;
16862
+ }
16298
16863
  const session = deps.manager.get(params.sessionId);
16299
16864
  if (!session) {
16300
16865
  return;
@@ -16307,11 +16872,14 @@ function registerAcpWsEndpoint(app, deps) {
16307
16872
  };
16308
16873
  connection.onNotification("session/cancel", handleCancelParams);
16309
16874
  connection.onRequest("session/cancel", async (raw) => {
16875
+ const params = SessionCancelParams.parse(raw);
16876
+ denyIfReadonly(params.sessionId, "session/cancel");
16310
16877
  handleCancelParams(raw);
16311
16878
  return null;
16312
16879
  });
16313
16880
  connection.onRequest("hydra-acp/cancel_prompt", async (raw) => {
16314
16881
  const params = CancelPromptParams.parse(raw);
16882
+ denyIfReadonly(params.sessionId, "hydra-acp/cancel_prompt");
16315
16883
  const session = deps.manager.get(params.sessionId);
16316
16884
  if (!session) {
16317
16885
  const err = new Error(`session ${params.sessionId} not found`);
@@ -16322,6 +16890,7 @@ function registerAcpWsEndpoint(app, deps) {
16322
16890
  });
16323
16891
  connection.onRequest("hydra-acp/update_prompt", async (raw) => {
16324
16892
  const params = UpdatePromptParams.parse(raw);
16893
+ denyIfReadonly(params.sessionId, "hydra-acp/update_prompt");
16325
16894
  const session = deps.manager.get(params.sessionId);
16326
16895
  if (!session) {
16327
16896
  const err = new Error(`session ${params.sessionId} not found`);
@@ -16332,6 +16901,7 @@ function registerAcpWsEndpoint(app, deps) {
16332
16901
  });
16333
16902
  connection.onRequest("hydra-acp/amend_prompt", async (raw) => {
16334
16903
  const params = AmendPromptParams.parse(raw);
16904
+ denyIfReadonly(params.sessionId, "hydra-acp/amend_prompt");
16335
16905
  const att = state.attached.get(params.sessionId);
16336
16906
  if (!att) {
16337
16907
  const err = new Error("not attached to session");
@@ -16371,7 +16941,8 @@ function registerAcpWsEndpoint(app, deps) {
16371
16941
  const { entries: replay } = await session.attach(client, "pending_only");
16372
16942
  state.attached.set(session.sessionId, {
16373
16943
  sessionId: session.sessionId,
16374
- clientId: client.clientId
16944
+ clientId: client.clientId,
16945
+ readonly: false
16375
16946
  });
16376
16947
  for (const note of replay) {
16377
16948
  await connection.notify(note.method, note.params);
@@ -16390,6 +16961,10 @@ function registerAcpWsEndpoint(app, deps) {
16390
16961
  };
16391
16962
  });
16392
16963
  connection.onRequest("session/set_model", async (rawParams) => {
16964
+ const sessionIdField = rawParams?.sessionId;
16965
+ if (typeof sessionIdField === "string") {
16966
+ denyIfReadonly(sessionIdField, "session/set_model");
16967
+ }
16393
16968
  const decision = decideSetModel(rawParams, deps.manager);
16394
16969
  if (decision.kind === "error") {
16395
16970
  app.log.warn(decision.logMessage);
@@ -16423,6 +16998,7 @@ function registerAcpWsEndpoint(app, deps) {
16423
16998
  err.code = JsonRpcErrorCodes.MethodNotFound;
16424
16999
  throw err;
16425
17000
  }
17001
+ denyIfReadonly(sessionId, method);
16426
17002
  const session = deps.manager.get(sessionId);
16427
17003
  if (!session) {
16428
17004
  const err = new Error(`session ${sessionId} not found`);
@@ -16561,6 +17137,38 @@ function decideSetModel(rawParams, manager) {
16561
17137
  logMessage: `session/set_model accepted sessionId=${params.sessionId} modelId=${JSON.stringify(params.modelId)}`
16562
17138
  };
16563
17139
  }
17140
+ function buildViewerResponseMeta(fromDisk) {
17141
+ const ours = {
17142
+ upstreamSessionId: fromDisk.upstreamSessionId,
17143
+ agentId: fromDisk.agentId,
17144
+ cwd: fromDisk.cwd
17145
+ };
17146
+ if (fromDisk.title !== void 0) {
17147
+ ours.name = fromDisk.title;
17148
+ }
17149
+ if (fromDisk.agentArgs && fromDisk.agentArgs.length > 0) {
17150
+ ours.agentArgs = fromDisk.agentArgs;
17151
+ }
17152
+ if (fromDisk.currentModel !== void 0) {
17153
+ ours.currentModel = fromDisk.currentModel;
17154
+ }
17155
+ if (fromDisk.currentMode !== void 0) {
17156
+ ours.currentMode = fromDisk.currentMode;
17157
+ }
17158
+ if (fromDisk.currentUsage !== void 0) {
17159
+ ours.currentUsage = fromDisk.currentUsage;
17160
+ }
17161
+ if (fromDisk.agentCommands && fromDisk.agentCommands.length > 0) {
17162
+ ours.availableCommands = fromDisk.agentCommands;
17163
+ }
17164
+ if (fromDisk.agentModes && fromDisk.agentModes.length > 0) {
17165
+ ours.availableModes = fromDisk.agentModes;
17166
+ }
17167
+ if (fromDisk.agentModels && fromDisk.agentModels.length > 0) {
17168
+ ours.availableModels = fromDisk.agentModels;
17169
+ }
17170
+ return { [HYDRA_META_KEY]: ours };
17171
+ }
16564
17172
  function buildResponseMeta(session) {
16565
17173
  const ours = {
16566
17174
  upstreamSessionId: session.upstreamSessionId,
@@ -16836,9 +17444,9 @@ import * as fs16 from "fs";
16836
17444
  import * as fsp6 from "fs/promises";
16837
17445
  async function runLogTail(logPath, argv, notFoundMessage) {
16838
17446
  const opts = parseLogTailFlags(argv);
16839
- let stat4;
17447
+ let stat5;
16840
17448
  try {
16841
- stat4 = await fsp6.stat(logPath);
17449
+ stat5 = await fsp6.stat(logPath);
16842
17450
  } catch (err) {
16843
17451
  const e = err;
16844
17452
  if (e.code === "ENOENT") {
@@ -16849,7 +17457,7 @@ async function runLogTail(logPath, argv, notFoundMessage) {
16849
17457
  }
16850
17458
  throw err;
16851
17459
  }
16852
- let position = await printTail(logPath, stat4.size, opts.tail);
17460
+ let position = await printTail(logPath, stat5.size, opts.tail);
16853
17461
  if (!opts.follow) {
16854
17462
  return;
16855
17463
  }
@@ -16884,10 +17492,10 @@ async function runLogTail(logPath, argv, notFoundMessage) {
16884
17492
  }
16885
17493
  });
16886
17494
  });
16887
- await new Promise((resolve5) => {
17495
+ await new Promise((resolve6) => {
16888
17496
  const finish = () => {
16889
17497
  watcher.close();
16890
- resolve5();
17498
+ resolve6();
16891
17499
  };
16892
17500
  process.once("SIGINT", finish);
16893
17501
  process.once("SIGTERM", finish);
@@ -17693,7 +18301,7 @@ async function promptPassword(prompt) {
17693
18301
  if (!process.stdin.isTTY) {
17694
18302
  return readLineFromStdin();
17695
18303
  }
17696
- return new Promise((resolve5, reject) => {
18304
+ return new Promise((resolve6, reject) => {
17697
18305
  const stdin = process.stdin;
17698
18306
  const wasRaw = stdin.isRaw === true;
17699
18307
  let buffer = "";
@@ -17710,7 +18318,7 @@ async function promptPassword(prompt) {
17710
18318
  if (byte === 10 || byte === 13) {
17711
18319
  process.stdout.write("\n");
17712
18320
  cleanup();
17713
- resolve5(buffer);
18321
+ resolve6(buffer);
17714
18322
  return;
17715
18323
  }
17716
18324
  if (byte === 3) {
@@ -17736,7 +18344,7 @@ async function promptPassword(prompt) {
17736
18344
  });
17737
18345
  }
17738
18346
  function readLineFromStdin() {
17739
- return new Promise((resolve5, reject) => {
18347
+ return new Promise((resolve6, reject) => {
17740
18348
  let buffer = "";
17741
18349
  process.stdin.setEncoding("utf8");
17742
18350
  const onData = (chunk) => {
@@ -17745,7 +18353,7 @@ function readLineFromStdin() {
17745
18353
  if (nl !== -1) {
17746
18354
  process.stdin.removeListener("data", onData);
17747
18355
  process.stdin.removeListener("error", onError);
17748
- resolve5(buffer.slice(0, nl).replace(/\r$/, ""));
18356
+ resolve6(buffer.slice(0, nl).replace(/\r$/, ""));
17749
18357
  }
17750
18358
  };
17751
18359
  const onError = (err) => {
@@ -18542,8 +19150,15 @@ async function dispatchTui(flags, base) {
18542
19150
  const cwd = resolveOption(flags, "cwd");
18543
19151
  const resume = flags.reattach === true;
18544
19152
  const forceNew = flags.new === true;
19153
+ const readonly = flags.readonly === true;
19154
+ if (readonly && base.sessionId === void 0) {
19155
+ process.stderr.write(
19156
+ "hydra-acp: --readonly requires a session id. Pass --resume <id> --readonly, or open the picker and press `v` on a session.\n"
19157
+ );
19158
+ process.exit(2);
19159
+ }
18545
19160
  const { runTui } = await Promise.resolve().then(() => (init_tui(), tui_exports));
18546
- const tuiOpts = { resume, forceNew };
19161
+ const tuiOpts = { resume, forceNew, readonly };
18547
19162
  if (base.sessionId !== void 0) {
18548
19163
  tuiOpts.sessionId = base.sessionId;
18549
19164
  }
@@ -18571,7 +19186,7 @@ function readVersion() {
18571
19186
  try {
18572
19187
  const here = dirname6(fileURLToPath2(import.meta.url));
18573
19188
  const pkg = JSON.parse(
18574
- readFileSync2(resolve4(here, "../package.json"), "utf8")
19189
+ readFileSync2(resolve5(here, "../package.json"), "utf8")
18575
19190
  );
18576
19191
  return pkg.version ?? "unknown";
18577
19192
  } catch {
@@ -18618,8 +19233,9 @@ function printHelp() {
18618
19233
  " hydra-acp auth password [--force] Set the daemon's master password",
18619
19234
  " hydra-acp auth [list] List active session tokens",
18620
19235
  " hydra-acp auth revoke <id> Revoke a session token",
18621
- " hydra-acp tui flags: [--resume <id>] [--reattach] [--new] [--agent <id>] [--model <id>] [--cwd <path>] [--name <label>]",
19236
+ " hydra-acp tui flags: [--resume <id>] [--reattach] [--new] [--readonly] [--agent <id>] [--model <id>] [--cwd <path>] [--name <label>]",
18622
19237
  " --resume <id> attaches to a specific session; --reattach picks the most-recent in cwd.",
19238
+ " --readonly opens a session as a transcript viewer (no agent spawn, no prompting). Requires --resume.",
18623
19239
  " Smart default (no flags): shows a picker when sessions exist, else new.",
18624
19240
  " hydra-acp --version Print version",
18625
19241
  " hydra-acp --help Show this help",