agenter-app 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,6 +75,8 @@ Every live session has a message box. What happens to your message depends on th
75
75
 
76
76
  Queued messages show above the box, with **Cancel**.
77
77
 
78
+ **Images:** paste them into any message box, drop them on it, or use the image button. This works in the steer box, in the reply after a finished turn, and in **New session**. You can attach up to 8 PNG, JPEG, GIF or WebP images per message, each under 10 MB. Agenter saves them to `~/.agenter/uploads` and adds their paths to your message, and Claude or Codex opens them from there. Uploads older than 30 days are removed when Agenter starts.
79
+
78
80
  ### Drive from VS Code or Agenter
79
81
 
80
82
  Each session has a **Drive from** switch:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agenter-app",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "One local dashboard to watch, approve and steer every Claude Code and Codex session on your machine.",
5
5
  "keywords": [
6
6
  "claude",
package/public/app.js CHANGED
@@ -92,6 +92,7 @@ const ICONS = {
92
92
  tool: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
93
93
  code: '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
94
94
  send: '<path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/>',
95
+ image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
95
96
  check: '<path d="M20 6 9 17l-5-5"/>',
96
97
  shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/>',
97
98
  reply: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
@@ -495,6 +496,129 @@ function fitTextarea(ta) {
495
496
  ta.style.height = Math.min(ta.scrollHeight, 260) + 'px';
496
497
  }
497
498
 
499
+ // ---------- image attachments ----------
500
+ // Images waiting to go with a message, by the box they belong to: a card's data-id, or
501
+ // 'new' for the New session dialog. Kept out of the DOM so re-renders don't drop them.
502
+ const attachments = new Map();
503
+ const MAX_ATTACH = 8;
504
+ const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
505
+ const MAX_ATTACH_TOTAL = 22 * 1024 * 1024; // the request body stays under the server's limit
506
+ const IMAGE_TYPE = /^image\/(png|jpeg|gif|webp)$/;
507
+
508
+ function thumbsHtml(key) {
509
+ return (attachments.get(key) || [])
510
+ .map((src, i) => `<span class="thumb"><img src="${esc(src)}" alt="Attached image ${i + 1}"><button type="button" class="thumb-x" data-unattach="${i}" title="Remove image" aria-label="Remove image ${i + 1}">×</button></span>`)
511
+ .join('');
512
+ }
513
+
514
+ const attachRow = (key) => `<div class="attach-row" data-attach="${esc(key)}"${attachments.get(key)?.length ? '' : ' hidden'}>${thumbsHtml(key)}</div>`;
515
+
516
+ function renderThumbs(key) {
517
+ document.querySelectorAll(`[data-attach="${CSS.escape(key)}"]`).forEach((el) => {
518
+ el.innerHTML = thumbsHtml(key);
519
+ el.hidden = !attachments.get(key)?.length;
520
+ });
521
+ }
522
+
523
+ const readDataUrl = (file) =>
524
+ new Promise((resolve, reject) => {
525
+ const r = new FileReader();
526
+ r.onload = () => resolve(r.result);
527
+ r.onerror = () => reject(r.error);
528
+ r.readAsDataURL(file);
529
+ });
530
+
531
+ // Returns whether there were any images to take.
532
+ async function addImages(key, files) {
533
+ const imgs = [...(files || [])].filter((f) => IMAGE_TYPE.test(f.type));
534
+ if (!imgs.length) {
535
+ if ([...(files || [])].length) toast('Only PNG, JPEG, GIF and WebP images');
536
+ return false;
537
+ }
538
+ const list = [...(attachments.get(key) || [])];
539
+ let total = list.reduce((n, s) => n + s.length * 0.75, 0);
540
+ for (const f of imgs) {
541
+ if (list.length >= MAX_ATTACH) {
542
+ toast(`At most ${MAX_ATTACH} images per message`);
543
+ break;
544
+ }
545
+ if (f.size > MAX_ATTACH_BYTES) {
546
+ toast('Each image must be under 10 MB');
547
+ continue;
548
+ }
549
+ if (total + f.size > MAX_ATTACH_TOTAL) {
550
+ toast('That’s too much for one message. Send some images separately');
551
+ break;
552
+ }
553
+ list.push(await readDataUrl(f));
554
+ total += f.size;
555
+ }
556
+ attachments.set(key, list);
557
+ renderThumbs(key);
558
+ return true;
559
+ }
560
+
561
+ function takeAttachments(key) {
562
+ const list = attachments.get(key) || [];
563
+ attachments.delete(key);
564
+ renderThumbs(key);
565
+ return list;
566
+ }
567
+
568
+ function putBackAttachments(key, list) {
569
+ if (!list.length) return;
570
+ attachments.set(key, [...list, ...(attachments.get(key) || [])].slice(0, MAX_ATTACH));
571
+ renderThumbs(key);
572
+ }
573
+
574
+ // Which attachment list an element belongs to.
575
+ function attachKey(el) {
576
+ if (el.closest('#newDialog')) return 'new';
577
+ return el.closest('.card[data-id]')?.dataset.id || null;
578
+ }
579
+
580
+ let attachTarget = null;
581
+ $('#attachInput').addEventListener('change', async (e) => {
582
+ const files = [...e.target.files];
583
+ e.target.value = '';
584
+ if (attachTarget) await addImages(attachTarget, files);
585
+ document.querySelector(attachTarget === 'new' ? '#newMsg' : `.card[data-id="${CSS.escape(attachTarget || '')}"] textarea`)?.focus();
586
+ });
587
+
588
+ document.addEventListener('paste', (e) => {
589
+ const t = e.target instanceof Element ? e.target : null;
590
+ if (!t?.matches('.reply-box textarea, #newMsg')) return;
591
+ const files = [...(e.clipboardData?.files || [])].filter((f) => IMAGE_TYPE.test(f.type));
592
+ if (!files.length) return;
593
+ e.preventDefault();
594
+ addImages(attachKey(t), files);
595
+ });
596
+
597
+ const dropZone = (el) => (el instanceof Element ? el.closest('.reply-box, #newForm') : null);
598
+ const hasFiles = (e) => [...(e.dataTransfer?.types || [])].includes('Files');
599
+ document.addEventListener('dragover', (e) => {
600
+ if (!hasFiles(e)) return;
601
+ e.preventDefault(); // otherwise the browser opens a dropped file in place of the dashboard
602
+ const zone = dropZone(e.target);
603
+ e.dataTransfer.dropEffect = zone ? 'copy' : 'none';
604
+ document.querySelectorAll('.drop-over').forEach((z) => z !== zone && z.classList.remove('drop-over'));
605
+ zone?.classList.add('drop-over');
606
+ });
607
+ document.addEventListener('dragleave', (e) => {
608
+ const zone = dropZone(e.target);
609
+ if (zone && !zone.contains(e.relatedTarget)) zone.classList.remove('drop-over');
610
+ });
611
+ document.addEventListener('drop', (e) => {
612
+ if (!hasFiles(e)) return;
613
+ e.preventDefault();
614
+ document.querySelectorAll('.drop-over').forEach((z) => z.classList.remove('drop-over'));
615
+ const zone = dropZone(e.target);
616
+ const key = zone && attachKey(zone);
617
+ if (key) addImages(key, e.dataTransfer.files);
618
+ });
619
+
620
+ const attachButton = () => `<button class="btn ghost small attach-btn" type="button" data-attach-pick title="Attach images (or paste / drop them)" aria-label="Attach images">${icon('image')}</button>`;
621
+
498
622
  function refreshAges() {
499
623
  document.querySelectorAll('[data-t]').forEach((el) => (el.textContent = ago(Number(el.dataset.t))));
500
624
  }
@@ -857,8 +981,10 @@ function renderSteer(s) {
857
981
  ${queued}
858
982
  <div class="reply-box">
859
983
  <textarea rows="1" placeholder="${busy ? `Steer ${isCodex(s) ? 'Codex' : 'Claude'} while it works…` : `Message ${isCodex(s) ? 'Codex' : 'Claude'}…`}"></textarea>
984
+ ${attachRow(`steer-${s.id}`)}
860
985
  <div class="reply-row">
861
986
  <span class="hint">${when}</span>
987
+ ${attachButton()}
862
988
  ${s.running ? `<button class="btn small" type="button" data-interrupt title="Stop the turn Agenter is running">Stop</button>` : ''}
863
989
  <button class="btn primary small" type="button" data-steer-send>${icon('send')}Send</button>
864
990
  </div>
@@ -869,21 +995,29 @@ const STEER_TOAST = { sent: 'Reply sent', queued: 'Queued', started: 'Running in
869
995
 
870
996
  async function steer(card) {
871
997
  const ta = $('textarea', card);
998
+ const key = card.dataset.id;
872
999
  const message = ta.value.trim();
873
- if (!message) return ta.focus();
1000
+ if (!message && !attachments.get(key)?.length) return ta.focus();
874
1001
  // Clear first: the queued message re-renders the dock, which would otherwise put the text back.
875
1002
  ta.value = '';
876
1003
  fitTextarea(ta);
877
- const r = await post('/api/session/steer', { sessionId: state.selected, message }).catch(() => null);
878
- if (!r?.ok) {
1004
+ const images = takeAttachments(key);
1005
+ const r = await post('/api/session/steer', { sessionId: state.selected, message, images }).catch(() => null);
1006
+ const putBack = () => {
879
1007
  const box = $('#dock [data-steer] textarea');
880
1008
  if (box && !box.value) box.value = message;
881
- return toast("Couldn't send that");
1009
+ putBackAttachments(key, images);
1010
+ };
1011
+ if (!r?.ok) {
1012
+ putBack();
1013
+ const error = r && (await r.json().catch(() => ({}))).error;
1014
+ return toast(error || "Couldn't send that");
882
1015
  }
883
1016
  const { result } = await r.json();
884
1017
  // A turn Agenter just started: show your message now, ahead of the transcript.
885
1018
  if (result === 'started') {
886
- sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), message]);
1019
+ const echo = [message, images.length ? `[${images.length === 1 ? 'Image' : `${images.length} images`}]` : ''].filter(Boolean).join('\n\n');
1020
+ sentEchoes.set(state.selected, [...(sentEchoes.get(state.selected) || []), echo]);
887
1021
  renderLiveRow();
888
1022
  }
889
1023
  const FAILED = {
@@ -892,8 +1026,7 @@ async function steer(card) {
892
1026
  error: "Codex didn't accept the message",
893
1027
  };
894
1028
  if (FAILED[result]) {
895
- const box = $('#dock [data-steer] textarea');
896
- if (box && !box.value) box.value = message;
1029
+ putBack();
897
1030
  return toast(FAILED[result]);
898
1031
  }
899
1032
  toast(STEER_TOAST[result] || 'Sent');
@@ -1150,8 +1283,10 @@ function renderCard(p, showSession) {
1150
1283
  ${p.message ? `<div class="last-msg md">${md(p.message)}</div>` : ''}
1151
1284
  <div class="reply-box">
1152
1285
  <textarea rows="2" placeholder="Tell Claude what to do next…"></textarea>
1286
+ ${attachRow(p.id)}
1153
1287
  <div class="reply-row">
1154
1288
  <span class="hint"><kbd>Enter</kbd> send · <kbd>Shift</kbd>+<kbd>Enter</kbd> new line</span>
1289
+ ${attachButton()}
1155
1290
  <button class="btn ghost small" data-act="pass" title="Let the session stop normally so you can type in VS Code">Release to VS Code</button>
1156
1291
  <button class="btn primary small" data-act="reply">${icon('send')}Send</button>
1157
1292
  </div>
@@ -1168,7 +1303,8 @@ async function act(card, action) {
1168
1303
  if (action === 'reply') {
1169
1304
  const ta = $('textarea', card);
1170
1305
  body.message = ta.value.trim();
1171
- if (!body.message) return ta.focus();
1306
+ body.images = attachments.get(p.id) || [];
1307
+ if (!body.message && !body.images.length) return ta.focus();
1172
1308
  }
1173
1309
  if (action === 'answer') {
1174
1310
  body.answers = {};
@@ -1185,6 +1321,12 @@ async function act(card, action) {
1185
1321
  const r = await post('/api/respond', body);
1186
1322
  const s = sessionById(p.sessionId);
1187
1323
  const what = p.kind === 'permission' && ['allow', 'always', 'deny'].includes(action) ? ` ${toolName(p.tool)}` : '';
1324
+ if (r.status === 400) {
1325
+ // Rejected before anything was sent (a bad image): let you fix it and try again.
1326
+ card.querySelectorAll('button, input, textarea').forEach((b) => (b.disabled = false));
1327
+ return toast((await r.json().catch(() => ({}))).error || "Couldn't send that");
1328
+ }
1329
+ if (action === 'reply' || action === 'pass') takeAttachments(p.id);
1188
1330
  toast(r.ok ? `${ACTION_TOAST[action]}${what}${s ? ` · ${sessionLabel(s)}` : ''}` : 'That was already handled');
1189
1331
  }
1190
1332
 
@@ -1288,6 +1430,18 @@ document.addEventListener('click', (e) => {
1288
1430
  if (t.closest('[data-mem-close]')) return closeMemory();
1289
1431
  const memFile = t.closest('[data-mem-file]');
1290
1432
  if (memFile) return showMemFile(memFile.dataset.memFile);
1433
+ if (t.closest('[data-attach-pick]')) {
1434
+ attachTarget = attachKey(t);
1435
+ return $('#attachInput').click();
1436
+ }
1437
+ const unattach = t.closest('[data-unattach]');
1438
+ if (unattach) {
1439
+ const key = attachKey(unattach);
1440
+ const list = [...(attachments.get(key) || [])];
1441
+ list.splice(Number(unattach.dataset.unattach), 1);
1442
+ attachments.set(key, list);
1443
+ return renderThumbs(key);
1444
+ }
1291
1445
  if (t.closest('[data-steer-send]')) return steer(t.closest('.card'));
1292
1446
  if (t.closest('[data-drive]')) return setDrive(t.closest('[data-drive]').dataset.drive);
1293
1447
  if (t.closest('[data-interrupt]')) return post('/api/session/interrupt', { sessionId: state.selected });
@@ -1472,10 +1626,12 @@ $('#newForm').addEventListener('submit', async (e) => {
1472
1626
  $('#newError').hidden = false;
1473
1627
  $('#newStart').disabled = false;
1474
1628
  };
1629
+ const images = attachments.get('new') || [];
1630
+ if (!message && !images.length) return ($('#newMsg').focus(), showError('Type a first message, or attach an image.'));
1475
1631
  $('#newStart').disabled = true;
1476
1632
  let r;
1477
1633
  try {
1478
- r = await post('/api/session/new', { cwd, message, agent: store.get('newAgent', 'claude'), fromSessionId: carryFrom });
1634
+ r = await post('/api/session/new', { cwd, message, images, agent: store.get('newAgent', 'claude'), fromSessionId: carryFrom });
1479
1635
  } catch {
1480
1636
  return showError("Couldn't reach the Agenter server.");
1481
1637
  }
@@ -1483,6 +1639,7 @@ $('#newForm').addEventListener('submit', async (e) => {
1483
1639
  if (!r.ok) return showError(data.error || "Couldn't start the session.");
1484
1640
  store.set('newCwd', cwd);
1485
1641
  $('#newMsg').value = '';
1642
+ takeAttachments('new');
1486
1643
  $('#newDialog').close();
1487
1644
  state.awaiting = data.sessionId;
1488
1645
  select(data.sessionId);
package/public/index.html CHANGED
@@ -201,9 +201,13 @@
201
201
  <button class="btn ghost small" type="button" data-carry-drop>Remove</button>
202
202
  </div>
203
203
  <label class="field-label" for="newMsg">First message</label>
204
- <textarea class="field" id="newMsg" rows="4" placeholder="What should Claude do?" required></textarea>
204
+ <textarea class="field" id="newMsg" rows="4" placeholder="What should Claude do?"></textarea>
205
+ <div class="attach-row" data-attach="new" hidden></div>
205
206
  <div class="dlg-error" id="newError" hidden></div>
206
207
  <div class="dlg-actions">
208
+ <button class="btn ghost attach-left" type="button" data-attach-pick title="Attach images (or paste / drop them)">
209
+ <svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/></svg>Attach images
210
+ </button>
207
211
  <button class="btn ghost" type="button" data-new-cancel>Cancel</button>
208
212
  <button class="btn primary" type="submit" id="newStart">Start session</button>
209
213
  </div>
@@ -249,6 +253,7 @@
249
253
  <form method="dialog"><button class="btn primary">Got it</button></form>
250
254
  </dialog>
251
255
 
256
+ <input type="file" id="attachInput" accept="image/png,image/jpeg,image/gif,image/webp" multiple hidden>
252
257
  <script src="/app.js"></script>
253
258
  </body>
254
259
  </html>
package/public/style.css CHANGED
@@ -549,6 +549,19 @@ dialog.mem-dialog { max-width: 860px; }
549
549
  .reply-box textarea { width: 100%; min-height: 64px; max-height: 260px; resize: none; border: 0; background: transparent; color: var(--text); font: inherit; padding: 10px 12px 4px; outline: none; display: block; }
550
550
  .reply-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px 8px 12px; }
551
551
  .reply-row .hint { color: var(--muted); font-size: 12px; margin-right: auto; }
552
+ .reply-box.drop-over, #newForm.drop-over .field#newMsg { border-color: var(--accent-ink); box-shadow: 0 0 0 3px var(--accent-soft); }
553
+
554
+ /* Images waiting to go with a message. */
555
+ .attach-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 6px 12px 2px; }
556
+ .attach-row[hidden] { display: none; }
557
+ #newDialog .attach-row { padding: 10px 0 0; }
558
+ .thumb { position: relative; width: 64px; height: 64px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); background: var(--panel-2); flex: none; }
559
+ .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
560
+ .thumb-x { position: absolute; top: 3px; right: 3px; width: 20px; height: 20px; border: 0; border-radius: 50%; padding: 0; background: rgba(0, 0, 0, .6); color: #fff; font-size: 14px; line-height: 20px; cursor: pointer; opacity: .85; }
561
+ .thumb-x:hover, .thumb-x:focus-visible { opacity: 1; background: var(--danger); }
562
+ .attach-btn { padding-inline: 7px; }
563
+ .attach-btn .ic { margin: 0; }
564
+ .new-dialog .dlg-actions .attach-left { margin-right: auto; }
552
565
 
553
566
  .question { margin-bottom: 14px; }
554
567
  .q-title { font-weight: 600; margin-bottom: 8px; display: flex; gap: 8px; align-items: center; }
package/server.js CHANGED
@@ -996,6 +996,55 @@ function startCodexRun(s, text, { fresh = false } = {}) {
996
996
  });
997
997
  }
998
998
 
999
+ // ---------- images ----------
1000
+ // Hooks and the CLIs only carry text, so pasted images are saved here and their paths go
1001
+ // into the message. Claude opens them with its Read tool, Codex with its image viewer.
1002
+
1003
+ const UPLOADS_DIR = path.join(SETTINGS_DIR, 'uploads');
1004
+ const IMAGE_TYPES = { png: 'png', jpeg: 'jpg', gif: 'gif', webp: 'webp' };
1005
+ const MAX_IMAGES = 8;
1006
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
1007
+
1008
+ // images: data: URLs from the dashboard. Returns the saved files' paths.
1009
+ function saveImages(images) {
1010
+ if (!Array.isArray(images) || !images.length) return [];
1011
+ if (images.length > MAX_IMAGES) throw new Error(`Attach at most ${MAX_IMAGES} images.`);
1012
+ const files = images.map((src) => {
1013
+ const m = /^data:image\/(png|jpeg|gif|webp);base64,([A-Za-z0-9+/=]+)$/.exec(String(src));
1014
+ if (!m) throw new Error('Only PNG, JPEG, GIF and WebP images can be attached.');
1015
+ const data = Buffer.from(m[2], 'base64');
1016
+ if (data.length > MAX_IMAGE_BYTES) throw new Error('Each image must be under 10 MB.');
1017
+ return { data, file: path.join(UPLOADS_DIR, `${crypto.randomUUID()}.${IMAGE_TYPES[m[1]]}`) };
1018
+ });
1019
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
1020
+ for (const f of files) fs.writeFileSync(f.file, f.data);
1021
+ return files.map((f) => f.file);
1022
+ }
1023
+
1024
+ function withImages(text, paths) {
1025
+ text = String(text || '').trim();
1026
+ if (!paths.length) return text;
1027
+ const list = paths.map((p) => `- ${p}`).join('\n');
1028
+ const note = `The user attached ${paths.length === 1 ? 'an image' : `${paths.length} images`}. Open ${paths.length === 1 ? 'it' : 'them'} to see:\n${list}`;
1029
+ return text ? `${text}\n\n${note}` : note;
1030
+ }
1031
+
1032
+ // Uploads only matter for the turn they went with; clear out old ones.
1033
+ function pruneUploads(maxAgeMs = 30 * 24 * 3600 * 1000) {
1034
+ let names = [];
1035
+ try {
1036
+ names = fs.readdirSync(UPLOADS_DIR);
1037
+ } catch {
1038
+ return;
1039
+ }
1040
+ for (const n of names) {
1041
+ const f = path.join(UPLOADS_DIR, n);
1042
+ try {
1043
+ if (Date.now() - fs.statSync(f).mtimeMs > maxAgeMs) fs.rmSync(f, { force: true });
1044
+ } catch {}
1045
+ }
1046
+ }
1047
+
999
1048
  // ---------- memory ----------
1000
1049
 
1001
1050
  const memoryContext = (s) => ({ agent: s.agent, cwd: s.cwd, transcriptPath: s.transcriptPath, home: os.homedir(), codexHome: CODEX_HOME });
@@ -1272,6 +1321,10 @@ const server = http.createServer(async (req, res) => {
1272
1321
  return json(res, status, out);
1273
1322
  }
1274
1323
  if (url.pathname === '/api/respond') {
1324
+ if (body.action === 'reply' && body.images?.length) {
1325
+ if (!pending.has(body.id)) return json(res, 404, {});
1326
+ body.message = withImages(body.message, saveImages(body.images));
1327
+ }
1275
1328
  const s = sessions.get(pending.get(body.id)?.item.sessionId);
1276
1329
  const ok = respond(body.id, body.action, body);
1277
1330
  // Handing something back to VS Code: bring up that project's window so it's right there.
@@ -1292,18 +1345,19 @@ const server = http.createServer(async (req, res) => {
1292
1345
  }
1293
1346
  if (url.pathname === '/api/session/steer') {
1294
1347
  const s = sessions.get(body.sessionId);
1295
- const text = String(body.message || '').trim();
1348
+ const hasImages = Array.isArray(body.images) && body.images.length > 0;
1296
1349
  // A closed Claude session can only be continued by Agenter; Codex queues for its app either way.
1297
- if (!s || !text || (s.agent !== 'codex' && driveOf(s) === 'vscode' && (s.status === 'dormant' || s.status === 'ended'))) return json(res, 404, {});
1298
- return json(res, 200, { result: await steer(s, text) });
1350
+ if (!s || !(String(body.message || '').trim() || hasImages) || (s.agent !== 'codex' && driveOf(s) === 'vscode' && (s.status === 'dormant' || s.status === 'ended'))) return json(res, 404, {});
1351
+ return json(res, 200, { result: await steer(s, withImages(body.message, saveImages(body.images))) });
1299
1352
  }
1300
1353
  if (url.pathname === '/api/session/new') {
1301
1354
  const cwd = String(body.cwd || '').trim().replace(/^"(.*)"$/, '$1');
1302
- const text = String(body.message || '').trim();
1355
+ const typed = String(body.message || '').trim();
1303
1356
  const codex = body.agent === 'codex';
1357
+ if (!typed && !body.images?.length) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1358
+ const text = withImages(typed, saveImages(body.images));
1304
1359
  const carried = carryFrom(sessions.get(body.fromSessionId));
1305
1360
  const prompt = carried ? `${text}\n\n${carried}` : text;
1306
- if (!text) return json(res, 400, { error: `Type a first message for ${codex ? 'Codex' : 'Claude'}.` });
1307
1361
  let isDir = false;
1308
1362
  try {
1309
1363
  isDir = path.isAbsolute(cwd) && fs.statSync(cwd).isDirectory();
@@ -1312,7 +1366,7 @@ const server = http.createServer(async (req, res) => {
1312
1366
  if (codex) {
1313
1367
  if (!findCodex()) return json(res, 400, { error: "Couldn't find Codex. Install the Codex VS Code extension, or set AGENTER_CODEX_BIN to codex.exe." });
1314
1368
  // Codex names the session itself, so it's registered once Codex has started it.
1315
- const draft = Object.assign(newSession(''), { agent: 'codex', title: text.split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1369
+ const draft = Object.assign(newSession(''), { agent: 'codex', title: (typed || 'Image').split('\n')[0].slice(0, 140), lastEventAt: Date.now() });
1316
1370
  setCwd(draft, path.resolve(cwd));
1317
1371
  const started = await Promise.race([startCodexRun(draft, prompt, { fresh: true }), new Promise((r) => setTimeout(() => r({ error: 'Codex took too long to start.' }), 60000))]);
1318
1372
  if (!started?.id) return json(res, 500, { error: `Codex couldn't start: ${started?.error || 'unknown error'}` });
@@ -1325,7 +1379,7 @@ const server = http.createServer(async (req, res) => {
1325
1379
  if (!findClaude()) return json(res, 400, { error: "Couldn't find Claude Code. Install the VS Code extension, or set AGENTER_CLAUDE to claude.exe." });
1326
1380
  const s = getOrCreateSession(crypto.randomUUID());
1327
1381
  setCwd(s, path.resolve(cwd));
1328
- s.title = text.split('\n')[0].slice(0, 140);
1382
+ s.title = (typed || 'Image').split('\n')[0].slice(0, 140);
1329
1383
  s.lastEventAt = Date.now();
1330
1384
  settings.drive = { ...settings.drive, [s.id]: 'agenter' };
1331
1385
  saveSettings();
@@ -1420,6 +1474,7 @@ if (require.main === module) {
1420
1474
  process.chdir(os.homedir());
1421
1475
  startTray();
1422
1476
  startDiscovery();
1477
+ pruneUploads();
1423
1478
  startCodexDiscovery();
1424
1479
  fetchUsage();
1425
1480
  setInterval(fetchUsage, USAGE_INTERVAL_MS);
@@ -1433,5 +1488,5 @@ module.exports = {
1433
1488
  sessions, pending, clients, runners, dismissed,
1434
1489
  get settings() { return settings; },
1435
1490
  handleHook, hold, respond, steer, takeQueue, touchSession, snapshot, driveOf, effectiveHold,
1436
- findClaude, findCodex, codexCommand, releaseRun,
1491
+ findClaude, findCodex, codexCommand, releaseRun, saveImages, withImages, UPLOADS_DIR,
1437
1492
  };