@lazyingart/agintiflow 0.12.6 → 0.12.8

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
@@ -67,7 +67,7 @@ aginti
67
67
  aginti chat
68
68
  ```
69
69
 
70
- Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/instructions` to inspect `AGINTI.md`, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. `Ctrl+J` inserts a new line in the colored input panel, Enter sends, arrow keys move through wrapped multiline input, and `Ctrl+A`/`Ctrl+E` jump to the current line start/end. During an active run, Enter sends the draft as an ASAP pipe message (`→`) and Tab queues it for after the run (`↳`); ASAP messages are consumed first, Alt+Up edits the last piped message, and Shift+Left edits the last after-finish queued message. The input panel always shows the current `cwd` footer. Assistant responses start on a fresh line after the `aginti>` header with a colored response gutter and render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, and tables. Resuming a session prints a compact recent-chat preview before the prompt. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
70
+ Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/instructions` to inspect `AGINTI.md`, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. `Ctrl+J` inserts a new line in the colored input panel, Enter sends, arrow keys move through wrapped multiline input, and `Ctrl+A`/`Ctrl+E` jump to the current line start/end. During an active run, Enter sends the draft as an ASAP pipe message (`→`) and Tab queues it for after the run (`↳`); ASAP messages are consumed first, Alt+Up edits the last piped message, and Shift+Left edits the last after-finish queued message. The input panel always shows the current `cwd` footer and a single live status row, so long goals and tool updates are compacted instead of flooding the transcript. Assistant responses start on a fresh line after the `aginti>` header with a colored response gutter and render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, tables, and red/green patch diff lines. Resuming a session prints a compact recent-chat preview before the prompt. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
71
71
 
72
72
  `aginti init` creates `AGINTI.md` at the project root. This is the editable project-instruction file for both CLI and web runs, similar to `AGENTS.md` or project memory in other agents. Keep durable preferences, commands, and constraints there, but never secrets. You can edit it manually or ask in chat, for example: `update AGINTI.md to remember that this project uses pytest and npm run check`.
73
73
 
@@ -365,7 +365,7 @@ Package policy values:
365
365
 
366
366
  Toolchain commands such as `python3 plot.py`, `latexmk -pdf paper.tex`, and `pdflatex -interaction=nonstopmode -halt-on-error paper.tex` are allowlisted only when the shell tool is enabled. In Docker mode the project folder is mounted as `/workspace`; any file written to `/workspace/report.pdf` appears on the host as `<your-project>/report.pdf`. CLI runs print both the host workspace and the Docker mapping before execution. File and canvas tools accept both normal relative paths and Docker virtual paths like `/workspace/report.pdf`, while other absolute host paths remain blocked.
367
367
 
368
- The web chat mirrors the CLI session store. Enter sends, Ctrl+J inserts a newline, Tab submits/queues the message, and Esc stops the active run. CLI live input has two pending lanes: ASAP pipe messages (`→`) are written to `.sessions/<session-id>/inbox.jsonl` and drained before normal queued items at safe runner boundaries, while after-finish messages (`↳`) run as the next prompt once the current task fully finishes. Generated local sites should use the built-in `preview_workspace` or `open_workspace_file` tools; AgInTiFlow avoids transient localhost servers inside Docker because those containers stop between commands and their ports are not host-published.
368
+ The web chat mirrors the CLI session store while keeping browser-native controls. Launch `aginti web` from the same project folder and it reads the same `.sessions/` history as `aginti` and `aginti resume`. In the web UI, Enter sends and Shift+Enter adds a newline. `Pipe to run` writes an ASAP message (`→`) to `.sessions/<session-id>/inbox.jsonl` so an active CLI or web agent can consume it at the next safe boundary. `Queue after finish` stores a browser-local next prompt (`↳`) and starts it after the current web run finishes; queued items have Edit and Remove buttons. Esc or Stop stops active web runs. Generated local sites should use the built-in `preview_workspace` or `open_workspace_file` tools; AgInTiFlow avoids transient localhost servers inside Docker because those containers stop between commands and their ports are not host-published.
369
369
 
370
370
  Safe preflight endpoints:
371
371
 
@@ -376,6 +376,7 @@ curl -X POST http://127.0.0.1:3210/api/sandbox/preflight \
376
376
  -d '{"sandboxMode":"docker-workspace","buildImage":true}'
377
377
  curl http://127.0.0.1:3210/api/workspace/changes
378
378
  curl "http://127.0.0.1:3210/api/sessions/<session-id>/artifacts"
379
+ curl "http://127.0.0.1:3210/api/sessions/<session-id>/inbox"
379
380
  ```
380
381
 
381
382
  These endpoints report Docker/image/workspace readiness, recent sandbox logs, file-change provenance, and renderable artifact metadata without returning API keys or npm tokens. Artifact content is loaded on demand through guarded session/workspace reads.
@@ -7,10 +7,12 @@ AgInTiFlow keeps CLI and web runs equivalent by using the project folder as the
7
7
  - Web settings database: `<project>/.sessions/web-state.sqlite`.
8
8
  - Runtime inbox: `<project>/.sessions/<session-id>/inbox.jsonl`.
9
9
 
10
- When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
10
+ When a run is active, the web chat and `aginti queue <session-id> "..."` append messages to the inbox instead of trying to mutate the running process directly. The web API exposes `GET /api/sessions/:id/inbox`, `POST /api/sessions/:id/inbox`, `PATCH /api/sessions/:id/inbox/:itemId`, and `DELETE /api/sessions/:id/inbox/:itemId` so browser users can inspect, edit, or remove pending pipe messages before the runner consumes them. The runner drains the inbox at safe boundaries: before each model step and after tool execution. This mirrors the event-queue style used by mature agent UIs while keeping the backend decoupled from any specific frontend.
11
11
 
12
12
  The interactive CLI keeps the input panel visible while a run is working. Enter sends the current draft as an ASAP pipe message and displays it as `→`; the runner drains those messages before normal inbox items. Tab stores the draft as an after-finish queue item and displays it as `↳`; those prompts run only after the current run completes. Alt+Up moves the last pending `→` message back into the editor, and Shift+Left moves the last pending `↳` message back into the editor. The current command cwd is rendered below the input panel in both idle and running states.
13
13
 
14
+ The web UI uses a related but browser-appropriate pattern. Enter sends and Shift+Enter adds a newline. `Pipe to run` writes an ASAP inbox item shared with CLI. `Queue after finish` keeps a browser-local next prompt and starts it after the current web-owned run finishes. Both lanes render in a pending panel with Edit and Remove buttons instead of terminal-only keybindings.
15
+
14
16
  Runs can be stopped without corrupting session state. The CLI listens for Esc or Ctrl+C during an active run, and the web UI exposes a Stop button plus Esc. Both paths send an abort signal, persist `session.stopped`, and leave the session resumable through `aginti resume <session-id>`.
15
17
 
16
18
  Default execution is Docker workspace mode with package installs approved inside the sandbox. The project is mounted at `/workspace`; persistent agent toolchain folders are mounted at `/aginti-home`, `/aginti-cache`, and `/aginti-env` from `~/.agintiflow/docker/`. Python, conda, and other language-level environments should be installed under `/aginti-env` so they survive across runs. Apt/apk package changes are ephemeral unless the Docker image is rebuilt.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.12.6",
3
+ "version": "0.12.8",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a resumable Playwright website-control agent with OpenAI-compatible tool calling.",
6
6
  "license": "Apache-2.0",
package/public/app.js CHANGED
@@ -122,8 +122,18 @@ const translations = {
122
122
  sendingStatus: "Sending...",
123
123
  runningStatus: "Running...",
124
124
  queuedStatus: "Queued for the running agent.",
125
+ queuedAsapStatus: "Piped into the shared session inbox.",
126
+ queuedAfterFinishStatus: "Queued for the next web run.",
125
127
  failedContinue: "Failed to continue the conversation.",
126
- chatShortcutHelp: "Enter sends · Ctrl+J inserts a newline · Tab queues while the agent is running",
128
+ chatShortcutHelp: "Enter sends · Shift+Enter adds a newline · use buttons for pipe/queue control",
129
+ pipeMessageButton: "Pipe to run",
130
+ queueAfterFinishButton: "Queue after finish",
131
+ pendingMessagesTitle: "Pending messages",
132
+ asapQueueLabel: "ASAP pipe",
133
+ afterFinishQueueLabel: "After finish",
134
+ editQueuedMessage: "Edit",
135
+ removeQueuedMessage: "Remove",
136
+ queuedConsumedHint: "ASAP pipe messages are shared with CLI and consumed at the next safe agent boundary.",
127
137
  conversationMenuLabel: "Manage conversations",
128
138
  manageConversationsTitle: "Manage conversations",
129
139
  manageConversationsHelp: "Rename, auto-rename, or delete saved chat history.",
@@ -671,7 +681,10 @@ const sessionSelectEl = document.querySelector("#session-select");
671
681
  const chatThreadEl = document.querySelector("#chat-thread");
672
682
  const chatFormEl = document.querySelector("#chat-form");
673
683
  const chatInputEl = document.querySelector("#chat-input");
684
+ const chatPendingEl = document.querySelector("#chat-pending");
674
685
  const chatStatusEl = document.querySelector("#chat-status");
686
+ const pipeMessageButton = document.querySelector("#pipe-message");
687
+ const queueAfterFinishButton = document.querySelector("#queue-after-finish");
675
688
  const manageSessionsButton = document.querySelector("#manage-sessions");
676
689
  const openArtifactsButton = document.querySelector("#open-artifacts");
677
690
  const artifactBadgeEl = document.querySelector("#artifact-badge");
@@ -714,6 +727,9 @@ let pollTimer = null;
714
727
  let saveTimer = null;
715
728
  let lastChatEntries = [];
716
729
  let lastSessions = [];
730
+ let pendingInboxItems = [];
731
+ let pendingAfterFinishItems = [];
732
+ let flushingAfterFinish = false;
717
733
  let lastKeyStatus = null;
718
734
  let lastWrappers = [];
719
735
  let lastSandbox = null;
@@ -1270,6 +1286,233 @@ function renderChat(chatEntries) {
1270
1286
  chatThreadEl.scrollTop = chatThreadEl.scrollHeight;
1271
1287
  }
1272
1288
 
1289
+ function afterFinishQueueKey(sessionId = currentSessionId) {
1290
+ return `agintiflow.web.afterFinishQueue.${sessionId || "none"}`;
1291
+ }
1292
+
1293
+ function loadAfterFinishQueue(sessionId = currentSessionId) {
1294
+ if (!sessionId) return [];
1295
+ try {
1296
+ const parsed = JSON.parse(localStorage.getItem(afterFinishQueueKey(sessionId)) || "[]");
1297
+ return Array.isArray(parsed) ? parsed.filter((item) => item?.id && item?.content) : [];
1298
+ } catch {
1299
+ return [];
1300
+ }
1301
+ }
1302
+
1303
+ function saveAfterFinishQueue(sessionId = currentSessionId) {
1304
+ if (!sessionId) return;
1305
+ localStorage.setItem(afterFinishQueueKey(sessionId), JSON.stringify(pendingAfterFinishItems));
1306
+ }
1307
+
1308
+ function newLocalQueueId() {
1309
+ if (globalThis.crypto?.randomUUID) return `after-${globalThis.crypto.randomUUID()}`;
1310
+ return `after-${Date.now()}-${Math.random().toString(16).slice(2)}`;
1311
+ }
1312
+
1313
+ function compactMessage(content, limit = 220) {
1314
+ const text = String(content || "").replace(/\s+/g, " ").trim();
1315
+ return text.length > limit ? `${text.slice(0, limit - 1)}...` : text;
1316
+ }
1317
+
1318
+ function renderPendingMessages() {
1319
+ if (!chatPendingEl) return;
1320
+ const inboxItems = pendingInboxItems || [];
1321
+ const afterItems = pendingAfterFinishItems || [];
1322
+ if (inboxItems.length === 0 && afterItems.length === 0) {
1323
+ chatPendingEl.hidden = true;
1324
+ chatPendingEl.innerHTML = "";
1325
+ return;
1326
+ }
1327
+
1328
+ const section = (label, items, kind) => {
1329
+ if (!items.length) return "";
1330
+ return `
1331
+ <div class="pending-section">
1332
+ <div class="pending-label">${escapeHtml(label)}</div>
1333
+ ${items
1334
+ .map(
1335
+ (item) => `
1336
+ <div class="pending-item">
1337
+ <div class="pending-copy">
1338
+ <span class="pending-kind ${kind}">${kind === "asap" ? "→" : "↳"}</span>
1339
+ <span>${escapeHtml(compactMessage(item.content))}</span>
1340
+ </div>
1341
+ <div class="pending-actions">
1342
+ <button type="button" class="mini-button" data-edit-${kind}="${escapeHtml(item.id)}">${t(
1343
+ "editQueuedMessage"
1344
+ )}</button>
1345
+ <button type="button" class="mini-button danger" data-remove-${kind}="${escapeHtml(item.id)}">${t(
1346
+ "removeQueuedMessage"
1347
+ )}</button>
1348
+ </div>
1349
+ </div>
1350
+ `
1351
+ )
1352
+ .join("")}
1353
+ </div>
1354
+ `;
1355
+ };
1356
+
1357
+ chatPendingEl.hidden = false;
1358
+ chatPendingEl.innerHTML = `
1359
+ <div class="pending-header">
1360
+ <strong>${t("pendingMessagesTitle")}</strong>
1361
+ <span>${t("queuedConsumedHint")}</span>
1362
+ </div>
1363
+ ${section(t("asapQueueLabel"), inboxItems, "asap")}
1364
+ ${section(t("afterFinishQueueLabel"), afterItems, "after")}
1365
+ `;
1366
+ }
1367
+
1368
+ async function refreshInbox() {
1369
+ if (!currentSessionId) {
1370
+ pendingInboxItems = [];
1371
+ pendingAfterFinishItems = [];
1372
+ renderPendingMessages();
1373
+ return;
1374
+ }
1375
+
1376
+ pendingAfterFinishItems = loadAfterFinishQueue(currentSessionId);
1377
+ const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/inbox`);
1378
+ if (!response.ok) {
1379
+ pendingInboxItems = [];
1380
+ renderPendingMessages();
1381
+ return;
1382
+ }
1383
+ const data = await response.json();
1384
+ pendingInboxItems = data.items || [];
1385
+ renderPendingMessages();
1386
+ }
1387
+
1388
+ async function updateInboxItem(itemId, content) {
1389
+ const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/inbox/${encodeURIComponent(itemId)}`, {
1390
+ method: "PATCH",
1391
+ headers: { "Content-Type": "application/json" },
1392
+ body: JSON.stringify({ content }),
1393
+ });
1394
+ const data = await response.json().catch(() => ({}));
1395
+ if (!response.ok) throw new Error(data.error || t("failedContinue"));
1396
+ await refreshInbox();
1397
+ }
1398
+
1399
+ async function removeInboxItem(itemId) {
1400
+ const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/inbox/${encodeURIComponent(itemId)}`, {
1401
+ method: "DELETE",
1402
+ });
1403
+ const data = await response.json().catch(() => ({}));
1404
+ if (!response.ok) throw new Error(data.error || t("failedContinue"));
1405
+ await refreshInbox();
1406
+ }
1407
+
1408
+ function editAfterFinishItem(itemId) {
1409
+ const item = pendingAfterFinishItems.find((candidate) => candidate.id === itemId);
1410
+ if (!item) return;
1411
+ pendingAfterFinishItems = pendingAfterFinishItems.filter((candidate) => candidate.id !== itemId);
1412
+ saveAfterFinishQueue();
1413
+ chatInputEl.value = item.content;
1414
+ chatInputEl.focus();
1415
+ renderPendingMessages();
1416
+ }
1417
+
1418
+ function removeAfterFinishItem(itemId) {
1419
+ pendingAfterFinishItems = pendingAfterFinishItems.filter((candidate) => candidate.id !== itemId);
1420
+ saveAfterFinishQueue();
1421
+ renderPendingMessages();
1422
+ }
1423
+
1424
+ function queueAfterFinishFromInput() {
1425
+ if (!currentSessionId) {
1426
+ chatStatusEl.textContent = t("selectSessionFirst");
1427
+ return;
1428
+ }
1429
+ const content = chatInputEl.value.trim();
1430
+ if (!content) {
1431
+ chatStatusEl.textContent = t("messageRequired");
1432
+ return;
1433
+ }
1434
+ pendingAfterFinishItems = [
1435
+ ...pendingAfterFinishItems,
1436
+ {
1437
+ id: newLocalQueueId(),
1438
+ content,
1439
+ timestamp: new Date().toISOString(),
1440
+ source: "web",
1441
+ },
1442
+ ];
1443
+ saveAfterFinishQueue();
1444
+ chatInputEl.value = "";
1445
+ chatStatusEl.textContent = t("queuedAfterFinishStatus");
1446
+ renderPendingMessages();
1447
+ }
1448
+
1449
+ async function pipeMessageFromInput() {
1450
+ if (!currentSessionId) {
1451
+ chatStatusEl.textContent = t("selectSessionFirst");
1452
+ return;
1453
+ }
1454
+ const content = chatInputEl.value.trim();
1455
+ if (!content) {
1456
+ chatStatusEl.textContent = t("messageRequired");
1457
+ return;
1458
+ }
1459
+
1460
+ chatStatusEl.textContent = t("sendingStatus");
1461
+ const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/inbox`, {
1462
+ method: "POST",
1463
+ headers: { "Content-Type": "application/json" },
1464
+ body: JSON.stringify({ content, priority: "asap" }),
1465
+ });
1466
+ const data = await response.json().catch(() => ({}));
1467
+ if (!response.ok) {
1468
+ chatStatusEl.textContent = data.error || t("failedContinue");
1469
+ return;
1470
+ }
1471
+ chatInputEl.value = "";
1472
+ chatStatusEl.textContent = t("queuedAsapStatus");
1473
+ pendingInboxItems = data.item ? [...pendingInboxItems, data.item] : pendingInboxItems;
1474
+ renderPendingMessages();
1475
+ await refreshInbox();
1476
+ }
1477
+
1478
+ async function flushAfterFinishQueue() {
1479
+ if (!currentSessionId || flushingAfterFinish || currentRunStatus === "running" || pendingAfterFinishItems.length === 0) return;
1480
+ flushingAfterFinish = true;
1481
+ const next = pendingAfterFinishItems[0];
1482
+ pendingAfterFinishItems = pendingAfterFinishItems.slice(1);
1483
+ saveAfterFinishQueue();
1484
+ renderPendingMessages();
1485
+
1486
+ try {
1487
+ const response = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/messages`, {
1488
+ method: "POST",
1489
+ headers: { "Content-Type": "application/json" },
1490
+ body: JSON.stringify({
1491
+ ...formPayload(),
1492
+ content: next.content,
1493
+ }),
1494
+ });
1495
+ const data = await response.json().catch(() => ({}));
1496
+ if (!response.ok) throw new Error(data.error || t("failedContinue"));
1497
+ currentSessionId = data.sessionId;
1498
+ currentRunStatus = data.queued ? currentRunStatus : "running";
1499
+ updateStopRunButton();
1500
+ chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
1501
+ await refreshSessions();
1502
+ await refreshChat();
1503
+ if (pollTimer) clearInterval(pollTimer);
1504
+ pollTimer = setInterval(refreshRun, 1500);
1505
+ await refreshRun();
1506
+ } catch (error) {
1507
+ pendingAfterFinishItems = [next, ...pendingAfterFinishItems];
1508
+ saveAfterFinishQueue();
1509
+ renderPendingMessages();
1510
+ chatStatusEl.textContent = error instanceof Error ? error.message : String(error);
1511
+ } finally {
1512
+ flushingAfterFinish = false;
1513
+ }
1514
+ }
1515
+
1273
1516
  function artifactReadKey(sessionId = currentSessionId) {
1274
1517
  return `agintiflow.artifacts.readIds.${sessionId || "none"}`;
1275
1518
  }
@@ -1682,6 +1925,7 @@ async function refreshRun() {
1682
1925
  runMetaEl.textContent = `${run.status} · ${run.sessionId}`;
1683
1926
  renderLogs(run);
1684
1927
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
1928
+ await refreshInbox();
1685
1929
 
1686
1930
  if (run.status === "finished" || run.status === "failed" || run.status === "stopped") {
1687
1931
  clearInterval(pollTimer);
@@ -1690,6 +1934,7 @@ async function refreshRun() {
1690
1934
  await refreshSessions();
1691
1935
  await refreshWorkspaceChanges();
1692
1936
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
1937
+ if (run.status === "finished") await flushAfterFinishQueue();
1693
1938
  }
1694
1939
  }
1695
1940
 
@@ -1711,6 +1956,9 @@ async function stopCurrentRun() {
1711
1956
 
1712
1957
  async function refreshChat() {
1713
1958
  if (!currentSessionId) {
1959
+ pendingInboxItems = [];
1960
+ pendingAfterFinishItems = [];
1961
+ renderPendingMessages();
1714
1962
  renderChat([]);
1715
1963
  return;
1716
1964
  }
@@ -1722,6 +1970,9 @@ async function refreshChat() {
1722
1970
  }
1723
1971
 
1724
1972
  const data = await response.json();
1973
+ pendingInboxItems = data.inbox || [];
1974
+ pendingAfterFinishItems = loadAfterFinishQueue(currentSessionId);
1975
+ renderPendingMessages();
1725
1976
  renderChat(data.chat || []);
1726
1977
  }
1727
1978
 
@@ -2019,24 +2270,54 @@ form.addEventListener("input", schedulePreferenceSave);
2019
2270
  form.addEventListener("change", schedulePreferenceSave);
2020
2271
 
2021
2272
  chatInputEl.addEventListener("keydown", (event) => {
2022
- if (event.key === "j" && event.ctrlKey && !event.metaKey && !event.altKey) {
2273
+ if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
2023
2274
  event.preventDefault();
2024
- const start = chatInputEl.selectionStart ?? chatInputEl.value.length;
2025
- const end = chatInputEl.selectionEnd ?? chatInputEl.value.length;
2026
- chatInputEl.value = `${chatInputEl.value.slice(0, start)}\n${chatInputEl.value.slice(end)}`;
2027
- chatInputEl.selectionStart = chatInputEl.selectionEnd = start + 1;
2275
+ chatFormEl.requestSubmit();
2276
+ }
2277
+ });
2278
+
2279
+ pipeMessageButton?.addEventListener("click", () => {
2280
+ pipeMessageFromInput().catch((error) => {
2281
+ chatStatusEl.textContent = String(error);
2282
+ });
2283
+ });
2284
+
2285
+ queueAfterFinishButton?.addEventListener("click", queueAfterFinishFromInput);
2286
+
2287
+ chatPendingEl?.addEventListener("click", (event) => {
2288
+ const target = event.target.closest("button");
2289
+ if (!target) return;
2290
+
2291
+ const editAsap = target.dataset.editAsap;
2292
+ const removeAsap = target.dataset.removeAsap;
2293
+ const editAfter = target.dataset.editAfter;
2294
+ const removeAfter = target.dataset.removeAfter;
2295
+
2296
+ if (editAsap) {
2297
+ const item = pendingInboxItems.find((candidate) => candidate.id === editAsap);
2298
+ const next = prompt(t("editQueuedMessage"), item?.content || "");
2299
+ if (next !== null) {
2300
+ updateInboxItem(editAsap, next).catch((error) => {
2301
+ chatStatusEl.textContent = String(error);
2302
+ });
2303
+ }
2028
2304
  return;
2029
2305
  }
2030
2306
 
2031
- if (event.key === "Tab") {
2032
- event.preventDefault();
2033
- chatFormEl.requestSubmit();
2307
+ if (removeAsap) {
2308
+ removeInboxItem(removeAsap).catch((error) => {
2309
+ chatStatusEl.textContent = String(error);
2310
+ });
2034
2311
  return;
2035
2312
  }
2036
2313
 
2037
- if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
2038
- event.preventDefault();
2039
- chatFormEl.requestSubmit();
2314
+ if (editAfter) {
2315
+ editAfterFinishItem(editAfter);
2316
+ return;
2317
+ }
2318
+
2319
+ if (removeAfter) {
2320
+ removeAfterFinishItem(removeAfter);
2040
2321
  }
2041
2322
  });
2042
2323
 
@@ -2108,10 +2389,13 @@ sessionSelectEl.addEventListener("change", async () => {
2108
2389
  currentSessionId = sessionSelectEl.value;
2109
2390
  if (!currentSessionId) {
2110
2391
  currentRunStatus = "";
2392
+ pendingInboxItems = [];
2393
+ pendingAfterFinishItems = [];
2111
2394
  updateStopRunButton();
2112
2395
  runMetaEl.textContent = "";
2113
2396
  setLogs(t("noRunSelected"), "empty");
2114
2397
  renderChat([]);
2398
+ renderPendingMessages();
2115
2399
  artifactItems = [];
2116
2400
  selectedArtifactId = "";
2117
2401
  renderArtifactShell();
@@ -2158,18 +2442,8 @@ chatFormEl.addEventListener("submit", async (event) => {
2158
2442
  updateStopRunButton();
2159
2443
  chatInputEl.value = "";
2160
2444
  chatStatusEl.textContent = data.queued ? t("queuedStatus") : t("runningStatus");
2161
- if (data.queued) {
2162
- renderChat([
2163
- ...lastChatEntries,
2164
- {
2165
- role: "user",
2166
- content,
2167
- at: new Date().toISOString(),
2168
- },
2169
- ]);
2170
- }
2171
2445
  await refreshSessions();
2172
- if (!data.queued) await refreshChat();
2446
+ await refreshChat();
2173
2447
  await refreshWorkspaceChanges();
2174
2448
  await refreshArtifacts({ loadSelected: artifactTunnelDialog?.open });
2175
2449
 
package/public/index.html CHANGED
@@ -313,10 +313,15 @@
313
313
  placeholder="Continue the selected conversation here."
314
314
  ></textarea>
315
315
  <p class="chat-shortcuts subtle" data-i18n="chatShortcutHelp">
316
- Enter sends · Ctrl+J inserts a newline · Tab queues while the agent is running
316
+ Enter sends · Shift+Enter adds a newline · use buttons for pipe/queue control
317
317
  </p>
318
+ <div id="chat-pending" class="chat-pending" hidden></div>
318
319
  <div class="actions">
319
320
  <button type="submit" data-i18n="sendMessageButton">Send message</button>
321
+ <button id="pipe-message" type="button" class="secondary" data-i18n="pipeMessageButton">Pipe to run</button>
322
+ <button id="queue-after-finish" type="button" class="secondary" data-i18n="queueAfterFinishButton">
323
+ Queue after finish
324
+ </button>
320
325
  <span id="chat-status" class="subtle"></span>
321
326
  </div>
322
327
  </form>
package/public/styles.css CHANGED
@@ -742,6 +742,108 @@ button.danger {
742
742
  line-height: 1.35;
743
743
  }
744
744
 
745
+ .chat-pending {
746
+ display: grid;
747
+ gap: 10px;
748
+ padding: 10px;
749
+ border: 1px solid rgba(15, 118, 110, 0.16);
750
+ border-radius: 14px;
751
+ background:
752
+ linear-gradient(135deg, rgba(240, 253, 250, 0.9), rgba(255, 251, 235, 0.72)),
753
+ rgba(255, 255, 255, 0.78);
754
+ }
755
+
756
+ .pending-header {
757
+ display: flex;
758
+ flex-wrap: wrap;
759
+ gap: 8px 12px;
760
+ align-items: baseline;
761
+ justify-content: space-between;
762
+ color: var(--muted);
763
+ font-size: 0.78rem;
764
+ }
765
+
766
+ .pending-header strong,
767
+ .pending-label {
768
+ color: var(--ink);
769
+ }
770
+
771
+ .pending-section {
772
+ display: grid;
773
+ gap: 7px;
774
+ }
775
+
776
+ .pending-label {
777
+ font-size: 0.74rem;
778
+ font-weight: 800;
779
+ letter-spacing: 0.06em;
780
+ text-transform: uppercase;
781
+ }
782
+
783
+ .pending-item {
784
+ display: grid;
785
+ grid-template-columns: minmax(0, 1fr) auto;
786
+ gap: 8px;
787
+ align-items: center;
788
+ padding: 8px 10px;
789
+ border: 1px solid rgba(15, 118, 110, 0.12);
790
+ border-radius: 12px;
791
+ background: rgba(255, 255, 255, 0.82);
792
+ }
793
+
794
+ .pending-copy {
795
+ min-width: 0;
796
+ display: flex;
797
+ gap: 8px;
798
+ align-items: flex-start;
799
+ color: var(--ink);
800
+ font-size: 0.86rem;
801
+ line-height: 1.35;
802
+ }
803
+
804
+ .pending-kind {
805
+ flex: 0 0 auto;
806
+ display: inline-grid;
807
+ width: 20px;
808
+ height: 20px;
809
+ place-items: center;
810
+ border-radius: 999px;
811
+ font-size: 0.82rem;
812
+ font-weight: 900;
813
+ }
814
+
815
+ .pending-kind.asap {
816
+ color: #075985;
817
+ background: rgba(14, 165, 233, 0.14);
818
+ }
819
+
820
+ .pending-kind.after {
821
+ color: #92400e;
822
+ background: rgba(245, 158, 11, 0.16);
823
+ }
824
+
825
+ .pending-actions {
826
+ display: flex;
827
+ flex-wrap: wrap;
828
+ gap: 6px;
829
+ justify-content: flex-end;
830
+ }
831
+
832
+ .mini-button {
833
+ min-height: auto;
834
+ padding: 5px 8px;
835
+ border-radius: 9px;
836
+ font-size: 0.76rem;
837
+ background: rgba(15, 118, 110, 0.08);
838
+ color: #0f766e;
839
+ box-shadow: none;
840
+ }
841
+
842
+ .mini-button.danger {
843
+ color: #b91c1c;
844
+ background: rgba(239, 68, 68, 0.08);
845
+ }
846
+
745
847
  .modal {
746
848
  width: min(720px, calc(100% - 28px));
747
849
  max-width: 100%;
@@ -1152,6 +1254,14 @@ button.danger {
1152
1254
  grid-template-columns: 1fr;
1153
1255
  }
1154
1256
 
1257
+ .pending-item {
1258
+ grid-template-columns: 1fr;
1259
+ }
1260
+
1261
+ .pending-actions {
1262
+ justify-content: flex-start;
1263
+ }
1264
+
1155
1265
  .panel {
1156
1266
  padding: 15px;
1157
1267
  }
@@ -69,6 +69,22 @@ try {
69
69
  if (!renderedMarkdown.includes("Check") || !renderedMarkdown.includes("Present")) {
70
70
  throw new Error("terminal markdown renderer dropped table content");
71
71
  }
72
+ if ((renderedMarkdown.match(/Present/g) || []).length !== 1) {
73
+ throw new Error("terminal markdown renderer duplicated table rows");
74
+ }
75
+ const renderedDiff = stripMarkdown(
76
+ [
77
+ "Diff:",
78
+ "--- a/example.txt",
79
+ "+++ b/example.txt",
80
+ "@@ line 1 @@",
81
+ "-old",
82
+ "+new",
83
+ ].join("\n")
84
+ );
85
+ if (!renderedDiff.includes("-old") || !renderedDiff.includes("+new")) {
86
+ throw new Error("terminal markdown renderer dropped patch diff lines");
87
+ }
72
88
 
73
89
  const promptLayout = buildPromptLayout(`${"x".repeat(180)}\nsecond line`, 95, 80, 24);
74
90
  const visibleLengths = promptLayout.renderedRows.map((line) =>
@@ -86,13 +102,14 @@ try {
86
102
  }
87
103
  const queuedPromptLayout = buildPromptLayout("follow up", 9, 90, 24, {
88
104
  commandCwd: "/tmp/aginti-project",
105
+ statusLine: "running · tool: apply_patch with a very long request that should be compacted in the panel",
89
106
  pendingAsap: [{ content: "apply this to the running task" }],
90
107
  pendingQueued: [{ content: "run this after the current task" }],
91
108
  });
92
109
  const queuedText = queuedPromptLayout.renderedRows
93
110
  .map((line) => line.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ""))
94
111
  .join("\n");
95
- if (!queuedText.includes("→ apply this") || !queuedText.includes("↳ run this") || !queuedText.includes("cwd /tmp/aginti-project")) {
112
+ if (!queuedText.includes("run running · tool: apply_patch") || !queuedText.includes("→ apply this") || !queuedText.includes("↳ run this") || !queuedText.includes("cwd /tmp/aginti-project")) {
96
113
  throw new Error("terminal prompt layout did not render live input queue and cwd footer");
97
114
  }
98
115
 
@@ -119,8 +136,8 @@ try {
119
136
  if (!result.stdout.includes("Interactive agent chat")) {
120
137
  throw new Error("interactive chat did not print its banner");
121
138
  }
122
- if (!result.stdout.includes("status=running workingOn=") || !result.stdout.includes("status=idle session=")) {
123
- throw new Error("interactive chat did not print simple run status updates");
139
+ if (!result.stdout.includes("status=idle session=")) {
140
+ throw new Error("interactive chat did not print final run status");
124
141
  }
125
142
  if (!/aginti>\s*\r?\n\s*\|\s+Mock run complete\./.test(result.stdout)) {
126
143
  throw new Error("assistant response did not render with a fresh-line response gutter");
@@ -139,7 +156,7 @@ try {
139
156
  {
140
157
  ok: true,
141
158
  projectRoot: tempRoot,
142
- checks: ["markdown-render", "prompt-layout", "live-input-layout", "agent-response-gutter", "aginti-md", "instructions-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-preview"],
159
+ checks: ["markdown-render", "markdown-table-no-duplicate", "patch-diff-render", "prompt-layout", "live-input-status-layout", "agent-response-gutter", "aginti-md", "instructions-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest", "resume-history-preview"],
143
160
  },
144
161
  null,
145
162
  2
@@ -4,6 +4,7 @@ import fs from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { SessionStore } from "../src/session-store.js";
7
8
 
8
9
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-api-smoke-"));
@@ -165,6 +166,38 @@ try {
165
166
 
166
167
  const chat = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}/chat`);
167
168
  if (!Array.isArray(chat.chat) || chat.chat.length < 2) throw new Error("chat history was not persisted");
169
+ if (!Array.isArray(chat.inbox)) throw new Error("chat endpoint did not include shared inbox state");
170
+
171
+ const queued = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}/inbox`, {
172
+ method: "POST",
173
+ headers: { "Content-Type": "application/json" },
174
+ body: JSON.stringify({ content: "continue from web inbox smoke", priority: "asap" }),
175
+ });
176
+ if (!queued.item?.id || queued.item.priority !== "asap") throw new Error("inbox queue endpoint did not return an ASAP item");
177
+
178
+ const inbox = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}/inbox`);
179
+ if (!inbox.items?.some((item) => item.id === queued.item.id)) throw new Error("inbox endpoint did not list queued item");
180
+
181
+ const edited = await fetchJson(
182
+ `/api/sessions/${encodeURIComponent(runStart.sessionId)}/inbox/${encodeURIComponent(queued.item.id)}`,
183
+ {
184
+ method: "PATCH",
185
+ headers: { "Content-Type": "application/json" },
186
+ body: JSON.stringify({ content: "edited web inbox smoke" }),
187
+ }
188
+ );
189
+ if (!edited.item?.content.includes("edited web inbox smoke")) throw new Error("inbox edit endpoint did not persist content");
190
+
191
+ const deletedInbox = await fetchJson(
192
+ `/api/sessions/${encodeURIComponent(runStart.sessionId)}/inbox/${encodeURIComponent(queued.item.id)}`,
193
+ {
194
+ method: "DELETE",
195
+ }
196
+ );
197
+ if (!deletedInbox.ok) throw new Error("inbox delete endpoint failed");
198
+ const inboxStore = new SessionStore(path.join(runtimeDir, ".sessions"), runStart.sessionId);
199
+ const remainingInbox = await inboxStore.loadInbox();
200
+ if (remainingInbox.some((item) => item.id === queued.item.id)) throw new Error("inbox delete endpoint left item on disk");
168
201
 
169
202
  const renamed = await fetchJson(`/api/sessions/${encodeURIComponent(runStart.sessionId)}`, {
170
203
  method: "PATCH",
@@ -248,6 +281,10 @@ try {
248
281
  "/api/sandbox/preflight",
249
282
  "/api/runs",
250
283
  "/api/sessions/:id/chat",
284
+ "/api/sessions/:id/inbox",
285
+ "POST /api/sessions/:id/inbox",
286
+ "PATCH /api/sessions/:id/inbox/:itemId",
287
+ "DELETE /api/sessions/:id/inbox/:itemId",
251
288
  "PATCH /api/sessions/:id",
252
289
  "POST /api/sessions/:id/auto-title",
253
290
  "DELETE /api/sessions/:id",
@@ -28,6 +28,7 @@ const ansi = {
28
28
  userBg: "\x1b[48;5;24m\x1b[38;5;231m",
29
29
  agentBg: "\x1b[48;5;29m\x1b[38;5;231m",
30
30
  responseBg: "\x1b[48;5;25m\x1b[38;5;231m",
31
+ statusBg: "\x1b[48;5;23m\x1b[38;5;231m",
31
32
  systemBg: "\x1b[48;5;236m\x1b[38;5;245m",
32
33
  };
33
34
  const brandPalette = ["\x1b[38;5;45m", "\x1b[38;5;81m", "\x1b[38;5;86m", "\x1b[38;5;118m", "\x1b[38;5;226m"];
@@ -152,6 +153,7 @@ function compactLine(value = "", limit = 96) {
152
153
  export function stripMarkdown(text) {
153
154
  const lines = String(text || "").split(/\r?\n/);
154
155
  let inFence = false;
156
+ let diffContext = 0;
155
157
  const rendered = [];
156
158
 
157
159
  for (let index = 0; index < lines.length; index += 1) {
@@ -166,6 +168,15 @@ export function stripMarkdown(text) {
166
168
  continue;
167
169
  }
168
170
 
171
+ if (/^\s*Diff:\s*$/i.test(line)) diffContext = 80;
172
+ const patchLine = renderPatchLine(line, { active: diffContext > 0 || inFence });
173
+ if (patchLine) {
174
+ rendered.push(patchLine);
175
+ diffContext = 80;
176
+ continue;
177
+ }
178
+ if (diffContext > 0) diffContext -= 1;
179
+
169
180
  if (!inFence) {
170
181
  if (/^\s*[-*_]{3,}\s*$/.test(line)) {
171
182
  rendered.push(color("-".repeat(42), ansi.dim));
@@ -203,6 +214,17 @@ export function stripMarkdown(text) {
203
214
  return rendered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
204
215
  }
205
216
 
217
+ function renderPatchLine(line = "", { active = false } = {}) {
218
+ const value = String(line || "");
219
+ if (/^diff --git\s+/.test(value)) return color(value, ansi.bold, ansi.cyan);
220
+ if (/^@@\s+/.test(value)) return color(value, ansi.cyan);
221
+ if (/^---\s+a\//.test(value)) return color(value, ansi.red);
222
+ if (/^\+\+\+\s+b\//.test(value)) return color(value, ansi.green);
223
+ if (active && /^\+(?!\+\+)/.test(value)) return color(value, ansi.green);
224
+ if (active && /^-(?!--)/.test(value)) return color(value, ansi.red);
225
+ return "";
226
+ }
227
+
206
228
  function splitMarkdownTableRow(line = "") {
207
229
  const trimmed = String(line || "").trim();
208
230
  if (!trimmed.includes("|")) return null;
@@ -519,6 +541,11 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
519
541
  const renderedRows = [];
520
542
  let renderedCursorRow = cursorRow - visible.start;
521
543
 
544
+ if (options.statusLine) {
545
+ renderedRows.push(panelLine(` run ${compactLine(options.statusLine, lineWidth - 8)}`, ansi.statusBg, lineWidth));
546
+ renderedCursorRow += 1;
547
+ }
548
+
522
549
  const pendingAsap = Array.isArray(options.pendingAsap) ? options.pendingAsap : [];
523
550
  const pendingQueued = Array.isArray(options.pendingQueued) ? options.pendingQueued : [];
524
551
  for (const item of pendingAsap.slice(-4)) {
@@ -887,6 +914,7 @@ class LiveRunInput {
887
914
  this.preferredColumn = null;
888
915
  this.pendingAsap = [];
889
916
  this.pendingQueued = [];
917
+ this.statusLine = "";
890
918
  this.wasRaw = Boolean(input.isRaw);
891
919
  this.started = false;
892
920
  this.handler = this.handleKey.bind(this);
@@ -952,6 +980,7 @@ class LiveRunInput {
952
980
  }
953
981
  this.rendered = renderPromptBuffer(this.buffer, this.cursor, this.rendered, {
954
982
  commandCwd: this.commandCwd,
983
+ statusLine: this.statusLine,
955
984
  pendingAsap: this.pendingAsap,
956
985
  pendingQueued: this.pendingQueued,
957
986
  });
@@ -972,9 +1001,17 @@ class LiveRunInput {
972
1001
  this.redraw();
973
1002
  }
974
1003
 
1004
+ setStatus(value = "") {
1005
+ const nextStatus = compactLine(value, Math.max(terminalWidth() - 16, 36));
1006
+ if (this.statusLine === nextStatus) return;
1007
+ this.statusLine = nextStatus;
1008
+ this.redraw();
1009
+ }
1010
+
975
1011
  moveVertical(direction) {
976
1012
  const layout = buildPromptLayout(this.buffer, this.cursor, terminalWidth(), terminalHeight(), {
977
1013
  commandCwd: this.commandCwd,
1014
+ statusLine: this.statusLine,
978
1015
  pendingAsap: this.pendingAsap,
979
1016
  pendingQueued: this.pendingQueued,
980
1017
  });
@@ -1215,8 +1252,14 @@ async function printResumeHistory(state, { limit = 8 } = {}) {
1215
1252
  }
1216
1253
 
1217
1254
  function printStatusEvent(state, label, details = "") {
1218
- state.lastEvent = details ? `${label}: ${details}` : label;
1219
- printSystemLine(`status=${state.status || "running"} ${state.lastEvent}`);
1255
+ const safeDetails = compactLine(details, 72);
1256
+ state.lastEvent = safeDetails ? `${label}: ${safeDetails}` : label;
1257
+ const statusText = `${state.status || "running"} · ${state.lastEvent}`;
1258
+ if (activeRunInput) {
1259
+ activeRunInput.setStatus(statusText);
1260
+ return;
1261
+ }
1262
+ printSystemLine(`status=${statusText}`);
1220
1263
  }
1221
1264
 
1222
1265
  function attachRunInterrupts(controller) {
@@ -1595,15 +1638,19 @@ async function runPrompt(prompt, state, packageDir) {
1595
1638
 
1596
1639
  state.sessionId = config.resume || config.sessionId || state.sessionId;
1597
1640
  state.status = "running";
1598
- state.activeGoal = prompt.replace(/\s+/g, " ").slice(0, 120);
1641
+ state.activeGoal = compactLine(prompt, 84);
1599
1642
  state.lastEvent = "";
1600
- printSystemLine(`session=${state.sessionId}`);
1601
- printSystemLine(`status=running workingOn=${state.activeGoal}`);
1602
1643
 
1603
1644
  const store = new SessionStore(config.sessionsDir, state.sessionId);
1604
1645
  const liveInput = new LiveRunInput({ state, store, controller });
1605
1646
  const liveStarted = liveInput.start();
1606
1647
  const detachInterrupts = liveStarted ? () => {} : attachRunInterrupts(controller);
1648
+ if (liveStarted) {
1649
+ liveInput.setStatus(`running · ${state.activeGoal}`);
1650
+ } else {
1651
+ printSystemLine(`session=${state.sessionId}`);
1652
+ printSystemLine(`status=running workingOn=${state.activeGoal}`);
1653
+ }
1607
1654
  let result;
1608
1655
  let queuedAfterFinish = [];
1609
1656
  try {
@@ -1619,6 +1666,9 @@ async function runPrompt(prompt, state, packageDir) {
1619
1666
  printHeading(text);
1620
1667
  } else if (options.error) {
1621
1668
  outputLine(`${label("error", ansi.systemBg)} ${stripMarkdown(text)}`);
1669
+ } else if (options.kind === "meta" && liveStarted) {
1670
+ const trimmed = String(text || "").trim();
1671
+ if (trimmed) liveInput.setStatus(trimmed);
1622
1672
  } else {
1623
1673
  printSystemLine(text);
1624
1674
  }
package/web.js CHANGED
@@ -349,16 +349,49 @@ async function loadChat(sessionId) {
349
349
 
350
350
  if (!state && !meta) return null;
351
351
 
352
+ const inbox = await store.loadInbox().catch(() => []);
352
353
  return {
353
354
  sessionId,
354
355
  goal: state?.goal || meta?.goal || "",
355
356
  title: meta?.title || "",
356
357
  provider: state?.provider || meta?.provider || "",
357
358
  model: state?.model || meta?.model || "",
359
+ status: meta?.status || "",
360
+ inbox,
358
361
  chat: deriveChatFromState(state, meta),
359
362
  };
360
363
  }
361
364
 
365
+ function pushActiveInboxLog(sessionId, message, data = {}) {
366
+ const active = runs.get(sessionId);
367
+ if (active?.status !== "running") return;
368
+ active.logs.push({
369
+ at: new Date().toISOString(),
370
+ kind: "event",
371
+ message,
372
+ data,
373
+ });
374
+ active.updatedAt = new Date().toISOString();
375
+ db.upsertSession(active);
376
+ }
377
+
378
+ async function loadInboxSession(sessionId) {
379
+ if (!isSafeSessionId(sessionId)) {
380
+ return { error: "Invalid session id.", status: 400 };
381
+ }
382
+
383
+ const data = await loadChat(sessionId);
384
+ if (!data) {
385
+ return { error: "Session not found.", status: 404 };
386
+ }
387
+
388
+ return { store: sessionStore(sessionId), data };
389
+ }
390
+
391
+ function queuedMessagePreview(content) {
392
+ return String(content || "").replace(/\s+/g, " ").trim().slice(0, 180);
393
+ }
394
+
362
395
  function normalizeSessionTitle(title) {
363
396
  return String(title || "")
364
397
  .replace(/[\u0000-\u001f\u007f]/g, " ")
@@ -687,6 +720,128 @@ app.get("/api/sessions/:sessionId/chat", async (req, res) => {
687
720
  res.json(data);
688
721
  });
689
722
 
723
+ app.get("/api/sessions/:sessionId/inbox", async (req, res) => {
724
+ const loaded = await loadInboxSession(req.params.sessionId);
725
+ if (loaded.error) {
726
+ res.status(loaded.status || 500).json({ error: loaded.error });
727
+ return;
728
+ }
729
+
730
+ res.json({
731
+ ok: true,
732
+ sessionId: req.params.sessionId,
733
+ items: loaded.data.inbox || [],
734
+ });
735
+ });
736
+
737
+ app.post("/api/sessions/:sessionId/inbox", async (req, res) => {
738
+ const loaded = await loadInboxSession(req.params.sessionId);
739
+ if (loaded.error) {
740
+ res.status(loaded.status || 500).json({ error: loaded.error });
741
+ return;
742
+ }
743
+
744
+ const content = String(req.body?.content || "").trim();
745
+ if (!content) {
746
+ res.status(400).json({ error: "Message content is required." });
747
+ return;
748
+ }
749
+
750
+ const priority = req.body?.priority === "normal" ? "normal" : "asap";
751
+ const item = await loaded.store.appendInbox(content, { source: "web", priority });
752
+ await loaded.store
753
+ .appendEvent("conversation.queued_input", {
754
+ itemId: item?.id,
755
+ priority,
756
+ source: "web",
757
+ preview: queuedMessagePreview(content),
758
+ })
759
+ .catch(() => {});
760
+ pushActiveInboxLog(req.params.sessionId, "conversation.queued_input", {
761
+ itemId: item?.id,
762
+ priority,
763
+ source: "web",
764
+ preview: queuedMessagePreview(content),
765
+ });
766
+
767
+ res.json({ ok: true, sessionId: req.params.sessionId, item });
768
+ });
769
+
770
+ app.patch("/api/sessions/:sessionId/inbox/:itemId", async (req, res) => {
771
+ const loaded = await loadInboxSession(req.params.sessionId);
772
+ if (loaded.error) {
773
+ res.status(loaded.status || 500).json({ error: loaded.error });
774
+ return;
775
+ }
776
+
777
+ const content = String(req.body?.content || "").trim();
778
+ if (!content) {
779
+ res.status(400).json({ error: "Message content is required." });
780
+ return;
781
+ }
782
+
783
+ const items = await loaded.store.loadInbox();
784
+ const index = items.findIndex((item) => item.id === req.params.itemId);
785
+ if (index < 0) {
786
+ res.status(404).json({ error: "Queued message not found. It may already have been consumed by the agent." });
787
+ return;
788
+ }
789
+
790
+ const item = {
791
+ ...items[index],
792
+ content,
793
+ updatedAt: new Date().toISOString(),
794
+ editedBy: "web",
795
+ };
796
+ items[index] = item;
797
+ await loaded.store.saveInbox(items);
798
+ await loaded.store
799
+ .appendEvent("conversation.queued_input_edited", {
800
+ itemId: item.id,
801
+ source: "web",
802
+ preview: queuedMessagePreview(content),
803
+ })
804
+ .catch(() => {});
805
+ pushActiveInboxLog(req.params.sessionId, "conversation.queued_input_edited", {
806
+ itemId: item.id,
807
+ source: "web",
808
+ preview: queuedMessagePreview(content),
809
+ });
810
+
811
+ res.json({ ok: true, sessionId: req.params.sessionId, item });
812
+ });
813
+
814
+ app.delete("/api/sessions/:sessionId/inbox/:itemId", async (req, res) => {
815
+ const loaded = await loadInboxSession(req.params.sessionId);
816
+ if (loaded.error) {
817
+ res.status(loaded.status || 500).json({ error: loaded.error });
818
+ return;
819
+ }
820
+
821
+ const items = await loaded.store.loadInbox();
822
+ const item = items.find((candidate) => candidate.id === req.params.itemId);
823
+ if (!item) {
824
+ res.status(404).json({ error: "Queued message not found. It may already have been consumed by the agent." });
825
+ return;
826
+ }
827
+
828
+ await loaded.store.saveInbox(items.filter((candidate) => candidate.id !== req.params.itemId));
829
+ await loaded.store
830
+ .appendEvent("conversation.queued_input_removed", {
831
+ itemId: item.id,
832
+ source: "web",
833
+ preview: queuedMessagePreview(item.content),
834
+ })
835
+ .catch(() => {});
836
+ pushActiveInboxLog(req.params.sessionId, "conversation.queued_input_removed", {
837
+ itemId: item.id,
838
+ source: "web",
839
+ preview: queuedMessagePreview(item.content),
840
+ });
841
+
842
+ res.json({ ok: true, sessionId: req.params.sessionId, itemId: item.id });
843
+ });
844
+
690
845
  app.patch("/api/sessions/:sessionId", async (req, res) => {
691
846
  const sessionId = req.params.sessionId;
692
847
  if (!isSafeSessionId(sessionId)) {
@@ -795,17 +950,24 @@ app.post("/api/sessions/:sessionId/messages", async (req, res) => {
795
950
  const active = runs.get(sessionId);
796
951
  if (active?.status === "running") {
797
952
  const store = sessionStore(sessionId);
798
- await store.appendInbox(content, { source: "web" });
799
- await store.appendEvent("conversation.queued_input", { prompt: content, source: "web" }).catch(() => {});
953
+ const item = await store.appendInbox(content, { source: "web", priority: "asap" });
954
+ await store
955
+ .appendEvent("conversation.queued_input", {
956
+ itemId: item?.id,
957
+ priority: "asap",
958
+ source: "web",
959
+ preview: queuedMessagePreview(content),
960
+ })
961
+ .catch(() => {});
800
962
  active.logs.push({
801
963
  at: new Date().toISOString(),
802
964
  kind: "event",
803
965
  message: "conversation.queued_input",
804
- data: { prompt: content, source: "web" },
966
+ data: { itemId: item?.id, priority: "asap", source: "web", preview: queuedMessagePreview(content) },
805
967
  });
806
968
  active.updatedAt = new Date().toISOString();
807
969
  db.upsertSession(active);
808
- res.json({ sessionId, queued: true });
970
+ res.json({ sessionId, queued: true, item });
809
971
  return;
810
972
  }
811
973