@scira/cli 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/watch.js +196 -0
- package/dist/cli/index.js +60 -35
- package/dist/tools/background-tasks.js +1 -12
- package/dist/types/index.js +30 -0
- package/dist/ui/ink/SciraApp.js +0 -16
- package/dist/ui/ink/components/effects.js +0 -8
- package/dist/ui/ink/components/home-screen.js +0 -2
- package/dist/ui/ink/components/overlays.js +3 -10
- package/dist/ui/ink/constants.js +0 -3
- package/dist/ui/ink/hooks/use-agent-turn.js +3 -8
- package/dist/ui/ink/hooks/use-feed-lines.js +2 -19
- package/dist/ui/ink/hooks/use-feed.js +0 -1
- package/dist/ui/ink/hooks/use-keyboard.js +0 -5
- package/dist/ui/ink/hooks/use-session.js +0 -2
- package/dist/ui/ink/hooks/use-suggestions.js +0 -1
- package/dist/ui/ink/lib/tool-result.js +2 -36
- package/dist/ui/ink/lib/utils.js +4 -35
- package/dist/ui/ink/session-manager.js +0 -8
- package/dist/ui/ink/theme.js +1 -12
- package/dist/utils/desktop-notify.js +26 -0
- package/dist/utils/markdown-joiner.js +0 -30
- package/dist/utils/process.js +12 -0
- package/dist/utils/time.js +21 -0
- package/dist/utils/watch-notice.js +40 -0
- package/dist/watch/daemon-lock.js +13 -0
- package/dist/watch/daemon.js +59 -0
- package/dist/watch/executor.js +43 -0
- package/dist/watch/format.js +9 -0
- package/dist/watch/runner.js +44 -27
- package/dist/watch/scheduler.js +58 -0
- package/dist/watch/store.js +110 -0
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { loadConfig } from "../../config/load-config.js";
|
|
6
|
+
import { requireLlmKeys } from "../../providers/llm/registry.js";
|
|
7
|
+
import { watchLoop } from "../../watch/runner.js";
|
|
8
|
+
import { loadWatches, addWatch, removeWatch, findWatch, nextWatchId } from "../../watch/store.js";
|
|
9
|
+
import { isDue, nextRunAt } from "../../watch/scheduler.js";
|
|
10
|
+
import { DAEMON_LOG_FILE, DAEMON_PID_FILE, isProcessRunning, readLock } from "../../watch/daemon-lock.js";
|
|
11
|
+
const FREQUENCIES = ["day", "weekday", "week"];
|
|
12
|
+
const INTERVALS = { hourly: 60 * 60 * 1000, daily: 24 * 60 * 60 * 1000, weekly: 7 * 24 * 60 * 60 * 1000 };
|
|
13
|
+
/** Entry point for `scira watch <goal>`: foreground loop, or persisted schedule with -b. */
|
|
14
|
+
export async function watchCommand(goal, opts) {
|
|
15
|
+
if (opts.background || opts.b) {
|
|
16
|
+
await watchBackground(goal, opts);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
await watchForeground(goal, opts);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// --- Foreground: interval loop tied to this terminal ---------------------
|
|
23
|
+
async function watchForeground(goal, opts) {
|
|
24
|
+
const config = await loadConfig();
|
|
25
|
+
requireLlmKeys(config);
|
|
26
|
+
const intervalMs = opts.interval
|
|
27
|
+
? Number.parseInt(String(opts.interval), 10)
|
|
28
|
+
: opts.hourly ? INTERVALS.hourly
|
|
29
|
+
: opts.weekly ? INTERVALS.weekly
|
|
30
|
+
: INTERVALS.daily;
|
|
31
|
+
if (Number.isNaN(intervalMs) || intervalMs < 1000) {
|
|
32
|
+
throw new Error("Interval must be at least 1000 ms.");
|
|
33
|
+
}
|
|
34
|
+
const maxRuns = opts.runs != null ? Number.parseInt(String(opts.runs), 10) : undefined;
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
process.on("SIGINT", () => { console.log("\nStopping watch…"); controller.abort(); });
|
|
37
|
+
process.on("SIGTERM", () => controller.abort());
|
|
38
|
+
console.log(`Watching: "${goal}"`);
|
|
39
|
+
console.log(`Interval: ${intervalMs / 1000}s${maxRuns ? ` · max ${maxRuns} runs` : ""}`);
|
|
40
|
+
console.log("Press Ctrl-C to stop. (Tip: -b runs this in the background instead.)\n");
|
|
41
|
+
await watchLoop({
|
|
42
|
+
goal, intervalMs, maxRuns, config,
|
|
43
|
+
onRunStart: (runPath, tick) => { console.log(`\n[run ${tick + 1}] Starting → ${runPath}`); },
|
|
44
|
+
onRunComplete: (_runPath, diff, tick) => { console.log(`[run ${tick + 1}] Done.\n${diff.text}`); },
|
|
45
|
+
onError: (err, tick) => { console.error(`[run ${tick + 1}] Error: ${err.message}`); }
|
|
46
|
+
}, controller.signal);
|
|
47
|
+
console.log("Watch finished.");
|
|
48
|
+
}
|
|
49
|
+
// --- Background: persist a scheduled watch, run by the daemon -------------
|
|
50
|
+
async function watchBackground(goal, opts) {
|
|
51
|
+
const at = opts.at ?? "09:00";
|
|
52
|
+
const every = opts.every ?? "day";
|
|
53
|
+
// Reject impossible times: a bare \d{2}:\d{2} would accept 25:61, which
|
|
54
|
+
// Date.setHours silently rolls over into the wrong day.
|
|
55
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/u.test(at)) {
|
|
56
|
+
throw new Error(`--at must be a valid HH:MM 24h time, 00:00–23:59 (got: ${at})`);
|
|
57
|
+
}
|
|
58
|
+
if (!FREQUENCIES.includes(every)) {
|
|
59
|
+
throw new Error(`--every must be one of: ${FREQUENCIES.join(", ")} (got: ${every})`);
|
|
60
|
+
}
|
|
61
|
+
const config = await loadConfig();
|
|
62
|
+
requireLlmKeys(config); // fail now, not on the first silent background run
|
|
63
|
+
const projectRoot = process.cwd();
|
|
64
|
+
const existing = await loadWatches();
|
|
65
|
+
// Don't create duplicates: an identical goal+schedule+project already covers it.
|
|
66
|
+
const duplicate = existing.find((w) => w.goal === goal && w.projectRoot === projectRoot && w.frequency === every && w.atHHMM === at);
|
|
67
|
+
if (duplicate) {
|
|
68
|
+
console.log(`Already scheduled as "${duplicate.name}" (${duplicate.id}) — runs ${every} at ${at}.`);
|
|
69
|
+
await printSchedulerHint();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const id = nextWatchId(existing);
|
|
73
|
+
const name = opts.name ?? id;
|
|
74
|
+
const watch = {
|
|
75
|
+
id,
|
|
76
|
+
name,
|
|
77
|
+
goal,
|
|
78
|
+
projectRoot,
|
|
79
|
+
frequency: every,
|
|
80
|
+
atHHMM: at,
|
|
81
|
+
createdAt: new Date().toISOString(),
|
|
82
|
+
lastRunAt: null,
|
|
83
|
+
enabled: true
|
|
84
|
+
};
|
|
85
|
+
await addWatch(watch);
|
|
86
|
+
console.log(`Scheduled watch "${name}" (${id}) — runs ${every} at ${at}`);
|
|
87
|
+
console.log(`Goal: ${goal}`);
|
|
88
|
+
console.log("");
|
|
89
|
+
await printSchedulerHint();
|
|
90
|
+
}
|
|
91
|
+
/** Tell the user whether the scheduler is up (without starting it — that's an explicit action). */
|
|
92
|
+
async function printSchedulerHint() {
|
|
93
|
+
const lock = await readLock();
|
|
94
|
+
if (lock && isProcessRunning(lock.pid)) {
|
|
95
|
+
console.log(`Scheduler is running (pid ${lock.pid}); it will pick this up on the next tick.`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.log(`Start the scheduler to run it: scira watch start`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// --- Management ----------------------------------------------------------
|
|
102
|
+
export async function watchList() {
|
|
103
|
+
const watches = await loadWatches();
|
|
104
|
+
if (watches.length === 0) {
|
|
105
|
+
console.log("No scheduled watches. Create one with: scira watch <goal> --background");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const now = new Date();
|
|
109
|
+
for (const w of watches) {
|
|
110
|
+
const when = isDue(w, now) ? "due now" : `next: ${nextRunAt(w, now).toLocaleString()}`;
|
|
111
|
+
const status = w.enabled ? "enabled" : "disabled";
|
|
112
|
+
console.log(`${w.id} "${w.name}" ${w.frequency}@${w.atHHMM} [${status}] ${when}`);
|
|
113
|
+
console.log(` goal: ${w.goal.slice(0, 80)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
export async function watchRemove(idOrName) {
|
|
117
|
+
const w = await findWatch(idOrName);
|
|
118
|
+
if (!w)
|
|
119
|
+
throw new Error(`Watch not found: ${idOrName}`);
|
|
120
|
+
await removeWatch(w.id);
|
|
121
|
+
console.log(`Removed watch "${w.name}" (${w.id})`);
|
|
122
|
+
}
|
|
123
|
+
// --- Daemon control ------------------------------------------------------
|
|
124
|
+
export async function daemonStart() {
|
|
125
|
+
const lock = await readLock();
|
|
126
|
+
if (lock && isProcessRunning(lock.pid)) {
|
|
127
|
+
console.log(`Scheduler already running (pid ${lock.pid})`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Resolve the daemon script next to this command's compiled output. tsc emits
|
|
131
|
+
// per-file, so dist/cli/commands/watch.js → dist/watch/daemon.js; in dev
|
|
132
|
+
// (bun src/...) the .ts mirror resolves the same way.
|
|
133
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
134
|
+
const ext = thisFile.endsWith(".ts") ? ".ts" : ".js";
|
|
135
|
+
const daemonScript = join(dirname(thisFile), "..", "..", "watch", `daemon${ext}`);
|
|
136
|
+
const bunExe = Bun.which("bun") ?? "bun";
|
|
137
|
+
const child = spawn(bunExe, [daemonScript], { detached: true, stdio: "ignore" });
|
|
138
|
+
child.unref();
|
|
139
|
+
// Give the daemon a beat to claim its PID lock before we report status.
|
|
140
|
+
await Bun.sleep(600);
|
|
141
|
+
const fresh = await readLock();
|
|
142
|
+
if (fresh && isProcessRunning(fresh.pid)) {
|
|
143
|
+
console.log(`Scheduler started (pid ${fresh.pid})`);
|
|
144
|
+
console.log(`Log: ${DAEMON_LOG_FILE}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
console.log(`Scheduler may have failed to start. Check: ${DAEMON_LOG_FILE}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function daemonStop() {
|
|
151
|
+
const lock = await readLock();
|
|
152
|
+
if (!lock) {
|
|
153
|
+
console.log("Scheduler not running.");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!isProcessRunning(lock.pid)) {
|
|
157
|
+
await rm(DAEMON_PID_FILE, { force: true });
|
|
158
|
+
console.log("Scheduler not running (cleared stale pid file).");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
process.kill(lock.pid, "SIGTERM");
|
|
162
|
+
console.log(`Sent SIGTERM to scheduler (pid ${lock.pid})`);
|
|
163
|
+
}
|
|
164
|
+
export async function daemonStatus() {
|
|
165
|
+
const lock = await readLock();
|
|
166
|
+
if (!lock || !isProcessRunning(lock.pid)) {
|
|
167
|
+
console.log("Scheduler: not running");
|
|
168
|
+
console.log("Start with: scira watch start");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const upSecs = Math.floor((Date.now() - new Date(lock.startedAt).getTime()) / 1000);
|
|
172
|
+
console.log(`Scheduler: running (pid ${lock.pid}, uptime ${upSecs}s)`);
|
|
173
|
+
console.log(`Log: ${DAEMON_LOG_FILE}`);
|
|
174
|
+
try {
|
|
175
|
+
const tail = (await readFile(DAEMON_LOG_FILE, "utf8")).trim().split("\n").filter(Boolean).slice(-10);
|
|
176
|
+
if (tail.length > 0) {
|
|
177
|
+
console.log("\nLast log lines:");
|
|
178
|
+
for (const line of tail)
|
|
179
|
+
console.log(` ${line}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
/* no log yet */
|
|
184
|
+
}
|
|
185
|
+
const enabled = (await loadWatches()).filter((w) => w.enabled);
|
|
186
|
+
if (enabled.length === 0) {
|
|
187
|
+
console.log("\nNo scheduled watches. Use: scira watch <goal> --background");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
console.log("\nScheduled watches:");
|
|
191
|
+
const now = new Date();
|
|
192
|
+
for (const w of enabled) {
|
|
193
|
+
const when = isDue(w, now) ? "due now" : nextRunAt(w, now).toLocaleString();
|
|
194
|
+
console.log(` ${w.name} ${w.frequency}@${w.atHHMM} next: ${when}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { saveGlobalMcpConfig } from "../config/load-config.js";
|
|
|
30
30
|
import { runOAuthFlow } from "../tools/mcp-oauth.js";
|
|
31
31
|
import { initCommand } from "./commands/init.js";
|
|
32
32
|
import { checkForUpdate, formatUpdateNotice } from "../utils/update-check.js";
|
|
33
|
+
import { checkWatchNotices, commitWatchNotices, formatWatchNotice } from "../utils/watch-notice.js";
|
|
33
34
|
// Once per invocation (throttled to a real npm check at most daily): surface an
|
|
34
35
|
// available update. The TUI shows it as an in-app notice; CLI commands print it.
|
|
35
36
|
// Skip for --version/--help so those stay instant (the daily check can spend up
|
|
@@ -38,6 +39,12 @@ const argv = process.argv.slice(2);
|
|
|
38
39
|
const wantsUpdateCheck = !argv.some((a) => ["-v", "--version", "-h", "--help"].includes(a));
|
|
39
40
|
const update = wantsUpdateCheck ? await checkForUpdate(pkgVersion) : null;
|
|
40
41
|
const updateNotice = update ? formatUpdateNotice(update) : undefined;
|
|
42
|
+
// Unread results from background (--background) watches, surfaced as a banner
|
|
43
|
+
// after the command finishes (mirrors the update-notice pattern above). We only
|
|
44
|
+
// advance the read cursor once the banner has actually been printed (in the
|
|
45
|
+
// finally block), so a crash mid-command can't silently mark results read.
|
|
46
|
+
const watch = wantsUpdateCheck ? await checkWatchNotices() : { results: [], nextOffset: 0 };
|
|
47
|
+
const watchNotice = watch.results.length > 0 ? formatWatchNotice(watch.results) : undefined;
|
|
41
48
|
// The TUI renders the notice in-app, so the finally banner would double it up.
|
|
42
49
|
let noticeShownInApp = false;
|
|
43
50
|
const prog = sade("scira");
|
|
@@ -284,43 +291,57 @@ prog
|
|
|
284
291
|
await saveGlobalMcpConfig(nextMcp);
|
|
285
292
|
console.log(`Removed MCP server "${name}" from ~/.scira/config.json`);
|
|
286
293
|
});
|
|
294
|
+
// `watch` has two modes from one command:
|
|
295
|
+
// foreground (default) — interval loop tied to this terminal, prints diffs.
|
|
296
|
+
// --background — persist the watch; the daemon re-runs it on a
|
|
297
|
+
// day/weekday/week schedule at --at, even after the
|
|
298
|
+
// terminal closes, with a desktop notification and a
|
|
299
|
+
// banner on the next CLI invocation.
|
|
300
|
+
// The management subcommands (list/remove/start/stop/status) drive the daemon.
|
|
287
301
|
prog
|
|
288
|
-
.command("watch <goal>", "
|
|
289
|
-
.option("--
|
|
290
|
-
.option("--
|
|
291
|
-
.option("--weekly", "run once per week")
|
|
292
|
-
.option("--interval", "custom interval in milliseconds")
|
|
293
|
-
.option("--runs", "stop after N runs (default: run forever)")
|
|
302
|
+
.command("watch <goal>", "re-run research on a schedule and diff each report (add -b to run in the background)")
|
|
303
|
+
.option("--hourly", "foreground: run once per hour")
|
|
304
|
+
.option("--daily", "foreground: run once per day (default)")
|
|
305
|
+
.option("--weekly", "foreground: run once per week")
|
|
306
|
+
.option("--interval", "foreground: custom interval in milliseconds")
|
|
307
|
+
.option("--runs", "foreground: stop after N runs (default: run forever)")
|
|
308
|
+
.option("-b, --background", "persist as a scheduled watch run by the daemon")
|
|
309
|
+
.option("--every", "background: frequency — day | weekday | week", "day")
|
|
310
|
+
.option("--at", "background: time of day to run, HH:MM 24h", "09:00")
|
|
311
|
+
.option("--name", "background: human-readable label")
|
|
294
312
|
.action(async (goal, opts) => {
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
313
|
+
const { watchCommand } = await import("./commands/watch.js");
|
|
314
|
+
await watchCommand(goal, opts);
|
|
315
|
+
});
|
|
316
|
+
prog
|
|
317
|
+
.command("watch list", "list scheduled (background) watches and their next run times")
|
|
318
|
+
.action(async () => {
|
|
319
|
+
const { watchList } = await import("./commands/watch.js");
|
|
320
|
+
await watchList();
|
|
321
|
+
});
|
|
322
|
+
prog
|
|
323
|
+
.command("watch remove <id>", "remove a scheduled watch by id or name")
|
|
324
|
+
.action(async (id) => {
|
|
325
|
+
const { watchRemove } = await import("./commands/watch.js");
|
|
326
|
+
await watchRemove(id);
|
|
327
|
+
});
|
|
328
|
+
prog
|
|
329
|
+
.command("watch start", "start the background watch scheduler")
|
|
330
|
+
.action(async () => {
|
|
331
|
+
const { daemonStart } = await import("./commands/watch.js");
|
|
332
|
+
await daemonStart();
|
|
333
|
+
});
|
|
334
|
+
prog
|
|
335
|
+
.command("watch stop", "stop the background watch scheduler")
|
|
336
|
+
.action(async () => {
|
|
337
|
+
const { daemonStop } = await import("./commands/watch.js");
|
|
338
|
+
await daemonStop();
|
|
339
|
+
});
|
|
340
|
+
prog
|
|
341
|
+
.command("watch status", "show scheduler status and upcoming scheduled watches")
|
|
342
|
+
.action(async () => {
|
|
343
|
+
const { daemonStatus } = await import("./commands/watch.js");
|
|
344
|
+
await daemonStatus();
|
|
324
345
|
});
|
|
325
346
|
prog
|
|
326
347
|
.command("models", "list models for the configured LLM provider")
|
|
@@ -512,4 +533,8 @@ finally {
|
|
|
512
533
|
// rendered the notice in-app, so we don't show it twice.
|
|
513
534
|
if (updateNotice && !noticeShownInApp)
|
|
514
535
|
process.stderr.write(`\n\x1b[2m${updateNotice}\x1b[0m\n`);
|
|
536
|
+
if (watchNotice) {
|
|
537
|
+
process.stderr.write(`\n\x1b[36m${watchNotice}\x1b[0m\n`);
|
|
538
|
+
await commitWatchNotices(watch.nextOffset);
|
|
539
|
+
}
|
|
515
540
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { isProcessRunning } from "../utils/process.js";
|
|
4
5
|
const MAX_OUTPUT_LINES = 500;
|
|
5
6
|
const MAX_TAIL_CHARS = 4000;
|
|
6
7
|
function nextTaskId(existing) {
|
|
@@ -17,18 +18,6 @@ function tailText(lines, maxChars = MAX_TAIL_CHARS) {
|
|
|
17
18
|
return joined;
|
|
18
19
|
return `…[truncated]\n${joined.slice(-maxChars)}`;
|
|
19
20
|
}
|
|
20
|
-
/** Returns true if a process with this pid is still running. */
|
|
21
|
-
function isProcessRunning(pid) {
|
|
22
|
-
if (pid <= 0)
|
|
23
|
-
return false;
|
|
24
|
-
try {
|
|
25
|
-
process.kill(pid, 0);
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return false;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
21
|
function isValidRecord(t) {
|
|
33
22
|
if (typeof t !== "object" || t === null)
|
|
34
23
|
return false;
|
package/dist/types/index.js
CHANGED
|
@@ -104,3 +104,33 @@ export const ClaimSchema = z.object({
|
|
|
104
104
|
reason: z.string().default(""),
|
|
105
105
|
createdAt: z.string()
|
|
106
106
|
});
|
|
107
|
+
// A scheduled (background) watch: a research goal the daemon re-runs on a
|
|
108
|
+
// schedule. `frequency` selects which days fire — weekly fires on the weekday
|
|
109
|
+
// the watch was created (derived from createdAt) — and `atHHMM` is the
|
|
110
|
+
// local-time trigger. Foreground watches are ephemeral and never persisted here.
|
|
111
|
+
export const SavedWatchSchema = z.object({
|
|
112
|
+
id: z.string(),
|
|
113
|
+
name: z.string(),
|
|
114
|
+
goal: z.string(),
|
|
115
|
+
projectRoot: z.string(), // run context — config + prior reports resolve from here
|
|
116
|
+
frequency: z.enum(["day", "weekday", "week"]),
|
|
117
|
+
atHHMM: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/u),
|
|
118
|
+
createdAt: z.string(),
|
|
119
|
+
lastRunAt: z.string().nullable().default(null),
|
|
120
|
+
enabled: z.boolean().default(true)
|
|
121
|
+
});
|
|
122
|
+
// One execution record per background run, appended to results.jsonl. We store
|
|
123
|
+
// the diff as section counts (not the full text) — the full report lives at
|
|
124
|
+
// runPath — so the unread-results banner needs no string re-parsing.
|
|
125
|
+
export const WatchResultSchema = z.object({
|
|
126
|
+
id: z.string(),
|
|
127
|
+
watchId: z.string(),
|
|
128
|
+
watchName: z.string(),
|
|
129
|
+
runPath: z.string(),
|
|
130
|
+
goal: z.string(),
|
|
131
|
+
added: z.number().int().default(0),
|
|
132
|
+
removed: z.number().int().default(0),
|
|
133
|
+
ranAt: z.string(),
|
|
134
|
+
status: z.enum(["ok", "error"]),
|
|
135
|
+
errorMessage: z.string().optional()
|
|
136
|
+
});
|
package/dist/ui/ink/SciraApp.js
CHANGED
|
@@ -56,7 +56,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
|
|
|
56
56
|
const planModeRef = useRef(false);
|
|
57
57
|
const [planMode, setPlanModeState] = useState(false);
|
|
58
58
|
const setPlanMode = useCallback((active) => { planModeRef.current = active; setPlanModeState(active); }, []);
|
|
59
|
-
// Plan-mode preference armed from the home screen, applied when the next run opens.
|
|
60
59
|
const pendingPlanModeRef = useRef(false);
|
|
61
60
|
const [pendingPlanMode, setPendingPlanModeState] = useState(false);
|
|
62
61
|
const setPendingPlanMode = useCallback((active) => { pendingPlanModeRef.current = active; setPendingPlanModeState(active); }, []);
|
|
@@ -136,7 +135,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
|
|
|
136
135
|
setPendingCollapse(new Set());
|
|
137
136
|
return new Set();
|
|
138
137
|
}
|
|
139
|
-
// Mark done groups for pending collapse, but don't collapse active ones
|
|
140
138
|
setPendingCollapse(new Set(doneGroupKeys));
|
|
141
139
|
return new Set(doneGroupKeys.filter((k) => {
|
|
142
140
|
const group = computeGroups(feed).groups.get(k);
|
|
@@ -159,7 +157,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
|
|
|
159
157
|
return;
|
|
160
158
|
toggleGroup(focusedGroupKey);
|
|
161
159
|
}, [focusedGroupKey, toggleGroup]);
|
|
162
|
-
// Auto-collapse groups when they become inactive if they're in pendingCollapse
|
|
163
160
|
React.useEffect(() => {
|
|
164
161
|
if (pendingCollapse.size === 0)
|
|
165
162
|
return;
|
|
@@ -354,10 +351,6 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
|
|
|
354
351
|
const contentRows = Math.max(1, feedRows);
|
|
355
352
|
const maxScrollOffset = Math.max(0, feedLines.length - contentRows);
|
|
356
353
|
wheelStateRef.current = { screen, maxScrollOffset };
|
|
357
|
-
// scrollOffset < 0 is a sentinel meaning "pin the most recent user message to
|
|
358
|
-
// the top of the viewport" (with empty space below for the incoming reply).
|
|
359
|
-
// Once the reply grows past the viewport we fall back to bottom-anchoring so
|
|
360
|
-
// the streaming output stays visible. Any manual scroll clears the sentinel.
|
|
361
354
|
const pinUserToTop = scrollOffset < 0 && lastUserLineStart >= 0;
|
|
362
355
|
let startIdx;
|
|
363
356
|
if (pinUserToTop) {
|
|
@@ -402,16 +395,7 @@ export function SciraApp({ runPath: initialRunPath, config: initialConfig, updat
|
|
|
402
395
|
hoverMapRef.current = hoverMap;
|
|
403
396
|
}
|
|
404
397
|
const slicedLines = feedLines.slice(startIdx, startIdx + contentRows);
|
|
405
|
-
// The feed box is bottom-aligned (justifyContent="flex-end"). When pinning the
|
|
406
|
-
// user message to the top, pad blank lines below it so short content is pushed
|
|
407
|
-
// up — the user message sits at the top with empty room below for the reply.
|
|
408
398
|
const blankLine = (k) => _jsx(Text, { children: " " }, k);
|
|
409
|
-
// Show an animated loading line whenever the agent is still doing non-text
|
|
410
|
-
// work (reasoning, tool calls, status) — i.e. the latest feed item isn't the
|
|
411
|
-
// streamed answer text. It stays pinned below the latest content for the whole
|
|
412
|
-
// turn, and the phrase rotates slowly so it doesn't feel frozen. The feed box
|
|
413
|
-
// is bottom-aligned with overflow hidden, so when the timeline is long the
|
|
414
|
-
// loader stays visible and the oldest lines scroll off the top instead.
|
|
415
399
|
const lastItem = feed[feed.length - 1];
|
|
416
400
|
const showLoader = busy && lastItem !== undefined && lastItem.kind !== "text";
|
|
417
401
|
const phrase = LOADING_PHRASES[Math.floor(frame / 24) % LOADING_PHRASES.length];
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { HOME_TIPS } from "../constants.js";
|
|
3
|
-
/** Stable mount-only effect: makes intent explicit, prevents accidental dep-driven re-runs. */
|
|
4
3
|
export function useMountEffect(effect) {
|
|
5
|
-
/* eslint-disable-next-line react-hooks/exhaustive-deps */
|
|
6
4
|
React.useEffect(effect, []);
|
|
7
5
|
}
|
|
8
|
-
/** Cycles the tip index every 6 s while mounted (rendered only on the home screen). */
|
|
9
6
|
export function TipCycler({ setTipIndex }) {
|
|
10
7
|
useMountEffect(() => {
|
|
11
8
|
const id = setInterval(() => setTipIndex((i) => (i + 1) % HOME_TIPS.length), 6000);
|
|
@@ -13,13 +10,9 @@ export function TipCycler({ setTipIndex }) {
|
|
|
13
10
|
});
|
|
14
11
|
return null;
|
|
15
12
|
}
|
|
16
|
-
/** Drives the spinner, caret-blink, and reasoning-clock animations while mounted (rendered only when busy). */
|
|
17
13
|
export function AnimationTick({ setBlink, setFrame, setReasoningTick, }) {
|
|
18
14
|
useMountEffect(() => {
|
|
19
15
|
const blinkId = setInterval(() => setBlink((v) => !v), 400);
|
|
20
|
-
// frame increments monotonically; consumers take `% SPINNER_FRAMES.length` for the
|
|
21
|
-
// spinner glyph and `Math.floor(frame / 24)` for the slow phrase rotation. Wrapping it
|
|
22
|
-
// here would pin the phrase to index 0 forever (frame would never exceed the frame count).
|
|
23
16
|
const frameId = setInterval(() => setFrame((f) => f + 1), 80);
|
|
24
17
|
const tickId = setInterval(() => setReasoningTick((t) => t + 1), 500);
|
|
25
18
|
return () => {
|
|
@@ -32,7 +25,6 @@ export function AnimationTick({ setBlink, setFrame, setReasoningTick, }) {
|
|
|
32
25
|
});
|
|
33
26
|
return null;
|
|
34
27
|
}
|
|
35
|
-
/** Enables SGR mouse-click/hover tracking while mounted (rendered only on the home screen). */
|
|
36
28
|
export function MouseTracker({ stdout, stdin, onData, onUnmount, }) {
|
|
37
29
|
useMountEffect(() => {
|
|
38
30
|
stdout.write("\x1b[?1003h\x1b[?1006h");
|
|
@@ -48,8 +48,6 @@ function computeHeroLayout(bodyRows, bodyCols, hasNotice) {
|
|
|
48
48
|
newResearchRowOffset,
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
|
-
/** Home screen body: branding card, browse modal, notice, and tip line.
|
|
52
|
-
* Also (re)builds the mouse click/hover row maps as a render side-effect. */
|
|
53
51
|
export function HomeScreen({ cols, rows, sessions, selectedIdx, hoveredIdx, heroHidden, notice, tipIndex, commandMenuHeight, mcpOpen, sessionsModalOpen, sessionsModalIdx, inputText, config, modelName, clickMapRef, hoverMapRef, setSelectedIdx, setSessionsModalOpen, setSessionsModalIdx, setNotice, openRun, submitHome, exit, }) {
|
|
54
52
|
const theme = useTheme();
|
|
55
53
|
const bodyCols = Math.max(32, cols - 4);
|
|
@@ -10,7 +10,6 @@ export function TopBar({ screen, runState, fullMode, planMode, activeUsage, busy
|
|
|
10
10
|
}
|
|
11
11
|
export function InputBar({ inputLines, cursorLine, cursorCol, showCursor, approvalPending, busy, frame, boxWidth, modelName, planMode, config }) {
|
|
12
12
|
const theme = useTheme();
|
|
13
|
-
// Plan mode tints the whole input box (unless an approval/busy state takes precedence).
|
|
14
13
|
const borderColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.textDim;
|
|
15
14
|
const promptColor = approvalPending ? theme.warning : busy ? theme.accentDim : planMode ? "cyan" : theme.accent;
|
|
16
15
|
const inputColor = approvalPending ? theme.textDim : theme.inputText;
|
|
@@ -113,13 +112,8 @@ export function MenuDialog({ menu, cols, rows, config }) {
|
|
|
113
112
|
const dialogH = 5 + (menu.loading ? 1 : Math.min(DIALOG_ITEMS, menuFiltered.length) + (menuStart > 0 ? 1 : 0) + (menuFiltered.length - (menuStart + DIALOG_ITEMS) > 0 ? 1 : 0));
|
|
114
113
|
const dialogTop = Math.max(1, Math.floor((rows - dialogH) / 2));
|
|
115
114
|
const bg = theme.userBandBackground ? { backgroundColor: theme.userBandBackground } : {};
|
|
116
|
-
const innerW = DIALOG_W - 2;
|
|
117
|
-
// Draw the border as characters inside full-width background lines (like
|
|
118
|
-
// InputBar). Ink's box border + backgroundColor leaves unfilled gaps, so we
|
|
119
|
-
// compose each line ourselves: every line is one solid Text spanning DIALOG_W.
|
|
115
|
+
const innerW = DIALOG_W - 2;
|
|
120
116
|
const line = (key, visibleLen, content) => (_jsxs(Text, { ...bg, wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: "\u2502" }), _jsx(Text, { children: " " }), content, _jsx(Text, { children: " ".repeat(Math.max(0, innerW - 1 - visibleLen)) }), _jsx(Text, { color: theme.accent, children: "\u2502" })] }, key));
|
|
121
|
-
// Clip a string to at most `max` display columns (so a row never overruns the
|
|
122
|
-
// border on narrow terminals — wrap="truncate" would eat the closing │).
|
|
123
117
|
const clip = (s, max) => {
|
|
124
118
|
if (max <= 0)
|
|
125
119
|
return "";
|
|
@@ -129,17 +123,16 @@ export function MenuDialog({ menu, cols, rows, config }) {
|
|
|
129
123
|
for (const ch of s) {
|
|
130
124
|
const cw = displayWidth(ch);
|
|
131
125
|
if (w + cw > max - 1)
|
|
132
|
-
break;
|
|
126
|
+
break;
|
|
133
127
|
out += ch;
|
|
134
128
|
w += cw;
|
|
135
129
|
}
|
|
136
130
|
return out + "…";
|
|
137
131
|
};
|
|
138
|
-
const avail = innerW - 1;
|
|
132
|
+
const avail = innerW - 1;
|
|
139
133
|
const title = menu.type === "model" ? "Select model" : menu.type === "llm" ? "Select LLM provider" : "Select search provider";
|
|
140
134
|
const hint = "↑↓ navigate · ⏎ apply · esc close";
|
|
141
135
|
const dialogLines = [];
|
|
142
|
-
// Title + hint, dropping/clipping the (secondary) hint when space is tight.
|
|
143
136
|
const titleC = clip(title, avail);
|
|
144
137
|
const hintRoom = avail - displayWidth(titleC) - 2;
|
|
145
138
|
const hintC = hintRoom >= 6 ? clip(hint, hintRoom) : "";
|
package/dist/ui/ink/constants.js
CHANGED
|
@@ -4,14 +4,12 @@ export const FILE_MENTION_MAX_CHARS = 20000;
|
|
|
4
4
|
export const FILE_MENTION_SKIP = new Set([".git", "node_modules", "dist", ".scira"]);
|
|
5
5
|
export const PROVIDERS = ["parallel", "exa", "firecrawl"];
|
|
6
6
|
export const CHAT_COMMANDS = ["/help", "/home", "/new", "/plan", "/rerun", "/report", "/sources", "/claims", "/why", "/mcp", "/copy", "/usage", "/rename", "/model", "/llm", "/provider", "/thinking", "/reasoning", "/theme", "/links", "/key", "/keys", "/stop", "/back", "/quit"];
|
|
7
|
-
/** Commands grouped by purpose, for the /help reference. */
|
|
8
7
|
export const COMMAND_GROUPS = [
|
|
9
8
|
{ label: "Model", commands: ["/llm", "/model", "/provider", "/plan", "/thinking", "/reasoning"] },
|
|
10
9
|
{ label: "Session", commands: ["/new", "/rerun", "/rename", "/report", "/sources", "/claims", "/why", "/copy", "/usage"] },
|
|
11
10
|
{ label: "Setup", commands: ["/key", "/keys", "/mcp", "/theme", "/links"] },
|
|
12
11
|
{ label: "Go", commands: ["/home", "/back", "/stop", "/quit"] },
|
|
13
12
|
];
|
|
14
|
-
/** Keyboard shortcuts shown in /help (not discoverable via the `/` autocomplete). */
|
|
15
13
|
export const KEY_HINTS = [
|
|
16
14
|
{ keys: "↑↓ jk u/d pgup/dn", action: "scroll" },
|
|
17
15
|
{ keys: "^C ^C / ^D", action: "quit" },
|
|
@@ -19,7 +17,6 @@ export const KEY_HINTS = [
|
|
|
19
17
|
{ keys: "/ @ #", action: "commands · files · sessions" },
|
|
20
18
|
{ keys: "[ ] C", action: "navigate / toggle tool groups" },
|
|
21
19
|
];
|
|
22
|
-
/** Slash commands that take an argument; ⏎ from the menu appends a space instead of running. */
|
|
23
20
|
export const COMMANDS_NEEDING_ARGS = new Set(["/theme", "/key", "/rename", "/why", "/links"]);
|
|
24
21
|
export const COMMAND_DESCRIPTIONS = {
|
|
25
22
|
"/help": "Show command and keyboard shortcuts.",
|
|
@@ -20,13 +20,10 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
20
20
|
if (existing?.busy)
|
|
21
21
|
return;
|
|
22
22
|
const session = createSession(runPath);
|
|
23
|
-
// Always re-attach subscriber so follow-up turns have a live listener.
|
|
24
23
|
attachSubscriber(runPath, getSubscriber());
|
|
25
24
|
const controller = new AbortController();
|
|
26
25
|
session.abort = controller;
|
|
27
26
|
setBusy(true);
|
|
28
|
-
// Pin the just-sent user message to the top of the viewport, leaving room
|
|
29
|
-
// below for the incoming assistant reply (-1 sentinel; see SciraApp).
|
|
30
27
|
setScrollOffset(-1);
|
|
31
28
|
sessionSetBusy(runPath, true);
|
|
32
29
|
const modelId = config.model;
|
|
@@ -43,7 +40,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
43
40
|
if (cleanTitle)
|
|
44
41
|
await setRunTitle(runPath, cleanTitle);
|
|
45
42
|
}
|
|
46
|
-
catch {
|
|
43
|
+
catch { }
|
|
47
44
|
})();
|
|
48
45
|
}
|
|
49
46
|
const onApprovalRequired = (toolName, description) => new Promise((resolve) => sessionSetApproval(runPath, { toolName, description, resolve }));
|
|
@@ -62,8 +59,6 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
62
59
|
sessionFinishReasoning(runPath);
|
|
63
60
|
break;
|
|
64
61
|
case "tool-call": {
|
|
65
|
-
// Stash the raw input (capped) so dedicated tool renderers (diffs,
|
|
66
|
-
// checklists, …) can reconstruct it from the feed.
|
|
67
62
|
let inputJson;
|
|
68
63
|
try {
|
|
69
64
|
inputJson = JSON.stringify(part.input)?.slice(0, 32_000);
|
|
@@ -117,7 +112,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
117
112
|
turnUsage.output += u.outputTokens ?? 0;
|
|
118
113
|
turnUsage.total += u.totalTokens ?? (u.inputTokens ?? 0) + (u.outputTokens ?? 0);
|
|
119
114
|
}
|
|
120
|
-
catch {
|
|
115
|
+
catch { }
|
|
121
116
|
return result.text;
|
|
122
117
|
};
|
|
123
118
|
const mentioned = await promptWithFileMentions(prompt);
|
|
@@ -217,7 +212,7 @@ export function useAgentTurn({ config, currentRunPath, queuedPromptRef, fullMode
|
|
|
217
212
|
try {
|
|
218
213
|
await Bun.write(join(runPath, "convo.json"), JSON.stringify({ feed: snapshot, messages: conversationRef.current, usage: aggregateTurns(turnsRef.current) }, null, 2));
|
|
219
214
|
}
|
|
220
|
-
catch {
|
|
215
|
+
catch { }
|
|
221
216
|
removeSession(runPath);
|
|
222
217
|
const queued = queuedPromptRef.current;
|
|
223
218
|
if (queued && !controller.signal.aborted) {
|