@ouro.bot/cli 0.1.0-alpha.257 → 0.1.0-alpha.259
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 +14 -0
- package/dist/heart/daemon/daemon-entry.js +18 -4
- package/dist/senses/cli/image-paste.js +66 -17
- package/dist/senses/cli/ouro-tui.js +34 -22
- package/package.json +1 -1
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.259",
|
|
6
|
+
"changes": [
|
|
7
|
+
"fix(daemon): SIGINT and SIGTERM now ALWAYS write a tombstone before exiting, instead of silently skipping when `_gracefulShutdown` was set. The previous behavior made signal-driven shutdowns invisible in `~/.ouro-cli/daemon-death.json` \u2014 launchd policy decisions, the OOM killer, manual `kill`, and `killOrphanProcesses` from a sibling daemon all looked identical to a clean exit. After a real outage where the user's daemon kept dying with a tombstone from a week earlier, this restores observability: every signal-driven exit now records `reason: \"sigint\"` or `reason: \"sigterm\"` with timestamp + recentCrashes accumulator. The catch-all `process.on('exit')` handler also no longer short-circuits on graceful shutdown."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.258",
|
|
12
|
+
"changes": [
|
|
13
|
+
"fix(tui): deferred ESC handling fixes arrow keys and Alt+Enter.",
|
|
14
|
+
"fix(tui): green cursor background.",
|
|
15
|
+
"fix(tui): clipboard fallback for macOS temp screenshots, proper path splitting and unescaping for image drag-drop."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
4
18
|
{
|
|
5
19
|
"version": "0.1.0-alpha.257",
|
|
6
20
|
"changes": [
|
|
@@ -195,7 +195,15 @@ void daemon.start().then(() => {
|
|
|
195
195
|
process.exit(1);
|
|
196
196
|
});
|
|
197
197
|
process.on("SIGINT", () => {
|
|
198
|
-
|
|
198
|
+
// ALWAYS write a tombstone, even on signal-driven shutdown. The previous
|
|
199
|
+
// behavior was to set _gracefulShutdown=true and skip the tombstone, which
|
|
200
|
+
// meant ANY external SIGINT/SIGTERM (launchd policy, OOM killer, manual
|
|
201
|
+
// kill, killOrphanProcesses from a sibling daemon) silently disappeared
|
|
202
|
+
// from the death log. The user lost weeks of visibility into why their
|
|
203
|
+
// daemon kept dying. Tombstones are informational — having a "sigint"
|
|
204
|
+
// tombstone is strictly better than silence.
|
|
205
|
+
_tombstoneWritten = true;
|
|
206
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("sigint", new Error("daemon received SIGINT"));
|
|
199
207
|
for (const s of habitSchedulers) {
|
|
200
208
|
s.stopWatching();
|
|
201
209
|
s.stop();
|
|
@@ -204,7 +212,8 @@ process.on("SIGINT", () => {
|
|
|
204
212
|
void daemon.stop().then(() => process.exit(0));
|
|
205
213
|
});
|
|
206
214
|
process.on("SIGTERM", () => {
|
|
207
|
-
|
|
215
|
+
_tombstoneWritten = true;
|
|
216
|
+
(0, daemon_tombstone_1.writeDaemonTombstone)("sigterm", new Error("daemon received SIGTERM"));
|
|
208
217
|
for (const s of habitSchedulers) {
|
|
209
218
|
s.stopWatching();
|
|
210
219
|
s.stop();
|
|
@@ -221,7 +230,6 @@ process.stderr?.on?.("error", () => { });
|
|
|
221
230
|
/* v8 ignore start -- global exception handlers: genuinely untestable in vitest; exercised by real daemon crashes @preserve */
|
|
222
231
|
let _uncaughtCount = 0;
|
|
223
232
|
let _tombstoneWritten = false;
|
|
224
|
-
let _gracefulShutdown = false;
|
|
225
233
|
let _lastKnownCause = null;
|
|
226
234
|
const CIRCUIT_BREAKER_WINDOW_MS = 60_000;
|
|
227
235
|
const CIRCUIT_BREAKER_MAX = 10;
|
|
@@ -270,8 +278,14 @@ process.on("unhandledRejection", (reason) => {
|
|
|
270
278
|
});
|
|
271
279
|
// Catch-all: write tombstone on any exit where we didn't already record the cause.
|
|
272
280
|
// process.on('exit') is synchronous-only — writeDaemonTombstone uses writeFileSync, so it works.
|
|
281
|
+
//
|
|
282
|
+
// Previously this skipped writing if `_gracefulShutdown` was true, which made
|
|
283
|
+
// SIGINT/SIGTERM-driven exits invisible in the death log. The signal handlers
|
|
284
|
+
// above now always write their own tombstone before exiting, so this catch-all
|
|
285
|
+
// only runs for exits the signal handlers didn't reach (e.g. process.exit
|
|
286
|
+
// called from somewhere unexpected).
|
|
273
287
|
process.on("exit", (code) => {
|
|
274
|
-
if (_tombstoneWritten
|
|
288
|
+
if (_tombstoneWritten)
|
|
275
289
|
return;
|
|
276
290
|
const reason = code === 0 ? "unexpectedCleanExit" : "unexpectedExit";
|
|
277
291
|
const error = _lastKnownCause ?? new Error(`daemon exited with code ${code} (no specific cause captured)`);
|
|
@@ -50,6 +50,7 @@ exports.resolveImageContent = resolveImageContent;
|
|
|
50
50
|
* files, and produces OpenAI-compatible `image_url` content blocks.
|
|
51
51
|
*/
|
|
52
52
|
const promises_1 = require("fs/promises");
|
|
53
|
+
const child_process_1 = require("child_process");
|
|
53
54
|
const path = __importStar(require("path"));
|
|
54
55
|
const runtime_1 = require("../../nerves/runtime");
|
|
55
56
|
/** Matches image file extensions (case-insensitive) */
|
|
@@ -58,10 +59,14 @@ exports.IMAGE_EXTENSION_REGEX = /\.(png|jpe?g|gif|webp)$/i;
|
|
|
58
59
|
function isImagePath(str) {
|
|
59
60
|
return exports.IMAGE_EXTENSION_REGEX.test(unescapePath(str));
|
|
60
61
|
}
|
|
61
|
-
/**
|
|
62
|
+
/** Strip shell escape backslashes from a path (macOS/Linux drag-drop format) */
|
|
62
63
|
function unescapePath(str) {
|
|
63
|
-
|
|
64
|
+
// Handle double-backslashes first (actual backslash in filename)
|
|
65
|
+
// then remove single escape backslashes
|
|
66
|
+
return str.replace(/\\(.)/g, "$1");
|
|
64
67
|
}
|
|
68
|
+
/** Detect temp screenshot paths from macOS screencapture */
|
|
69
|
+
const TEMP_SCREENSHOT_RE = /\/TemporaryItems\/.*screencaptureui.*\/Screenshot/i;
|
|
65
70
|
/** Map file extension to MIME media type */
|
|
66
71
|
function extensionToMediaType(ext) {
|
|
67
72
|
switch (ext.toLowerCase()) {
|
|
@@ -80,41 +85,85 @@ function extensionToMediaType(ext) {
|
|
|
80
85
|
}
|
|
81
86
|
/**
|
|
82
87
|
* Read an image file and return base64 + media type.
|
|
83
|
-
*
|
|
88
|
+
* Falls back to clipboard for temp screenshot paths (macOS screencapture
|
|
89
|
+
* creates ephemeral files in TemporaryItems that are cleaned up quickly).
|
|
90
|
+
* Returns null on any error.
|
|
84
91
|
*/
|
|
85
92
|
async function tryReadImage(absolutePath) {
|
|
93
|
+
// Try reading the file directly first
|
|
86
94
|
try {
|
|
87
95
|
const buffer = await (0, promises_1.readFile)(absolutePath);
|
|
88
|
-
|
|
96
|
+
if (buffer.length > 0) {
|
|
97
|
+
const ext = path.extname(absolutePath);
|
|
98
|
+
return {
|
|
99
|
+
base64: Buffer.from(buffer).toString("base64"),
|
|
100
|
+
mediaType: extensionToMediaType(ext),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// File not found or permission denied — try clipboard fallback
|
|
106
|
+
}
|
|
107
|
+
// Clipboard fallback for macOS temp screenshots
|
|
108
|
+
/* v8 ignore start -- clipboard fallback requires real macOS screencapture @preserve */
|
|
109
|
+
if (process.platform === "darwin" && TEMP_SCREENSHOT_RE.test(absolutePath)) {
|
|
110
|
+
const clipboardImage = await getImageFromClipboard();
|
|
111
|
+
if (clipboardImage)
|
|
112
|
+
return clipboardImage;
|
|
113
|
+
}
|
|
114
|
+
/* v8 ignore stop */
|
|
115
|
+
(0, runtime_1.emitNervesEvent)({
|
|
116
|
+
component: "senses",
|
|
117
|
+
event: "senses.image_read_error",
|
|
118
|
+
message: `Failed to read image: ${absolutePath}`,
|
|
119
|
+
meta: { path: absolutePath },
|
|
120
|
+
});
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read image data from macOS clipboard via osascript.
|
|
125
|
+
* Used as fallback when temp screenshot files are already cleaned up.
|
|
126
|
+
*/
|
|
127
|
+
/* v8 ignore start -- macOS clipboard integration @preserve */
|
|
128
|
+
async function getImageFromClipboard() {
|
|
129
|
+
const tmpPath = "/tmp/ouro_clipboard_image.png";
|
|
130
|
+
try {
|
|
131
|
+
// Check if clipboard has image data
|
|
132
|
+
(0, child_process_1.execFileSync)("osascript", ["-e", "the clipboard as «class PNGf»"], { stdio: "pipe" });
|
|
133
|
+
// Save clipboard image to temp file
|
|
134
|
+
(0, child_process_1.execFileSync)("osascript", [
|
|
135
|
+
"-e", "set png_data to (the clipboard as «class PNGf»)",
|
|
136
|
+
"-e", `set fp to open for access POSIX file "${tmpPath}" with write permission`,
|
|
137
|
+
"-e", "write png_data to fp",
|
|
138
|
+
"-e", "close access fp",
|
|
139
|
+
], { stdio: "pipe" });
|
|
140
|
+
const buffer = await (0, promises_1.readFile)(tmpPath);
|
|
141
|
+
void (0, promises_1.unlink)(tmpPath).catch(() => { });
|
|
142
|
+
if (buffer.length === 0)
|
|
143
|
+
return null;
|
|
89
144
|
return {
|
|
90
145
|
base64: Buffer.from(buffer).toString("base64"),
|
|
91
|
-
mediaType:
|
|
146
|
+
mediaType: "image/png",
|
|
92
147
|
};
|
|
93
148
|
}
|
|
94
149
|
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
150
|
return null;
|
|
102
151
|
}
|
|
103
152
|
}
|
|
153
|
+
/* v8 ignore stop */
|
|
104
154
|
/** Format an image reference placeholder */
|
|
105
155
|
function formatImageRef(n) {
|
|
106
156
|
return `[Image #${n}]`;
|
|
107
157
|
}
|
|
108
158
|
/**
|
|
109
|
-
* Scan input for absolute
|
|
110
|
-
*
|
|
111
|
-
* ending with an image extension.
|
|
159
|
+
* Scan input for absolute image file paths and replace with [Image #N] references.
|
|
160
|
+
* Handles backslash-escaped characters (macOS drag-drop) and paths with spaces.
|
|
112
161
|
*/
|
|
113
162
|
function replacePathsWithRefs(input) {
|
|
114
163
|
const images = new Map();
|
|
115
|
-
// Match absolute paths
|
|
116
|
-
//
|
|
117
|
-
const pathRegex = /(
|
|
164
|
+
// Match absolute paths: start with /, contain non-whitespace or backslash-escaped chars,
|
|
165
|
+
// end with an image extension. The (?:\\.|\S) alternation allows `\ ` sequences.
|
|
166
|
+
const pathRegex = /(?:\/(?:\\.|[^\s])+\.(?:png|jpe?g|gif|webp))/gi;
|
|
118
167
|
let counter = 0;
|
|
119
168
|
const text = input.replace(pathRegex, (match) => {
|
|
120
169
|
counter++;
|
|
@@ -163,9 +163,10 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
|
|
|
163
163
|
const cursorRef = (0, react_1.useRef)(0);
|
|
164
164
|
const historyIdx = (0, react_1.useRef)(-1);
|
|
165
165
|
const savedInput = (0, react_1.useRef)("");
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
166
|
+
// Deferred ESC handling: Ink 3.2 fires escape events for ESC prefix of arrow
|
|
167
|
+
// keys (\x1b[D) and Alt+Enter (\x1b\r). We defer ESC actions by 80ms — if
|
|
168
|
+
// another key arrives in that window, it was an escape sequence, not standalone ESC.
|
|
169
|
+
const escTimerRef = (0, react_1.useRef)(null);
|
|
169
170
|
const lastEscTime = (0, react_1.useRef)(0);
|
|
170
171
|
const tooltipTimerRef = (0, react_1.useRef)(null);
|
|
171
172
|
(0, react_1.useEffect)(() => () => {
|
|
@@ -211,25 +212,36 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
|
|
|
211
212
|
setTooltip("");
|
|
212
213
|
if (key.escape) {
|
|
213
214
|
lastEscTime.current = Date.now();
|
|
214
|
-
if
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
215
|
+
// Defer ESC action — if another key arrives within 80ms, this was an escape
|
|
216
|
+
// sequence prefix (arrow key, Alt+Enter), not a standalone Escape press.
|
|
217
|
+
if (escTimerRef.current)
|
|
218
|
+
clearTimeout(escTimerRef.current);
|
|
219
|
+
escTimerRef.current = setTimeout(() => {
|
|
220
|
+
escTimerRef.current = null;
|
|
221
|
+
if (inputRef.current) {
|
|
222
|
+
updateInput("");
|
|
223
|
+
historyIdx.current = -1;
|
|
224
|
+
setTooltip("Esc again to clear");
|
|
225
|
+
if (tooltipTimerRef.current)
|
|
226
|
+
clearTimeout(tooltipTimerRef.current);
|
|
227
|
+
tooltipTimerRef.current = setTimeout(() => setTooltip(""), 2000);
|
|
228
|
+
}
|
|
229
|
+
else if (queuedInputs.length > 0) {
|
|
230
|
+
const items = onPopQueue();
|
|
231
|
+
updateInput(items.join("\n"));
|
|
232
|
+
historyIdx.current = -1;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
setTooltip("");
|
|
236
|
+
}
|
|
237
|
+
}, 80);
|
|
231
238
|
return;
|
|
232
239
|
}
|
|
240
|
+
// Cancel pending ESC action — this key is part of an escape sequence
|
|
241
|
+
if (escTimerRef.current) {
|
|
242
|
+
clearTimeout(escTimerRef.current);
|
|
243
|
+
escTimerRef.current = null;
|
|
244
|
+
}
|
|
233
245
|
if (key.return) {
|
|
234
246
|
// Alt+Enter: detect via key.meta OR recent ESC (within 50ms — Ink splits \x1b\r)
|
|
235
247
|
const recentEsc = (Date.now() - lastEscTime.current) < 50;
|
|
@@ -389,8 +401,8 @@ function InputArea({ onSubmit, onCtrlC, history, queuedInputs, onPopQueue, agent
|
|
|
389
401
|
});
|
|
390
402
|
const isMultiline = input.includes("\n");
|
|
391
403
|
const inputLines = input.split("\n");
|
|
392
|
-
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, {
|
|
393
|
-
? (0, jsx_runtime_1.jsx)(ink_1.Text, {
|
|
404
|
+
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, { backgroundColor: OURO.scale, color: "#000000", 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
|
|
405
|
+
? (0, jsx_runtime_1.jsx)(ink_1.Text, { backgroundColor: OURO.scale, color: "#000000", children: input[cursorPos] ?? " " })
|
|
394
406
|
: (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 + 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" })] })] }));
|
|
395
407
|
}
|
|
396
408
|
// ─── Main TUI Component ─────────────────────────────────────────────
|