@ouro.bot/cli 0.1.0-alpha.253 → 0.1.0-alpha.255

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/changelog.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.255",
6
+ "changes": [
7
+ "feat(tui): session resume shows last messages as regular chat (no dimmed preview) with teal resume banner in header.",
8
+ "feat(tui): image/file drag-and-drop \u2014 detects image paths in pasted text, reads to base64, inserts [Image #N] references, sends as image_url content blocks to model.",
9
+ "refactor(tui): removed custom history-* roles and addSessionHistory (KISS/DRY)."
10
+ ]
11
+ },
12
+ {
13
+ "version": "0.1.0-alpha.254",
14
+ "changes": [
15
+ "fix(daemon): orphan-cleanup fallback no longer kills processes from sibling harness instances. On startup, `killOrphanProcesses` scans `ps` for harness entry points (`agent-entry.js`, `daemon-entry.js`, `bluebubbles/entry.js`, `teams-entry.js`) and SIGTERMs them when the pidfile is missing. Previously any matching process was fair game, so a vitest-driven harness run from a sibling worktree (or a parallel Claude Code session running the coverage gate) would terminate the production daemon's children, triggering cascading graceful shutdowns and making production slugger unavailable for seconds-to-minutes at a time. Now the fallback only flags true orphans \u2014 processes whose PPID has been reparented to init (PID 1). Sibling daemons' live-parented children are left alone. New exported helper `parseOrphanPidsFromPs` isolates the filter for direct unit coverage. Complements the test-isolation guard shipped in alpha.253 (#333): that PR stopped tests from SENDING `inner.wake testagent` commands into the production socket; this PR stops the production daemon from SIGTERMing test-spawned child processes during its own startup."
16
+ ]
17
+ },
4
18
  {
5
19
  "version": "0.1.0-alpha.253",
6
20
  "changes": [
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.OuroDaemon = void 0;
37
+ exports.parseOrphanPidsFromPs = parseOrphanPidsFromPs;
37
38
  exports.killOrphanProcesses = killOrphanProcesses;
38
39
  exports.writePidfile = writePidfile;
39
40
  exports.handleAgentSenseTurn = handleAgentSenseTurn;
@@ -62,13 +63,55 @@ const outlook_types_1 = require("../outlook/outlook-types");
62
63
  const outlook_read_1 = require("../outlook/outlook-read");
63
64
  const outlook_view_1 = require("../outlook/outlook-view");
64
65
  const PIDFILE_PATH = path.join(os.homedir(), ".ouro-cli", "daemon.pids");
66
+ /**
67
+ * Scan `ps -eo pid,ppid,command` output for daemon-owned entry points whose
68
+ * parent has died (PPID reparented to init/PID 1). Returns the list of PIDs
69
+ * that are safe to SIGTERM — true orphans, not children of live sibling
70
+ * daemons running from worktrees, test suites, or other users of the harness.
71
+ *
72
+ * Exported so unit tests can exercise the filter without shelling out.
73
+ */
74
+ function parseOrphanPidsFromPs(psOutput, selfPid) {
75
+ const orphans = [];
76
+ for (const line of psOutput.split("\n")) {
77
+ // Explicitly exclude MCP server processes — they share a harness entry
78
+ // point but are not daemon children and must never be killed.
79
+ if (line.includes("mcp-serve") || line.includes("mcp serve"))
80
+ continue;
81
+ // Match only daemon-owned JS entry points.
82
+ if (!line.includes("agent-entry.js")
83
+ && !line.includes("daemon-entry.js")
84
+ && !line.includes("bluebubbles/entry.js")
85
+ && !line.includes("teams-entry.js"))
86
+ continue;
87
+ // Parse `<pid> <ppid> <command...>`. ps pads these with leading spaces.
88
+ // Regex guarantees both groups are \d+ so parseInt can't produce NaN.
89
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s/);
90
+ if (!match)
91
+ continue;
92
+ const pid = parseInt(match[1], 10);
93
+ const ppid = parseInt(match[2], 10);
94
+ if (pid === selfPid)
95
+ continue;
96
+ // CRITICAL: only kill processes whose parent is init (PID 1). A live
97
+ // PPID means the process belongs to another daemon instance (parallel
98
+ // test run, sibling worktree, another user of /tmp/ouroboros-daemon.sock).
99
+ // Killing those will crash unrelated harnesses — we saw this in B6
100
+ // when a vitest worker's daemon killed slugger's production children.
101
+ if (ppid !== 1)
102
+ continue;
103
+ orphans.push(pid);
104
+ }
105
+ return orphans;
106
+ }
65
107
  /**
66
108
  * Kill all ouro processes from the previous daemon instance using the pidfile.
67
109
  * On startup, reads PIDs from ~/.ouro-cli/daemon.pids, kills them all, then
68
110
  * deletes the file. The new daemon writes its own PIDs after spawning.
69
111
  *
70
- * Falls back to ps-based scanning if the pidfile doesn't exist (first run
71
- * or manual cleanup).
112
+ * Falls back to ps-based scanning scoped to true orphans (PPID=1) if the
113
+ * pidfile doesn't exist (first run, previous daemon crashed before writing,
114
+ * manual cleanup). The scope is narrow on purpose — see parseOrphanPidsFromPs.
72
115
  */
73
116
  /* v8 ignore start -- process lifecycle: uses kill/ps, tested via deployment @preserve */
74
117
  function killOrphanProcesses() {
@@ -81,22 +124,12 @@ function killOrphanProcesses() {
81
124
  fs.unlinkSync(PIDFILE_PATH);
82
125
  }
83
126
  catch {
84
- // No pidfile — fall back to ps scan
127
+ // No pidfile — fall back to ps scan (scoped to orphans with PPID=1).
85
128
  }
86
- // Fallback: scan ps for daemon-owned processes only (not MCP servers or external tools)
87
129
  if (pidsToKill.length === 0) {
88
130
  try {
89
- const result = (0, child_process_1.execSync)("ps -eo pid,command", { encoding: "utf-8", timeout: 5000 });
90
- for (const line of result.split("\n")) {
91
- // Only match daemon-owned entry points, NOT mcp-serve or other external processes
92
- if (line.includes("mcp-serve") || line.includes("mcp serve"))
93
- continue;
94
- if (!line.includes("agent-entry.js") && !line.includes("daemon-entry.js") && !line.includes("bluebubbles/entry.js") && !line.includes("teams-entry.js"))
95
- continue;
96
- const pid = parseInt(line.trim(), 10);
97
- if (!isNaN(pid) && pid !== process.pid)
98
- pidsToKill.push(pid);
99
- }
131
+ const result = (0, child_process_1.execSync)("ps -eo pid,ppid,command", { encoding: "utf-8", timeout: 5000 });
132
+ pidsToKill = parseOrphanPidsFromPs(result, process.pid);
100
133
  }
101
134
  catch { /* ps failed — best effort */ }
102
135
  }
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.IMAGE_EXTENSION_REGEX = void 0;
37
+ exports.isImagePath = isImagePath;
38
+ exports.unescapePath = unescapePath;
39
+ exports.tryReadImage = tryReadImage;
40
+ exports.formatImageRef = formatImageRef;
41
+ exports.replacePathsWithRefs = replacePathsWithRefs;
42
+ exports.processSubmitInput = processSubmitInput;
43
+ exports.resolveImageContent = resolveImageContent;
44
+ /**
45
+ * Image paste detection and resolution for the TUI.
46
+ *
47
+ * When a user drags an image file onto the terminal, macOS pastes the absolute
48
+ * path (often with backslash-escaped spaces). This module detects image paths
49
+ * in the input text, replaces them with `[Image #N]` references, reads the
50
+ * files, and produces OpenAI-compatible `image_url` content blocks.
51
+ */
52
+ const promises_1 = require("fs/promises");
53
+ const path = __importStar(require("path"));
54
+ const runtime_1 = require("../../nerves/runtime");
55
+ /** Matches image file extensions (case-insensitive) */
56
+ exports.IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp)$/i;
57
+ /** Check whether a string (possibly backslash-escaped) ends with an image extension */
58
+ function isImagePath(str) {
59
+ return exports.IMAGE_EXTENSION_REGEX.test(unescapePath(str));
60
+ }
61
+ /** Convert backslash-escaped spaces to plain spaces (macOS drag-drop format) */
62
+ function unescapePath(str) {
63
+ return str.replace(/\\ /g, " ");
64
+ }
65
+ /** Map file extension to MIME media type */
66
+ function extensionToMediaType(ext) {
67
+ switch (ext.toLowerCase()) {
68
+ case ".jpg":
69
+ case ".jpeg":
70
+ return "image/jpeg";
71
+ case ".png":
72
+ return "image/png";
73
+ case ".gif":
74
+ return "image/gif";
75
+ case ".webp":
76
+ return "image/webp";
77
+ default:
78
+ return "image/png";
79
+ }
80
+ }
81
+ /**
82
+ * Read an image file and return base64 + media type.
83
+ * Returns null on any error (file not found, permission denied, etc.)
84
+ */
85
+ async function tryReadImage(absolutePath) {
86
+ try {
87
+ const buffer = await (0, promises_1.readFile)(absolutePath);
88
+ const ext = path.extname(absolutePath);
89
+ return {
90
+ base64: Buffer.from(buffer).toString("base64"),
91
+ mediaType: extensionToMediaType(ext),
92
+ };
93
+ }
94
+ catch {
95
+ (0, runtime_1.emitNervesEvent)({
96
+ component: "senses",
97
+ event: "senses.image_read_error",
98
+ message: `Failed to read image: ${absolutePath}`,
99
+ meta: { path: absolutePath },
100
+ });
101
+ return null;
102
+ }
103
+ }
104
+ /** Format an image reference placeholder */
105
+ function formatImageRef(n) {
106
+ return `[Image #${n}]`;
107
+ }
108
+ /**
109
+ * Scan input for absolute path tokens ending with image extensions.
110
+ * Matches `/` followed by non-whitespace chars (allowing `\ ` for escaped spaces)
111
+ * ending with an image extension.
112
+ */
113
+ function replacePathsWithRefs(input) {
114
+ const images = new Map();
115
+ // Match absolute paths starting with / that end with an image extension.
116
+ // Allow backslash-escaped characters (like `\ ` for spaces in macOS drag-drop).
117
+ const pathRegex = /(\/(?:[^\s\\]|\\.)+\.(?:png|jpe?g|gif|webp))/gi;
118
+ let counter = 0;
119
+ const text = input.replace(pathRegex, (match) => {
120
+ counter++;
121
+ images.set(counter, unescapePath(match));
122
+ return formatImageRef(counter);
123
+ });
124
+ return { text, images };
125
+ }
126
+ /**
127
+ * Process submit input: detect image paths, replace with refs, return map.
128
+ * This is the entry point called by InputArea on submit.
129
+ */
130
+ function processSubmitInput(text) {
131
+ return replacePathsWithRefs(text);
132
+ }
133
+ /**
134
+ * Build OpenAI content parts from text + image map.
135
+ * For each image in the map, reads the file and creates an `image_url` content part.
136
+ * Images that fail to read are silently skipped.
137
+ * Returns `[{ type: "text", text }]` when no images resolve.
138
+ */
139
+ async function resolveImageContent(text, images) {
140
+ if (images.size === 0) {
141
+ return [{ type: "text", text }];
142
+ }
143
+ const parts = [];
144
+ // Read all images in parallel
145
+ const entries = Array.from(images.entries());
146
+ const results = await Promise.all(entries.map(async ([, absolutePath]) => tryReadImage(absolutePath)));
147
+ for (const result of results) {
148
+ if (result) {
149
+ parts.push({
150
+ type: "image_url",
151
+ image_url: { url: `data:${result.mediaType};base64,${result.base64}` },
152
+ });
153
+ }
154
+ }
155
+ // Always include the text part
156
+ parts.push({ type: "text", text });
157
+ // If no images resolved, return just text
158
+ if (parts.length === 1) {
159
+ return [{ type: "text", text }];
160
+ }
161
+ return parts;
162
+ }
@@ -17,6 +17,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
17
17
  const react_1 = require("react");
18
18
  const ink_1 = require("ink");
19
19
  const streaming_markdown_1 = require("./streaming-markdown");
20
+ const image_paste_1 = require("./image-paste");
20
21
  // ─── Ouroboros Brand Palette (ANSI RGB) ─────────────────────────────
21
22
  // From packages/outlook-ui/src/style.css and ouroboros.bot
22
23
  const OURO = {
@@ -49,7 +50,7 @@ function safeWidth() {
49
50
  function termWidth() {
50
51
  return process.stdout.columns || 80;
51
52
  }
52
- function Header({ agentName, model, contextPercent, cwd }) {
53
+ function Header({ agentName, model, contextPercent, cwd, resumeInfo }) {
53
54
  const showCtx = contextPercent > 0;
54
55
  const info = [agentName, model, cwd, showCtx ? `ctx ${contextPercent}%` : ""].filter(Boolean).join(" · ");
55
56
  // 3-segment snake: TAIL (fixed) + MIDDLE (stretches) + HEAD (fixed)
@@ -66,7 +67,7 @@ function Header({ agentName, model, contextPercent, cwd }) {
66
67
  const line1 = TAIL1 + " ".repeat(middleLen) + HEAD1;
67
68
  const line2 = TAIL2 + "_".repeat(middleLen) + HEAD2;
68
69
  const line3text = (info.length > middleLen ? info.slice(0, middleLen) : info) + "_".repeat(textPad);
69
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] })] }));
70
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line1 }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: line2 }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.scale, children: [TAIL3, (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.glow, children: line3text }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: HEAD3 })] }), resumeInfo ? ((0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.teal, children: [" resuming \u00b7 ", resumeInfo.messageCount, " messages \u00b7 last active ", resumeInfo.timeAgo] })) : null] }));
70
71
  }
71
72
  // ─── Message Rendering ──────────────────────────────────────────────
72
73
  // Flow control tools are invisible to the user — they are internal agent mechanics
@@ -85,19 +86,6 @@ function MessageBlock({ msg }) {
85
86
  return (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" });
86
87
  return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: visibleCalls.map((tc, i) => (0, jsx_runtime_1.jsx)(ToolResultLine, { tc: tc }, i)) }));
87
88
  }
88
- // ── Session history (dimmed recap of recent exchanges) ──
89
- if (msg.role === "history-summary") {
90
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) }));
91
- }
92
- if (msg.role === "history-user") {
93
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [(0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: ") " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, bold: true, children: msg.content })] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
94
- }
95
- if (msg.role === "history-assistant") {
96
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
97
- }
98
- if (msg.role === "history-end") {
99
- return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.separator, children: "─".repeat(termWidth()) }) }));
100
- }
101
89
  if (msg.role === "user") {
102
90
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginTop: 1, children: [msg.content ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, bold: true, children: msg.content }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) })] }));
103
91
  }
@@ -166,7 +154,7 @@ function QueuedMessages({ items }) {
166
154
  return ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: items.map((text, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Text, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "\u231B queued: " }), (0, jsx_runtime_1.jsxs)(ink_1.Text, { color: OURO.mist, children: ["\"", text, "\""] })] }, i))) }));
167
155
  }
168
156
  // ─── Input ──────────────────────────────────────────────────────────
169
- function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model }) {
157
+ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agentName, model, onImageMap }) {
170
158
  const [input, setInput] = (0, react_1.useState)("");
171
159
  const [cursorPos, setCursorPos] = (0, react_1.useState)(0); // cursor position within input
172
160
  const [tooltip, setTooltip] = (0, react_1.useState)("");
@@ -270,8 +258,13 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
270
258
  return;
271
259
  }
272
260
  const text = inputRef.current;
273
- if (text.trim())
274
- onSubmit(text);
261
+ if (text.trim()) {
262
+ const { text: processedText, images } = (0, image_paste_1.processSubmitInput)(text);
263
+ if (images.size > 0 && onImageMap) {
264
+ onImageMap(images);
265
+ }
266
+ onSubmit(images.size > 0 ? processedText : text);
267
+ }
275
268
  updateInput("");
276
269
  historyIdx.current = -1;
277
270
  return;
@@ -409,11 +402,11 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
409
402
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), isMultiline ? ((0, jsx_runtime_1.jsx)(ink_1.Box, { flexDirection: "column", children: inputLines.map((line, i) => ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: i === 0 ? ") " : "· " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: line }), i === inputLines.length - 1 ? (cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { children: " " })) : null] }, i))) })) : ((0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.teal, bold: true, children: ") " }), !input && queuedInputs.length > 0 ? ((0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, children: "Press up to edit queued messages" })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(0, cursorPos) }), cursorVisible ? (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.scale, children: "█" }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input[cursorPos] ?? " " }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.bone, children: input.slice(cursorPos + (cursorVisible ? 0 : 1)) })] }))] })), (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "─".repeat(termWidth()) }), (0, jsx_runtime_1.jsxs)(ink_1.Box, { children: [(0, jsx_runtime_1.jsxs)(ink_1.Text, { dimColor: true, children: [" ", agentName, model ? ` · ${model}` : "", " \u00B7 /help"] }), (0, jsx_runtime_1.jsx)(ink_1.Box, { flexGrow: 1 }), tooltip ? (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: tooltip }) : (0, jsx_runtime_1.jsx)(ink_1.Text, { dimColor: true, children: "opt+enter for newline" })] })] }));
410
403
  }
411
404
  // ─── Main TUI Component ─────────────────────────────────────────────
412
- function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, }) {
405
+ function OuroTui({ agentName, model, completedMessages, inputHistory, queuedInputs, live, elapsedSeconds, contextPercent, onSubmit, onCtrlC, onPopQueue, cwd, resumeInfo, onImageMap, }) {
413
406
  return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", children: [(0, jsx_runtime_1.jsx)(ink_1.Static, { items: [{ id: "__header__" }, ...completedMessages], children: (item, index) => {
414
407
  if (index === 0) {
415
- return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 2, children: [(0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(Header, { agentName: agentName, model: model, contextPercent: contextPercent, cwd: cwd }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, dimColor: true, children: " Ctrl-C twice to exit \u00b7 \u2191\u2193 history \u00b7 Esc clear \u00b7 opt+Enter newline" })] }, "header"));
408
+ return ((0, jsx_runtime_1.jsxs)(ink_1.Box, { flexDirection: "column", marginBottom: 2, children: [(0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(Header, { agentName: agentName, model: model, contextPercent: contextPercent, cwd: cwd, resumeInfo: resumeInfo }), (0, jsx_runtime_1.jsx)(ink_1.Text, { color: OURO.shadow, dimColor: true, children: " Ctrl-C twice to exit \u00b7 \u2191\u2193 history \u00b7 Esc clear \u00b7 opt+Enter newline" })] }, "header"));
416
409
  }
417
410
  return (0, jsx_runtime_1.jsx)(MessageBlock, { msg: item }, item.id);
418
- } }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, (0, jsx_runtime_1.jsx)(LiveArea, { live: live, elapsed: elapsedSeconds }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, queuedInputs.length > 0 ? (0, jsx_runtime_1.jsx)(QueuedMessages, { items: queuedInputs }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(InputArea, { onSubmit: onSubmit, onCtrlC: onCtrlC, history: inputHistory, queuedInputs: queuedInputs, onPopQueue: onPopQueue, agentName: agentName, model: model })] }));
411
+ } }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, (0, jsx_runtime_1.jsx)(LiveArea, { live: live, elapsed: elapsedSeconds }), (live.loading || live.streamingText || live.activeTool) ? (0, jsx_runtime_1.jsx)(ink_1.Box, { marginBottom: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }) : null, queuedInputs.length > 0 ? (0, jsx_runtime_1.jsx)(QueuedMessages, { items: queuedInputs }) : null, (0, jsx_runtime_1.jsx)(ink_1.Box, { marginTop: 1, children: (0, jsx_runtime_1.jsx)(ink_1.Text, { children: "" }) }), (0, jsx_runtime_1.jsx)(InputArea, { onSubmit: onSubmit, onCtrlC: onCtrlC, history: inputHistory, queuedInputs: queuedInputs, onPopQueue: onPopQueue, agentName: agentName, model: model, onImageMap: onImageMap })] }));
419
412
  }
@@ -173,17 +173,17 @@ class TuiStore {
173
173
  seedHistory(texts) {
174
174
  this._inputHistory.push(...texts);
175
175
  }
176
- /** Show session resume context: summary line + last N exchanges (dimmed), then separator */
177
- addSessionHistory(summary, exchanges) {
178
- const msgs = [
179
- { id: "history-summary", role: "history-summary", content: summary },
180
- ...exchanges.map((ex, i) => ({
181
- id: `history-${i}`,
182
- role: (ex.role === "user" ? "history-user" : "history-assistant"),
183
- content: ex.content,
184
- })),
185
- { id: "history-end", role: "history-end", content: "" },
186
- ];
176
+ /** Push previous session exchanges as normal user/assistant messages (for resume display) */
177
+ addResumeMessages(exchanges) {
178
+ if (exchanges.length === 0) {
179
+ this.notify();
180
+ return;
181
+ }
182
+ const msgs = exchanges.map((ex, i) => ({
183
+ id: `resume-${i}`,
184
+ role: ex.role,
185
+ content: ex.content,
186
+ }));
187
187
  this._completed = [...msgs, ...this._completed];
188
188
  this.notify();
189
189
  }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.InputQueue = exports.MarkdownStreamer = exports.InputController = exports.Spinner = exports.StreamingWordWrapper = exports.wrapCliText = exports.formatEchoedInputSummary = void 0;
37
37
  exports.formatPendingPrefix = formatPendingPrefix;
38
38
  exports.getCliContinuityIngressTexts = getCliContinuityIngressTexts;
39
+ exports.formatTimeAgo = formatTimeAgo;
39
40
  exports.writeCliAsyncAssistantMessage = writeCliAsyncAssistantMessage;
40
41
  exports.pauseActiveSpinner = pauseActiveSpinner;
41
42
  exports.resumeActiveSpinner = resumeActiveSpinner;
@@ -75,6 +76,7 @@ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
76
  const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
76
77
  const bundle_manifest_1 = require("../mind/bundle-manifest");
77
78
  const cli_layout_1 = require("./cli-layout");
79
+ const image_paste_1 = require("./cli/image-paste");
78
80
  const spinner_imperative_1 = require("./cli/spinner-imperative");
79
81
  const tool_display_1 = require("./cli/tool-display");
80
82
  var cli_layout_2 = require("./cli-layout");
@@ -513,6 +515,8 @@ async function runCliSession(options) {
513
515
  const useTui = !options._testInputSource && process.stdin.isTTY === true;
514
516
  let currentAbort = null;
515
517
  let closed = false;
518
+ // eslint-disable-next-line prefer-const -- set by onImageMap callback during input
519
+ let pendingImages = null;
516
520
  let cliCallbacks;
517
521
  let tuiStore = null;
518
522
  let inkRef = null;
@@ -535,8 +539,7 @@ async function runCliSession(options) {
535
539
  .filter((msg) => msg.role === "user" && typeof msg.content === "string")
536
540
  .map(msg => msg.content);
537
541
  tuiStore.seedHistory(prevUserMsgs);
538
- // Show session resume context: summary + last few exchanges (dimmed)
539
- /* v8 ignore start -- session resume display: integration-only, tested visually @preserve */
542
+ // Show session resume context: last 2 exchanges as normal messages
540
543
  if (messages.length > 1) {
541
544
  const userAssistantMsgs = messages.filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim().length > 0);
542
545
  // Extract last 2 exchanges (up to 4 messages)
@@ -544,12 +547,15 @@ async function runCliSession(options) {
544
547
  for (let i = userAssistantMsgs.length - 1; i >= 0 && lastExchanges.length < 4; i--) {
545
548
  lastExchanges.unshift({ role: userAssistantMsgs[i].role, content: userAssistantMsgs[i].content });
546
549
  }
547
- const msgCount = messages.filter(m => m.role === "user" || m.role === "assistant").length;
548
- const timeAgo = options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : null;
549
- const summary = ` resuming session · ${msgCount} messages${timeAgo ? ` · last active ${timeAgo}` : ""} · showing recent`;
550
- tuiStore.addSessionHistory(summary, lastExchanges);
550
+ tuiStore.addResumeMessages(lastExchanges);
551
551
  }
552
- /* v8 ignore stop */
552
+ // Compute resumeInfo for header banner
553
+ const resumeInfo = messages.length > 1
554
+ ? {
555
+ messageCount: messages.filter(m => m.role === "user" || m.role === "assistant").length,
556
+ timeAgo: options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : "unknown",
557
+ }
558
+ : undefined;
553
559
  // Ctrl-C state machine (Claude Code behavior):
554
560
  // During generation: abort current request
555
561
  // Idle with text: clear input
@@ -610,6 +616,8 @@ async function runCliSession(options) {
610
616
  onPopQueue: () => { const items = storeRef.popAllQueuedForEditing(); inputQueue.drainAll(); return items; },
611
617
  headerShown: storeRef.headerShown,
612
618
  cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
619
+ resumeInfo,
620
+ onImageMap: (images) => { pendingImages = images; },
613
621
  });
614
622
  }
615
623
  inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
@@ -811,12 +819,19 @@ async function runCliSession(options) {
811
819
  if (tuiStore)
812
820
  tuiStore.suppressInput();
813
821
  inputCtrl?.suppress(() => { currentAbort?.abort(); });
822
+ // Resolve pending image content before the turn executes
823
+ let contentParts = null;
824
+ const currentImages = pendingImages;
825
+ if (currentImages && currentImages.size > 0) {
826
+ contentParts = await (0, image_paste_1.resolveImageContent)(input, currentImages);
827
+ pendingImages = null;
828
+ }
814
829
  let result;
815
830
  try {
816
831
  if (options.runTurn) {
817
832
  // Pipeline-based turn: the runTurn callback handles user message assembly,
818
833
  // pending drain, trust gate, runAgent, postTurn, and token accumulation.
819
- result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
834
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext, contentParts ?? undefined);
820
835
  // Handle pipeline-intercepted commands with loop-control side effects
821
836
  if (result?.turnOutcome === "command") {
822
837
  if (result.commandAction === "exit") {
@@ -838,7 +853,10 @@ async function runCliSession(options) {
838
853
  else {
839
854
  // Legacy path: inline runAgent (used by serpent guide and tests)
840
855
  const prefix = options.getContentPrefix?.();
841
- messages.push({ role: "user", content: prefix ? `${prefix}\n\n${input}` : input });
856
+ const userContent = contentParts
857
+ ? contentParts
858
+ : (prefix ? `${prefix}\n\n${input}` : input);
859
+ messages.push({ role: "user", content: userContent });
842
860
  const traceId = (0, nerves_1.createTraceId)();
843
861
  result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
844
862
  toolChoiceRequired: getEffectiveToolChoiceRequired(),
@@ -973,7 +991,7 @@ async function main(agentName, options) {
973
991
  onAsyncAssistantMessage: async (messages, _assistantMessage) => {
974
992
  (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
975
993
  },
976
- runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
994
+ runTurn: async (messages, userInput, callbacks, signal, toolContext, userContent) => {
977
995
  // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
978
996
  // User message passed via input.messages so the pipeline can prepend pending messages to it.
979
997
  const failoverState = cliFailoverState;
@@ -997,7 +1015,7 @@ async function main(agentName, options) {
997
1015
  channel: "cli",
998
1016
  sessionKey: "session",
999
1017
  capabilities: cliCapabilities,
1000
- messages: [{ role: "user", content: userInput }],
1018
+ messages: [{ role: "user", content: userContent ?? userInput }],
1001
1019
  continuityIngressTexts: getCliContinuityIngressTexts(userInput),
1002
1020
  callbacks: failoverAwareCallbacks,
1003
1021
  friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.253",
3
+ "version": "0.1.0-alpha.255",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",