@hydra-acp/cli 0.1.12 → 0.1.13

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
@@ -749,6 +749,11 @@ var init_hydra_commands = __esm({
749
749
  name: "hydra agent",
750
750
  argsHint: "<agent>",
751
751
  description: "Swap the agent backing this session, preserving context"
752
+ },
753
+ {
754
+ verb: "kill",
755
+ name: "hydra kill",
756
+ description: "Close this session (kills the agent; record is kept so it can be resumed later)"
752
757
  }
753
758
  ];
754
759
  VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -1635,6 +1640,8 @@ var init_session = __esm({
1635
1640
  return this.runTitleCommand(arg);
1636
1641
  case "agent":
1637
1642
  return this.runAgentCommand(arg);
1643
+ case "kill":
1644
+ return this.runKillCommand();
1638
1645
  default: {
1639
1646
  const err = new Error(
1640
1647
  `no dispatcher for /hydra verb ${verb}`
@@ -1746,6 +1753,17 @@ var init_session = __esm({
1746
1753
  return { stopReason: "end_turn" };
1747
1754
  });
1748
1755
  }
1756
+ // Close this session in-place. Bypasses enqueuePrompt deliberately so a
1757
+ // mid-turn /hydra kill takes effect immediately — agent.kill() will tear
1758
+ // down any in-flight request as a side effect. The record is kept
1759
+ // (deleteRecord:false) so the session goes cold and can be resurrected.
1760
+ // Returns end_turn so the prompt() caller's response resolves normally,
1761
+ // but every attached client has already received hydra-acp/session_closed
1762
+ // by the time this returns.
1763
+ async runKillCommand() {
1764
+ await this.close({ deleteRecord: false });
1765
+ return { stopReason: "end_turn" };
1766
+ }
1749
1767
  // Walk the persisted history and produce a labeled transcript suitable
1750
1768
  // for handing to a fresh agent. Includes user prompts, agent replies,
1751
1769
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
@@ -2058,7 +2076,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2058
2076
  }
2059
2077
  async enqueuePrompt(task) {
2060
2078
  return new Promise((resolve5, reject) => {
2061
- const run2 = async () => {
2079
+ const run3 = async () => {
2062
2080
  try {
2063
2081
  const result = await task();
2064
2082
  resolve5(result);
@@ -2066,7 +2084,7 @@ _(switched from \`${oldAgentId}\` to \`${newAgentId}\`)_
2066
2084
  reject(err);
2067
2085
  }
2068
2086
  };
2069
- this.promptQueue.push(run2);
2087
+ this.promptQueue.push(run3);
2070
2088
  void this.drainQueue();
2071
2089
  });
2072
2090
  }
@@ -3410,8 +3428,15 @@ async function pickSession(term, opts) {
3410
3428
  let total = 1 + visible.length;
3411
3429
  let selectedIdx = 0;
3412
3430
  let scrollOffset = 0;
3431
+ if (opts.currentSessionId !== void 0) {
3432
+ const idx = visible.findIndex((s) => s.sessionId === opts.currentSessionId);
3433
+ if (idx >= 0) {
3434
+ selectedIdx = idx + 1;
3435
+ }
3436
+ }
3413
3437
  let searchActive = false;
3414
3438
  let searchTerm = "";
3439
+ let cwdOnly = false;
3415
3440
  let mode = "normal";
3416
3441
  let pendingAction = null;
3417
3442
  let transientStatus = null;
@@ -3440,10 +3465,14 @@ async function pickSession(term, opts) {
3440
3465
  computeLayout();
3441
3466
  };
3442
3467
  const applyFilter = () => {
3468
+ let base = allSessions;
3469
+ if (cwdOnly) {
3470
+ base = base.filter((s) => s.cwd === opts.cwd);
3471
+ }
3443
3472
  if (searchActive && searchTerm.length > 0) {
3444
- visible = allSessions.filter((s) => matchesSearch(s, searchTerm));
3473
+ visible = base.filter((s) => matchesSearch(s, searchTerm));
3445
3474
  } else {
3446
- visible = allSessions;
3475
+ visible = base;
3447
3476
  }
3448
3477
  rebuildRows();
3449
3478
  if (searchActive) {
@@ -3488,16 +3517,19 @@ async function pickSession(term, opts) {
3488
3517
  const formatIndicator = () => {
3489
3518
  const above = scrollOffset;
3490
3519
  const below = Math.max(0, visible.length - scrollOffset - viewportSize);
3491
- if (above === 0 && below === 0) {
3492
- return "";
3493
- }
3494
3520
  const parts = [];
3521
+ if (cwdOnly) {
3522
+ parts.push("cwd-only");
3523
+ }
3495
3524
  if (above > 0) {
3496
3525
  parts.push(`\u2191 ${above} above`);
3497
3526
  }
3498
3527
  if (below > 0) {
3499
3528
  parts.push(`\u2193 ${below} below`);
3500
3529
  }
3530
+ if (parts.length === 0) {
3531
+ return "";
3532
+ }
3501
3533
  return ` ${parts.join(" \xB7 ")}`;
3502
3534
  };
3503
3535
  const shortId2 = (sessionId) => stripHydraSessionPrefix(sessionId);
@@ -3721,6 +3753,38 @@ async function pickSession(term, opts) {
3721
3753
  renderFromScratch();
3722
3754
  return;
3723
3755
  }
3756
+ if (name === "n" || name === "N") {
3757
+ move(1);
3758
+ return;
3759
+ }
3760
+ if (name === "p" || name === "P") {
3761
+ move(-1);
3762
+ return;
3763
+ }
3764
+ if (name === "c" || name === "C") {
3765
+ cleanup();
3766
+ resolve5({ kind: "new" });
3767
+ return;
3768
+ }
3769
+ if (name === "q" || name === "Q") {
3770
+ cleanup();
3771
+ resolve5({ kind: "abort" });
3772
+ return;
3773
+ }
3774
+ if (name === "o" || name === "O") {
3775
+ const keepId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
3776
+ cwdOnly = !cwdOnly;
3777
+ applyFilter();
3778
+ if (keepId !== void 0) {
3779
+ const idx = visible.findIndex((s) => s.sessionId === keepId);
3780
+ if (idx >= 0) {
3781
+ selectedIdx = idx + 1;
3782
+ adjustScroll();
3783
+ }
3784
+ }
3785
+ renderFromScratch();
3786
+ return;
3787
+ }
3724
3788
  if (name === "r" || name === "R") {
3725
3789
  const currentId = selectedIdx > 0 ? visible[selectedIdx - 1]?.sessionId : void 0;
3726
3790
  void refresh(currentId);
@@ -3859,6 +3923,90 @@ var init_picker = __esm({
3859
3923
  }
3860
3924
  });
3861
3925
 
3926
+ // src/tui/attachments.ts
3927
+ import path9 from "path";
3928
+ function mimeFromExtension(p) {
3929
+ return EXTENSION_TO_MIME[path9.extname(p).toLowerCase()] ?? null;
3930
+ }
3931
+ function isSupportedImagePath(p) {
3932
+ return mimeFromExtension(p) !== null;
3933
+ }
3934
+ function formatSize(bytes) {
3935
+ if (bytes >= 1024 * 1024) {
3936
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
3937
+ }
3938
+ if (bytes >= 1024) {
3939
+ return `${(bytes / 1024).toFixed(0)}KB`;
3940
+ }
3941
+ return `${bytes}B`;
3942
+ }
3943
+ function parseImageDropPaste(raw) {
3944
+ const text = raw.trim();
3945
+ if (text.length === 0) {
3946
+ return null;
3947
+ }
3948
+ const tokens = [];
3949
+ let i = 0;
3950
+ while (i < text.length) {
3951
+ while (i < text.length && /\s/.test(text[i] ?? "")) {
3952
+ i++;
3953
+ }
3954
+ if (i >= text.length) {
3955
+ break;
3956
+ }
3957
+ const ch = text[i];
3958
+ let token = "";
3959
+ if (ch === "'" || ch === '"') {
3960
+ const quote = ch;
3961
+ i++;
3962
+ while (i < text.length && text[i] !== quote) {
3963
+ token += text[i];
3964
+ i++;
3965
+ }
3966
+ if (i >= text.length) {
3967
+ return null;
3968
+ }
3969
+ i++;
3970
+ } else {
3971
+ while (i < text.length && !/\s/.test(text[i] ?? "")) {
3972
+ if (text[i] === "\\" && i + 1 < text.length) {
3973
+ token += text[i + 1];
3974
+ i += 2;
3975
+ } else {
3976
+ token += text[i];
3977
+ i++;
3978
+ }
3979
+ }
3980
+ }
3981
+ let normalized = token;
3982
+ if (normalized.startsWith("file://")) {
3983
+ normalized = decodeURI(normalized.slice("file://".length));
3984
+ }
3985
+ if (!normalized.startsWith("/")) {
3986
+ return null;
3987
+ }
3988
+ if (!isSupportedImagePath(normalized)) {
3989
+ return null;
3990
+ }
3991
+ tokens.push(normalized);
3992
+ }
3993
+ return tokens.length > 0 ? tokens : null;
3994
+ }
3995
+ var MAX_ATTACHMENT_BYTES, EXTENSION_TO_MIME;
3996
+ var init_attachments = __esm({
3997
+ "src/tui/attachments.ts"() {
3998
+ "use strict";
3999
+ MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
4000
+ EXTENSION_TO_MIME = {
4001
+ ".png": "image/png",
4002
+ ".jpg": "image/jpeg",
4003
+ ".jpeg": "image/jpeg",
4004
+ ".gif": "image/gif",
4005
+ ".webp": "image/webp"
4006
+ };
4007
+ }
4008
+ });
4009
+
3862
4010
  // src/tui/screen.ts
3863
4011
  import stringWidth from "string-width";
3864
4012
  import wrapAnsi from "wrap-ansi";
@@ -3867,7 +4015,8 @@ function formattedLineSig(zone, width, line, highlight2 = null, activeCol = null
3867
4015
  if (!line) {
3868
4016
  return `${zone}|${width}|empty|${highlight2 ?? ""}|${active}`;
3869
4017
  }
3870
- return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}`;
4018
+ const img = line.iterm2Image ? `i${line.iterm2Image.heightCells}:${line.iterm2Image.data.length}` : "";
4019
+ return `${zone}|${width}|${line.prefix ?? ""}|${line.prefixStyle ?? ""}|${line.body}|${line.bodyStyle ?? ""}|${line.ansi ? "1" : "0"}|${line.fillRow ? "1" : "0"}|${highlight2 ?? ""}|${active}|${img}`;
3871
4020
  }
3872
4021
  function computePromptVisualRows(buffer, room) {
3873
4022
  const rows = [];
@@ -4115,6 +4264,14 @@ function* segmentForWidth(text) {
4115
4264
  i = runEnd;
4116
4265
  }
4117
4266
  }
4267
+ function buildIterm2ImageEscape(base64, heightCells, insideTmux) {
4268
+ const inner = `\x1B]1337;File=inline=1;height=${heightCells};preserveAspectRatio=1:${base64}\x07`;
4269
+ if (!insideTmux) {
4270
+ return inner;
4271
+ }
4272
+ const doubled = inner.replace(/\x1b/g, "\x1B\x1B");
4273
+ return `\x1BPtmux;${doubled}\x1B\\`;
4274
+ }
4118
4275
  function wrap(text, width, opts = {}) {
4119
4276
  if (width <= 0) {
4120
4277
  return [text];
@@ -4364,6 +4521,8 @@ function mapKeyName(name) {
4364
4521
  return "ctrl-s";
4365
4522
  case "CTRL_U":
4366
4523
  return "ctrl-u";
4524
+ case "CTRL_V":
4525
+ return "ctrl-v";
4367
4526
  case "CTRL_W":
4368
4527
  return "ctrl-w";
4369
4528
  case "CTRL_Y":
@@ -4374,13 +4533,14 @@ function mapKeyName(name) {
4374
4533
  return null;
4375
4534
  }
4376
4535
  }
4377
- var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
4536
+ var HEADER_ROWS, BANNER_ROWS, SEPARATOR_ROWS, MAX_PROMPT_ROWS, MAX_QUEUED_ROWS, MAX_PERMISSION_ROWS, MAX_COMPLETION_ROWS, MAX_CHIP_ROWS, CONFIRM_PROMPT_ROWS, DEFAULT_CONTENT_REPAINT_THROTTLE_MS, DEFAULT_MAX_SCROLLBACK_LINES, Screen, NON_ASCII, SEGMENTER, TK_MARKUP_STYLE_CHAR, shortId;
4378
4537
  var init_screen = __esm({
4379
4538
  "src/tui/screen.ts"() {
4380
4539
  "use strict";
4381
4540
  init_agent_display();
4382
4541
  init_paths();
4383
4542
  init_session();
4543
+ init_attachments();
4384
4544
  HEADER_ROWS = 2;
4385
4545
  BANNER_ROWS = 1;
4386
4546
  SEPARATOR_ROWS = 1;
@@ -4388,6 +4548,7 @@ var init_screen = __esm({
4388
4548
  MAX_QUEUED_ROWS = 5;
4389
4549
  MAX_PERMISSION_ROWS = 12;
4390
4550
  MAX_COMPLETION_ROWS = 6;
4551
+ MAX_CHIP_ROWS = 4;
4391
4552
  CONFIRM_PROMPT_ROWS = 2;
4392
4553
  DEFAULT_CONTENT_REPAINT_THROTTLE_MS = 1e3;
4393
4554
  DEFAULT_MAX_SCROLLBACK_LINES = 1e4;
@@ -4407,6 +4568,12 @@ var init_screen = __esm({
4407
4568
  lastPromptRows = 0;
4408
4569
  queuedTexts = [];
4409
4570
  lastQueueEditingIndex = -1;
4571
+ // Attachments on the current draft, pushed by the app whenever the
4572
+ // dispatcher mutates. The chip zone (drawAttachmentChipZone) renders
4573
+ // one row per attachment plus, in iTerm2-capable terminals, an inline
4574
+ // thumbnail. Capped at MAX_CHIP_ROWS in the visible zone — additional
4575
+ // chips collapse into an overflow row.
4576
+ attachments = [];
4410
4577
  repaintPaused = 0;
4411
4578
  repaintPending = false;
4412
4579
  lastRepaintAt = 0;
@@ -4469,7 +4636,7 @@ var init_screen = __esm({
4469
4636
  banner = {
4470
4637
  status: "ready",
4471
4638
  planMode: false,
4472
- hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
4639
+ hint: "\u21E7\u21E5 plan \xB7 \u2325\u23CE newline \xB7 \u2303V paste \xB7 \u2303P pick \xB7 \u2303C cancel \xB7 \u2303D detach",
4473
4640
  queued: 0
4474
4641
  };
4475
4642
  header = { agent: "?", cwd: "?", sessionId: "?" };
@@ -4600,7 +4767,12 @@ var init_screen = __esm({
4600
4767
  this.pasteActive = false;
4601
4768
  const pasted = Buffer.from(this.pasteBuffer, "binary").toString("utf-8").replace(/\r\n?/g, "\n");
4602
4769
  this.pasteBuffer = "";
4603
- this.onKey([{ type: "paste", text: pasted }]);
4770
+ const paths2 = parseImageDropPaste(pasted);
4771
+ if (paths2 !== null) {
4772
+ this.onKey([{ type: "attachment-paths", paths: paths2 }]);
4773
+ } else {
4774
+ this.onKey([{ type: "paste", text: pasted }]);
4775
+ }
4604
4776
  continue;
4605
4777
  }
4606
4778
  const startIdx = text.indexOf(startMarker);
@@ -5351,7 +5523,7 @@ var init_screen = __esm({
5351
5523
  }
5352
5524
  scrollbackVisibleRows() {
5353
5525
  const top = HEADER_ROWS + SEPARATOR_ROWS;
5354
- const bottom = this.term.height - this.promptRows() - BANNER_ROWS - SEPARATOR_ROWS - this.queuedRows() - this.completionRows();
5526
+ const bottom = this.term.height - this.promptRows() - BANNER_ROWS - SEPARATOR_ROWS - this.chipRows() - this.queuedRows() - this.completionRows();
5355
5527
  return Math.max(0, bottom - top + 1);
5356
5528
  }
5357
5529
  maxScrollOffset() {
@@ -5433,6 +5605,7 @@ var init_screen = __esm({
5433
5605
  this.drawScrollback();
5434
5606
  this.drawCompletionZone();
5435
5607
  this.drawQueuedZone();
5608
+ this.drawAttachmentChipZone();
5436
5609
  const promptRows = this.promptRows();
5437
5610
  const separatorRow = h - promptRows - BANNER_ROWS;
5438
5611
  this.drawSeparator(separatorRow);
@@ -5524,6 +5697,16 @@ var init_screen = __esm({
5524
5697
  queuedRows() {
5525
5698
  return Math.min(MAX_QUEUED_ROWS, this.queuedTexts.length);
5526
5699
  }
5700
+ chipRows() {
5701
+ return Math.min(MAX_CHIP_ROWS, this.attachments.length);
5702
+ }
5703
+ setAttachments(attachments) {
5704
+ if (this.attachments.length === attachments.length && this.attachments.every((a, i) => a === attachments[i])) {
5705
+ return;
5706
+ }
5707
+ this.attachments = [...attachments];
5708
+ this.repaint();
5709
+ }
5527
5710
  completionRows() {
5528
5711
  if (this.permissionPrompt) {
5529
5712
  return 0;
@@ -5539,7 +5722,8 @@ var init_screen = __esm({
5539
5722
  const promptRows = this.promptRows();
5540
5723
  const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5541
5724
  const queuedRows = this.queuedRows();
5542
- const completionBottom = separatorRow - 1 - queuedRows;
5725
+ const chipRows = this.chipRows();
5726
+ const completionBottom = separatorRow - 1 - queuedRows - chipRows;
5543
5727
  const completionTop = completionBottom - rows + 1;
5544
5728
  let nameWidth = 0;
5545
5729
  for (const item of this.completions.slice(0, rows)) {
@@ -5572,6 +5756,58 @@ var init_screen = __esm({
5572
5756
  });
5573
5757
  }
5574
5758
  }
5759
+ // Chip zone: one row per attached image, sitting between the queued
5760
+ // zone and the separator (closest to the user's draft). Each row
5761
+ // shows "📎 <name> · <size>" plus, in iTerm2-capable terminals, a
5762
+ // tiny inline thumbnail at the end. Overflow collapses into a
5763
+ // single "+ N more attached" row.
5764
+ drawAttachmentChipZone() {
5765
+ const rows = this.chipRows();
5766
+ if (rows === 0) {
5767
+ return;
5768
+ }
5769
+ const w = this.term.width;
5770
+ const promptRows = this.promptRows();
5771
+ const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5772
+ const chipBottom = separatorRow - 1;
5773
+ const chipTop = chipBottom - rows + 1;
5774
+ const iterm = this.isIterm2();
5775
+ for (let i = 0; i < rows; i++) {
5776
+ const row = chipTop + i;
5777
+ const isLast = i === rows - 1 && this.attachments.length > MAX_CHIP_ROWS;
5778
+ const overflow = this.attachments.length - MAX_CHIP_ROWS;
5779
+ const att = this.attachments[i];
5780
+ const label = att ? `${att.name ?? "image"} \xB7 ${formatSize(att.sizeBytes)}` : "";
5781
+ const sig = isLast ? `chip|${w}|overflow|${overflow}` : att ? `chip|${w}|${iterm ? "i" : "t"}|${label}|${att.sizeBytes}` : `chip|${w}|empty`;
5782
+ this.paintRow(row, sig, () => {
5783
+ if (isLast) {
5784
+ this.term.dim(` \u{1F4CE} + ${overflow + 1} more attached`);
5785
+ return;
5786
+ }
5787
+ if (!att) {
5788
+ return;
5789
+ }
5790
+ this.term(" ").yellow(`\u{1F4CE} ${label}`);
5791
+ if (iterm) {
5792
+ this.term(" ");
5793
+ this.writeIterm2Image(att.data, 1);
5794
+ }
5795
+ });
5796
+ }
5797
+ }
5798
+ isIterm2() {
5799
+ const env = process.env;
5800
+ return env.LC_TERMINAL === "iTerm2" || env.TERM_PROGRAM === "iTerm.app";
5801
+ }
5802
+ // Emits the iTerm2 OSC 1337 inline image escape at the current
5803
+ // cursor position. Wraps in DCS-passthrough when tmux is detected
5804
+ // (requires `set -g allow-passthrough on` in the user's tmux conf).
5805
+ // Caller is responsible for knowing iTerm2 is the active terminal.
5806
+ writeIterm2Image(base64, heightCells) {
5807
+ process.stdout.write(
5808
+ buildIterm2ImageEscape(base64, heightCells, Boolean(process.env.TMUX))
5809
+ );
5810
+ }
5575
5811
  drawQueuedZone() {
5576
5812
  const rows = this.queuedRows();
5577
5813
  if (rows === 0) {
@@ -5580,7 +5816,8 @@ var init_screen = __esm({
5580
5816
  const w = this.term.width;
5581
5817
  const promptRows = this.promptRows();
5582
5818
  const separatorRow = this.term.height - promptRows - BANNER_ROWS;
5583
- const queuedBottom = separatorRow - 1;
5819
+ const chipRows = this.chipRows();
5820
+ const queuedBottom = separatorRow - 1 - chipRows;
5584
5821
  const queuedTop = queuedBottom - rows + 1;
5585
5822
  const editingIndex = this.dispatcher.state().queueIndex;
5586
5823
  for (let i = 0; i < rows; i++) {
@@ -5729,6 +5966,8 @@ var init_screen = __esm({
5729
5966
  }
5730
5967
  } else if (this.banner.status === "disconnected") {
5731
5968
  this.term.brightRed(`${dot} ${this.banner.status}`);
5969
+ } else if (this.banner.status === "cold") {
5970
+ this.term.brightMagenta(`${dot} ${this.banner.status}`);
5732
5971
  } else {
5733
5972
  this.term.brightGreen(`${dot} ${this.banner.status}`);
5734
5973
  }
@@ -5883,6 +6122,9 @@ var init_screen = __esm({
5883
6122
  if (line.ansi) {
5884
6123
  wrappedLine.ansi = true;
5885
6124
  }
6125
+ if (i === 0 && line.iterm2Image) {
6126
+ wrappedLine.iterm2Image = line.iterm2Image;
6127
+ }
5886
6128
  if (id !== void 0 && chunk.length > 0) {
5887
6129
  const found = line.body.indexOf(chunk, scanPos);
5888
6130
  const colOffset = found === -1 ? scanPos : found;
@@ -5928,6 +6170,12 @@ var init_screen = __esm({
5928
6170
  if (line.ansi || line.body.includes("^")) {
5929
6171
  this.term.styleReset();
5930
6172
  }
6173
+ if (line.iterm2Image && this.isIterm2()) {
6174
+ this.writeIterm2Image(
6175
+ line.iterm2Image.data,
6176
+ line.iterm2Image.heightCells
6177
+ );
6178
+ }
5931
6179
  }
5932
6180
  };
5933
6181
  NON_ASCII = /[^\x20-\x7e]/;
@@ -5972,6 +6220,17 @@ var init_input = __esm({
5972
6220
  // here so ^Y can yank it back. Standard readline keeps a stack; we
5973
6221
  // only keep one slot because that's what 99% of yank uses look like.
5974
6222
  killBuffer = "";
6223
+ // Images attached to the current draft. Cleared in the same paths
6224
+ // that clear the text buffer (clearBuffer, after send). Queue
6225
+ // navigation snapshots/restores them alongside savedDraft so up/down
6226
+ // through queued items doesn't drop chips.
6227
+ attachments = [];
6228
+ // Snapshot of `attachments` taken when the user starts walking
6229
+ // history/queue with chips already attached. Restored alongside the
6230
+ // text draft when the walk ends. Distinct from savedDraft because
6231
+ // queue slots (which may carry their own attachments — though we
6232
+ // don't surface that yet) shouldn't blend with the current draft's.
6233
+ savedAttachments = null;
5975
6234
  constructor(opts = {}) {
5976
6235
  this.history = [...opts.history ?? []];
5977
6236
  this.planMode = opts.planMode ?? false;
@@ -5984,9 +6243,22 @@ var init_input = __esm({
5984
6243
  planMode: this.planMode,
5985
6244
  historyIndex: this.historyIndex,
5986
6245
  queueIndex: this.queueIndex,
6246
+ attachments: [...this.attachments],
5987
6247
  historySearchQuery: this.historySearch?.query ?? null
5988
6248
  };
5989
6249
  }
6250
+ // App calls this after asynchronously acquiring an image (drag-drop
6251
+ // file read, clipboard shellout). The dispatcher just records it;
6252
+ // chip rendering and capability gating live in the app/screen layer.
6253
+ addAttachment(attachment) {
6254
+ this.attachments.push(attachment);
6255
+ }
6256
+ removeAttachment(index) {
6257
+ if (index < 0 || index >= this.attachments.length) {
6258
+ return;
6259
+ }
6260
+ this.attachments.splice(index, 1);
6261
+ }
5990
6262
  setTurnRunning(running) {
5991
6263
  this.turnRunning = running;
5992
6264
  }
@@ -6017,13 +6289,17 @@ var init_input = __esm({
6017
6289
  }
6018
6290
  // Public seed for the buffer (used for Escape pre-fill). Treated like a
6019
6291
  // fresh draft: nav state and any saved draft are cleared, cursor lands
6020
- // at the end so the user can edit immediately.
6021
- setBuffer(text) {
6292
+ // at the end so the user can edit immediately. Attachments restore
6293
+ // alongside the text so a cancelled turn's chips land back in the
6294
+ // draft together with the typed prompt.
6295
+ setBuffer(text, attachments = []) {
6022
6296
  this.loadEntry(text);
6023
6297
  this.historyIndex = -1;
6024
6298
  this.queueIndex = -1;
6025
6299
  this.savedDraft = null;
6300
+ this.savedAttachments = null;
6026
6301
  this.historySearch = null;
6302
+ this.attachments = [...attachments];
6027
6303
  }
6028
6304
  feed(event) {
6029
6305
  if (this.historySearch !== null) {
@@ -6069,6 +6345,9 @@ var init_input = __esm({
6069
6345
  this.insertText(event.text);
6070
6346
  return [];
6071
6347
  }
6348
+ if (event.type === "attachment-paths") {
6349
+ return [];
6350
+ }
6072
6351
  return this.handleKey(event.name);
6073
6352
  }
6074
6353
  handleKey(name) {
@@ -6145,6 +6424,8 @@ var init_input = __esm({
6145
6424
  case "ctrl-u":
6146
6425
  this.killLine();
6147
6426
  return [];
6427
+ case "ctrl-v":
6428
+ return [{ type: "attachment-request", source: "clipboard" }];
6148
6429
  case "ctrl-w":
6149
6430
  this.killWord();
6150
6431
  return [];
@@ -6177,7 +6458,9 @@ var init_input = __esm({
6177
6458
  this.historyIndex = -1;
6178
6459
  this.queueIndex = -1;
6179
6460
  this.savedDraft = null;
6461
+ this.savedAttachments = null;
6180
6462
  this.historySearch = null;
6463
+ this.attachments = [];
6181
6464
  }
6182
6465
  insertChar(ch) {
6183
6466
  if (ch.length === 0) {
@@ -6329,6 +6612,8 @@ var init_input = __esm({
6329
6612
  row: this.row,
6330
6613
  col: this.col
6331
6614
  };
6615
+ this.savedAttachments = [...this.attachments];
6616
+ this.attachments = [];
6332
6617
  if (this.queue.length > 0) {
6333
6618
  this.queueIndex = this.queue.length - 1;
6334
6619
  this.loadEntry(this.queue[this.queueIndex] ?? "");
@@ -6401,6 +6686,8 @@ var init_input = __esm({
6401
6686
  this.row = this.savedDraft.row;
6402
6687
  this.col = this.savedDraft.col;
6403
6688
  this.savedDraft = null;
6689
+ this.attachments = this.savedAttachments ?? [];
6690
+ this.savedAttachments = null;
6404
6691
  } else {
6405
6692
  this.clearBuffer();
6406
6693
  }
@@ -6554,18 +6841,20 @@ var init_input = __esm({
6554
6841
  const text = this.bufferText();
6555
6842
  if (this.queueIndex >= 0 && this.queueIndex < this.queue.length) {
6556
6843
  const index = this.queueIndex;
6844
+ const attachments2 = [...this.attachments];
6557
6845
  this.clearBuffer();
6558
6846
  if (text.trim().length === 0) {
6559
6847
  return [{ type: "queue-remove", index }];
6560
6848
  }
6561
- return [{ type: "queue-edit", index, text }];
6849
+ return [{ type: "queue-edit", index, text, attachments: attachments2 }];
6562
6850
  }
6563
- if (text.trim().length === 0) {
6851
+ if (text.trim().length === 0 && this.attachments.length === 0) {
6564
6852
  return [];
6565
6853
  }
6566
6854
  const planMode = this.planMode;
6855
+ const attachments = [...this.attachments];
6567
6856
  this.clearBuffer();
6568
- return [{ type: "send", text, planMode }];
6857
+ return [{ type: "send", text, planMode, attachments }];
6569
6858
  }
6570
6859
  // Home: jump to the very start of the prompt buffer. If we're already
6571
6860
  // there, fall through to scrolling the scrollback to its top.
@@ -6590,13 +6879,15 @@ var init_input = __esm({
6590
6879
  return [{ type: "scroll-to-bottom" }];
6591
6880
  }
6592
6881
  handleCtrlC() {
6593
- if (!this.bufferIsEmpty()) {
6882
+ if (!this.bufferIsEmpty() || this.attachments.length > 0) {
6594
6883
  this.buffer = [""];
6595
6884
  this.row = 0;
6596
6885
  this.col = 0;
6886
+ this.attachments = [];
6597
6887
  if (this.queueIndex === -1) {
6598
6888
  this.historyIndex = -1;
6599
6889
  this.savedDraft = null;
6890
+ this.savedAttachments = null;
6600
6891
  }
6601
6892
  return [];
6602
6893
  }
@@ -6614,6 +6905,232 @@ var init_input = __esm({
6614
6905
  }
6615
6906
  });
6616
6907
 
6908
+ // src/tui/clipboard.ts
6909
+ import { spawn as nodeSpawn } from "child_process";
6910
+ import fs14 from "fs/promises";
6911
+ import os4 from "os";
6912
+ import path10 from "path";
6913
+ async function readClipboard(envIn = {}) {
6914
+ const env = { ...defaultEnv, ...envIn };
6915
+ if (env.platform === "darwin") {
6916
+ return readMacOS(env);
6917
+ }
6918
+ if (env.platform === "linux") {
6919
+ return readLinux(env);
6920
+ }
6921
+ return {
6922
+ ok: false,
6923
+ reason: `clipboard paste is not supported on ${env.platform}`
6924
+ };
6925
+ }
6926
+ async function readMacOS(env) {
6927
+ const tmpPath = path10.join(
6928
+ env.tmpdir(),
6929
+ `hydra-clipboard-${Date.now()}-${process.pid}.png`
6930
+ );
6931
+ const script = [
6932
+ "set png_data to the clipboard as \xABclass PNGf\xBB",
6933
+ `set out_file to (open for access (POSIX file "${tmpPath}") with write permission)`,
6934
+ "write png_data to out_file",
6935
+ "close access out_file"
6936
+ ];
6937
+ const args = [];
6938
+ for (const line of script) {
6939
+ args.push("-e", line);
6940
+ }
6941
+ try {
6942
+ await run2(env.spawn, "osascript", args);
6943
+ const img = await readFileAsAttachment(tmpPath, true);
6944
+ if (img.ok) {
6945
+ return img;
6946
+ }
6947
+ if (img.reason.startsWith("clipboard image is")) {
6948
+ return img;
6949
+ }
6950
+ } catch {
6951
+ await fs14.unlink(tmpPath).catch(() => void 0);
6952
+ }
6953
+ try {
6954
+ const buf = await runCapture(env.spawn, "pbpaste", []);
6955
+ if (buf.length === 0) {
6956
+ return { ok: false, reason: "clipboard is empty" };
6957
+ }
6958
+ return { ok: true, kind: "text", text: normalizeText(buf.toString("utf-8")) };
6959
+ } catch {
6960
+ return { ok: false, reason: "clipboard read failed" };
6961
+ }
6962
+ }
6963
+ async function readLinux(env) {
6964
+ const tool = await detectLinuxTool(env);
6965
+ if (!tool) {
6966
+ return {
6967
+ ok: false,
6968
+ reason: "install wl-clipboard (Wayland) or xclip (X11) to paste from the clipboard"
6969
+ };
6970
+ }
6971
+ try {
6972
+ const buf = await runCapture(env.spawn, tool.cmd, tool.imageArgs);
6973
+ if (buf.length > 0) {
6974
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
6975
+ return {
6976
+ ok: false,
6977
+ reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
6978
+ };
6979
+ }
6980
+ return {
6981
+ ok: true,
6982
+ kind: "image",
6983
+ attachment: {
6984
+ mimeType: "image/png",
6985
+ data: buf.toString("base64"),
6986
+ sizeBytes: buf.length
6987
+ }
6988
+ };
6989
+ }
6990
+ } catch {
6991
+ }
6992
+ try {
6993
+ const buf = await runCapture(env.spawn, tool.cmd, tool.textArgs);
6994
+ if (buf.length === 0) {
6995
+ return { ok: false, reason: "clipboard is empty" };
6996
+ }
6997
+ return {
6998
+ ok: true,
6999
+ kind: "text",
7000
+ text: normalizeText(buf.toString("utf-8"))
7001
+ };
7002
+ } catch {
7003
+ return { ok: false, reason: "clipboard read failed" };
7004
+ }
7005
+ }
7006
+ async function detectLinuxTool(env) {
7007
+ if (env.env.WAYLAND_DISPLAY && await which(env, "wl-paste")) {
7008
+ return {
7009
+ cmd: "wl-paste",
7010
+ imageArgs: ["-t", "image/png"],
7011
+ // -n: drop trailing newline wl-paste adds by default. We further
7012
+ // normalize line endings below, but this avoids a spurious
7013
+ // empty trailing row from a single-line clipboard text.
7014
+ textArgs: ["-n"]
7015
+ };
7016
+ }
7017
+ if (env.env.DISPLAY && await which(env, "xclip")) {
7018
+ return {
7019
+ cmd: "xclip",
7020
+ imageArgs: ["-selection", "clipboard", "-t", "image/png", "-o"],
7021
+ textArgs: ["-selection", "clipboard", "-o"]
7022
+ };
7023
+ }
7024
+ return null;
7025
+ }
7026
+ function normalizeText(text) {
7027
+ return text.replace(/\r\n?/g, "\n");
7028
+ }
7029
+ async function which(env, cmd) {
7030
+ try {
7031
+ await run2(env.spawn, "which", [cmd]);
7032
+ return true;
7033
+ } catch {
7034
+ return false;
7035
+ }
7036
+ }
7037
+ async function readFileAsAttachment(p, unlinkAfter) {
7038
+ try {
7039
+ const buf = await fs14.readFile(p);
7040
+ if (unlinkAfter) {
7041
+ await fs14.unlink(p).catch(() => void 0);
7042
+ }
7043
+ if (buf.length === 0) {
7044
+ return { ok: false, reason: "no image on clipboard" };
7045
+ }
7046
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
7047
+ return {
7048
+ ok: false,
7049
+ reason: `clipboard image is ${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)}`
7050
+ };
7051
+ }
7052
+ const mimeType = mimeFromExtension(p) ?? "image/png";
7053
+ return {
7054
+ ok: true,
7055
+ kind: "image",
7056
+ attachment: {
7057
+ mimeType,
7058
+ data: buf.toString("base64"),
7059
+ sizeBytes: buf.length
7060
+ }
7061
+ };
7062
+ } catch {
7063
+ return { ok: false, reason: "failed to read clipboard image" };
7064
+ }
7065
+ }
7066
+ function run2(spawn6, cmd, args) {
7067
+ return new Promise((resolve5, reject) => {
7068
+ const proc = spawn6(cmd, args);
7069
+ proc.stdout?.on("data", () => void 0);
7070
+ proc.stderr?.on("data", () => void 0);
7071
+ proc.on("error", reject);
7072
+ proc.on("close", (code) => {
7073
+ if (code === 0) {
7074
+ resolve5();
7075
+ } else {
7076
+ reject(new Error(`${cmd} exited ${code}`));
7077
+ }
7078
+ });
7079
+ });
7080
+ }
7081
+ function runCapture(spawn6, cmd, args) {
7082
+ return new Promise((resolve5, reject) => {
7083
+ const proc = spawn6(cmd, args);
7084
+ const chunks = [];
7085
+ let stdoutEnded = proc.stdout === null;
7086
+ let closedCode = null;
7087
+ let settled = false;
7088
+ const settle = () => {
7089
+ if (settled || !stdoutEnded || closedCode === null) {
7090
+ return;
7091
+ }
7092
+ settled = true;
7093
+ if (closedCode === 0) {
7094
+ resolve5(Buffer.concat(chunks));
7095
+ } else {
7096
+ reject(new Error(`${cmd} exited ${closedCode}`));
7097
+ }
7098
+ };
7099
+ proc.stdout?.on("data", (chunk) => {
7100
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
7101
+ });
7102
+ proc.stdout?.on("end", () => {
7103
+ stdoutEnded = true;
7104
+ settle();
7105
+ });
7106
+ proc.stderr?.on("data", () => void 0);
7107
+ proc.on("error", (err) => {
7108
+ if (settled) {
7109
+ return;
7110
+ }
7111
+ settled = true;
7112
+ reject(err);
7113
+ });
7114
+ proc.on("close", (code) => {
7115
+ closedCode = code ?? 0;
7116
+ settle();
7117
+ });
7118
+ });
7119
+ }
7120
+ var defaultEnv;
7121
+ var init_clipboard = __esm({
7122
+ "src/tui/clipboard.ts"() {
7123
+ "use strict";
7124
+ init_attachments();
7125
+ defaultEnv = {
7126
+ platform: process.platform,
7127
+ env: process.env,
7128
+ spawn: nodeSpawn,
7129
+ tmpdir: os4.tmpdir
7130
+ };
7131
+ }
7132
+ });
7133
+
6617
7134
  // src/tui/completion.ts
6618
7135
  function longestCommonPrefix(names) {
6619
7136
  if (names.length === 0) {
@@ -6948,8 +7465,29 @@ import chalk from "chalk";
6948
7465
  import { highlight, supportsLanguage } from "cli-highlight";
6949
7466
  function formatEvent(event) {
6950
7467
  switch (event.kind) {
6951
- case "user-text":
6952
- return formatBlock(event.text, "\u258E ", "user", void 0, event.sentBy, true);
7468
+ case "user-text": {
7469
+ const lines = formatBlock(
7470
+ event.text,
7471
+ "\u258E ",
7472
+ "user",
7473
+ void 0,
7474
+ event.sentBy,
7475
+ true
7476
+ );
7477
+ if (event.attachments && event.attachments.length > 0) {
7478
+ for (const a of event.attachments) {
7479
+ lines.push({
7480
+ prefix: "\u258E ",
7481
+ prefixStyle: "user",
7482
+ body: `\u{1F4CE} ${a.name ?? "image"}`,
7483
+ bodyStyle: "user",
7484
+ fillRow: true,
7485
+ iterm2Image: { data: a.data, heightCells: 5 }
7486
+ });
7487
+ }
7488
+ }
7489
+ return lines;
7490
+ }
6953
7491
  case "agent-text":
6954
7492
  return formatBlock(event.text, " ", "agent");
6955
7493
  case "agent-thought":
@@ -7279,6 +7817,8 @@ var init_format = __esm({
7279
7817
  import { appendFileSync, statSync, renameSync } from "fs";
7280
7818
  import { nanoid as nanoid3 } from "nanoid";
7281
7819
  import termkit from "terminal-kit";
7820
+ import fs15 from "fs/promises";
7821
+ import path11 from "path";
7282
7822
  async function runTuiApp(opts) {
7283
7823
  const config = await ensureConfig();
7284
7824
  logMaxBytes = config.tui.logMaxBytes;
@@ -7396,6 +7936,15 @@ async function runSession(term, config, opts, exitHint) {
7396
7936
  appendRender(event);
7397
7937
  maybeDismissPermissionByToolUpdate(update);
7398
7938
  });
7939
+ conn.onNotification("hydra-acp/session_closed", () => {
7940
+ if (pendingTurns > 0) {
7941
+ adjustPendingTurns(-pendingTurns);
7942
+ }
7943
+ const screenReady = typeof screenRef !== "undefined" && screenRef !== null;
7944
+ if (screenReady) {
7945
+ screenRef.setBanner({ status: "cold", elapsedMs: void 0 });
7946
+ }
7947
+ });
7399
7948
  const handlePermissionResolved = (update) => {
7400
7949
  const u = update ?? {};
7401
7950
  const toolCallId = typeof u.toolCallId === "string" ? u.toolCallId : void 0;
@@ -7499,6 +8048,7 @@ async function runSession(term, config, opts, exitHint) {
7499
8048
  });
7500
8049
  let upstreamSessionId;
7501
8050
  let agentInfoName;
8051
+ let agentAcceptsImages = true;
7502
8052
  try {
7503
8053
  const initResult = await conn.request("initialize", {
7504
8054
  protocolVersion: ACP_PROTOCOL_VERSION,
@@ -7509,6 +8059,10 @@ async function runSession(term, config, opts, exitHint) {
7509
8059
  clientInfo: { name: "hydra-acp-tui", version: HYDRA_VERSION }
7510
8060
  });
7511
8061
  agentInfoName = initResult?.agentInfo?.name;
8062
+ const imageCap = initResult?.agentCapabilities?.promptCapabilities?.image;
8063
+ if (imageCap === false) {
8064
+ agentAcceptsImages = false;
8065
+ }
7512
8066
  } catch {
7513
8067
  }
7514
8068
  let resolvedSessionId = ctx.sessionId;
@@ -7607,6 +8161,10 @@ async function runSession(term, config, opts, exitHint) {
7607
8161
  if (tryHandleCompletionKey(ev)) {
7608
8162
  continue;
7609
8163
  }
8164
+ if (ev.type === "attachment-paths") {
8165
+ void handleAttachmentPaths(ev.paths);
8166
+ continue;
8167
+ }
7610
8168
  const effects = dispatcher.feed(ev);
7611
8169
  for (const effect of effects) {
7612
8170
  handleEffect(effect);
@@ -7616,6 +8174,7 @@ async function runSession(term, config, opts, exitHint) {
7616
8174
  screen.setBannerSearchIndicator(
7617
8175
  dispatcher.state().historySearchQuery
7618
8176
  );
8177
+ screen.setAttachments(dispatcher.state().attachments);
7619
8178
  screen.refreshPrompt();
7620
8179
  }
7621
8180
  });
@@ -7906,7 +8465,8 @@ async function runSession(term, config, opts, exitHint) {
7906
8465
  const choice = await pickSession(term, {
7907
8466
  cwd: resolvedCwd,
7908
8467
  sessions,
7909
- config
8468
+ config,
8469
+ currentSessionId: resolvedSessionId
7910
8470
  });
7911
8471
  if (choice.kind === "abort") {
7912
8472
  screen.start();
@@ -7937,13 +8497,17 @@ async function runSession(term, config, opts, exitHint) {
7937
8497
  const handleEffect = (effect) => {
7938
8498
  switch (effect.type) {
7939
8499
  case "send":
7940
- enqueuePrompt(effect.text, effect.planMode);
8500
+ enqueuePrompt(effect.text, effect.planMode, effect.attachments);
7941
8501
  return;
7942
8502
  case "queue-edit": {
7943
8503
  const realIdx = effect.index + queueHeadOffset();
7944
8504
  const existing = promptQueue[realIdx];
7945
8505
  if (existing) {
7946
- promptQueue[realIdx] = { text: effect.text, planMode: existing.planMode };
8506
+ promptQueue[realIdx] = {
8507
+ text: effect.text,
8508
+ planMode: existing.planMode,
8509
+ attachments: effect.attachments
8510
+ };
7947
8511
  refreshQueueDisplay();
7948
8512
  }
7949
8513
  return;
@@ -7962,7 +8526,10 @@ async function runSession(term, config, opts, exitHint) {
7962
8526
  const waitingEmpty = promptQueue.length <= headOffset;
7963
8527
  const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
7964
8528
  if (waitingEmpty && bufferEmpty) {
7965
- pendingPrefill = turnInFlight.text;
8529
+ pendingPrefill = {
8530
+ text: turnInFlight.text,
8531
+ attachments: turnInFlight.attachments
8532
+ };
7966
8533
  }
7967
8534
  }
7968
8535
  if (turnInFlight) {
@@ -8001,17 +8568,81 @@ async function runSession(term, config, opts, exitHint) {
8001
8568
  screen.enterScrollbackSearch();
8002
8569
  screen.updateScrollbackSearchTerm(effect.query);
8003
8570
  return;
8571
+ case "attachment-request":
8572
+ void handleClipboardAttachment();
8573
+ return;
8004
8574
  }
8005
8575
  };
8576
+ const handleAttachmentPaths = async (paths2) => {
8577
+ if (!agentAcceptsImages) {
8578
+ screen.notify("agent does not accept image attachments");
8579
+ return;
8580
+ }
8581
+ let added = 0;
8582
+ for (const p of paths2) {
8583
+ const mimeType = mimeFromExtension(p);
8584
+ if (!mimeType) {
8585
+ screen.notify(`unsupported image type: ${path11.basename(p)}`);
8586
+ continue;
8587
+ }
8588
+ try {
8589
+ const buf = await fs15.readFile(p);
8590
+ if (buf.length > MAX_ATTACHMENT_BYTES) {
8591
+ screen.notify(
8592
+ `image too large (${formatSize(buf.length)}, max ${formatSize(MAX_ATTACHMENT_BYTES)})`
8593
+ );
8594
+ continue;
8595
+ }
8596
+ dispatcher.addAttachment({
8597
+ mimeType,
8598
+ data: buf.toString("base64"),
8599
+ name: path11.basename(p),
8600
+ sizeBytes: buf.length
8601
+ });
8602
+ added++;
8603
+ } catch (err) {
8604
+ screen.notify(`cannot read ${path11.basename(p)}: ${err.message}`);
8605
+ }
8606
+ }
8607
+ if (added > 0) {
8608
+ screen.setAttachments(dispatcher.state().attachments);
8609
+ screen.refreshPrompt();
8610
+ }
8611
+ };
8612
+ const handleClipboardAttachment = async () => {
8613
+ const result = await readClipboard();
8614
+ if (!result.ok) {
8615
+ screen.notify(result.reason);
8616
+ return;
8617
+ }
8618
+ if (result.kind === "image") {
8619
+ if (!agentAcceptsImages) {
8620
+ screen.notify("agent does not accept image attachments");
8621
+ return;
8622
+ }
8623
+ dispatcher.addAttachment(result.attachment);
8624
+ screen.setAttachments(dispatcher.state().attachments);
8625
+ screen.refreshPrompt();
8626
+ return;
8627
+ }
8628
+ const effects = dispatcher.feed({ type: "paste", text: result.text });
8629
+ for (const effect of effects) {
8630
+ handleEffect(effect);
8631
+ }
8632
+ screen.refreshPrompt();
8633
+ };
8006
8634
  const promptQueue = [];
8007
8635
  let workerActive = false;
8008
8636
  const refreshQueueDisplay = () => {
8009
8637
  const waiting = promptQueue.slice(workerActive ? 1 : 0);
8010
- screen.setQueuedPrompts(waiting.map((p) => p.text));
8638
+ const displayTexts = waiting.map(
8639
+ (p) => p.attachments.length > 0 ? `${p.text} \xB7 \u{1F4CE}\xD7${p.attachments.length}` : p.text
8640
+ );
8641
+ screen.setQueuedPrompts(displayTexts);
8011
8642
  screen.setBanner({ queued: waiting.length });
8012
8643
  dispatcher.setQueue(waiting.map((p) => p.text));
8013
8644
  };
8014
- const enqueuePrompt = (text, planMode) => {
8645
+ const enqueuePrompt = (text, planMode, attachments) => {
8015
8646
  screen.scrollToBottom();
8016
8647
  if (handleBuiltinCommand(text)) {
8017
8648
  return;
@@ -8019,7 +8650,7 @@ async function runSession(term, config, opts, exitHint) {
8019
8650
  history = appendEntry(history, text);
8020
8651
  dispatcher.setHistory(history);
8021
8652
  saveHistory(historyFile, history).catch(() => void 0);
8022
- promptQueue.push({ text, planMode });
8653
+ promptQueue.push({ text, planMode, attachments });
8023
8654
  refreshQueueDisplay();
8024
8655
  tickWorker();
8025
8656
  };
@@ -8186,31 +8817,38 @@ async function runSession(term, config, opts, exitHint) {
8186
8817
  break;
8187
8818
  }
8188
8819
  refreshQueueDisplay();
8189
- await processPrompt(next.text, next.planMode);
8820
+ await processPrompt(next.text, next.planMode, next.attachments);
8190
8821
  promptQueue.shift();
8191
8822
  }
8192
8823
  } finally {
8193
8824
  workerActive = false;
8194
8825
  refreshQueueDisplay();
8195
8826
  if (pendingPrefill !== null) {
8196
- const text = pendingPrefill;
8827
+ const { text, attachments } = pendingPrefill;
8197
8828
  pendingPrefill = null;
8198
8829
  const bufferEmpty = dispatcher.state().buffer.every((line) => line === "");
8199
8830
  if (bufferEmpty) {
8200
- dispatcher.setBuffer(text);
8831
+ dispatcher.setBuffer(text, attachments);
8201
8832
  screen.refreshPrompt();
8202
8833
  }
8203
8834
  }
8204
8835
  }
8205
8836
  };
8206
- const processPrompt = async (text, planMode) => {
8207
- const userBlocks = [{ type: "text", text }];
8837
+ const processPrompt = async (text, planMode, attachments) => {
8838
+ const userBlocks = [];
8839
+ if (text.length > 0) {
8840
+ userBlocks.push({ type: "text", text });
8841
+ }
8842
+ for (const a of attachments) {
8843
+ userBlocks.push({ type: "image", data: a.data, mimeType: a.mimeType });
8844
+ }
8208
8845
  const promptArr = planMode ? [{ type: "text", text: PLAN_PREFIX_TEXT }, ...userBlocks] : userBlocks;
8209
8846
  adjustPendingTurns(1);
8210
- appendRender({ kind: "user-text", text });
8847
+ appendRender({ kind: "user-text", text, attachments });
8211
8848
  let cancelled = false;
8212
8849
  turnInFlight = {
8213
8850
  text,
8851
+ attachments,
8214
8852
  cancel: () => {
8215
8853
  if (cancelled) {
8216
8854
  return;
@@ -8707,6 +9345,8 @@ var init_app = __esm({
8707
9345
  init_picker();
8708
9346
  init_screen();
8709
9347
  init_input();
9348
+ init_attachments();
9349
+ init_clipboard();
8710
9350
  init_completion();
8711
9351
  init_render_update();
8712
9352
  init_format();
package/dist/index.d.ts CHANGED
@@ -1777,6 +1777,7 @@ declare class Session {
1777
1777
  private runTitleRegen;
1778
1778
  private runInternalPrompt;
1779
1779
  private runAgentCommand;
1780
+ private runKillCommand;
1780
1781
  private buildSwitchTranscript;
1781
1782
  seedFromImport(): Promise<void>;
1782
1783
  private broadcastAgentSwitch;
package/dist/index.js CHANGED
@@ -1448,6 +1448,11 @@ var HYDRA_COMMANDS = [
1448
1448
  name: "hydra agent",
1449
1449
  argsHint: "<agent>",
1450
1450
  description: "Swap the agent backing this session, preserving context"
1451
+ },
1452
+ {
1453
+ verb: "kill",
1454
+ name: "hydra kill",
1455
+ description: "Close this session (kills the agent; record is kept so it can be resumed later)"
1451
1456
  }
1452
1457
  ];
1453
1458
  var VERB_INDEX = new Map(HYDRA_COMMANDS.map((c) => [c.verb, c]));
@@ -2152,6 +2157,8 @@ var Session = class {
2152
2157
  return this.runTitleCommand(arg);
2153
2158
  case "agent":
2154
2159
  return this.runAgentCommand(arg);
2160
+ case "kill":
2161
+ return this.runKillCommand();
2155
2162
  default: {
2156
2163
  const err = new Error(
2157
2164
  `no dispatcher for /hydra verb ${verb}`
@@ -2263,6 +2270,17 @@ var Session = class {
2263
2270
  return { stopReason: "end_turn" };
2264
2271
  });
2265
2272
  }
2273
+ // Close this session in-place. Bypasses enqueuePrompt deliberately so a
2274
+ // mid-turn /hydra kill takes effect immediately — agent.kill() will tear
2275
+ // down any in-flight request as a side effect. The record is kept
2276
+ // (deleteRecord:false) so the session goes cold and can be resurrected.
2277
+ // Returns end_turn so the prompt() caller's response resolves normally,
2278
+ // but every attached client has already received hydra-acp/session_closed
2279
+ // by the time this returns.
2280
+ async runKillCommand() {
2281
+ await this.close({ deleteRecord: false });
2282
+ return { stopReason: "end_turn" };
2283
+ }
2266
2284
  // Walk the persisted history and produce a labeled transcript suitable
2267
2285
  // for handing to a fresh agent. Includes user prompts, agent replies,
2268
2286
  // and tool-call outcomes; skips hydra-synthesized markers (so multi-hop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hydra-acp/cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",