@lazyingart/agintiflow 0.12.3 → 0.12.5
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 +2 -2
- package/docs/agent-runtime-pipe.md +2 -0
- package/package.json +1 -1
- package/scripts/smoke-cli-chat.js +15 -1
- package/scripts/smoke-inbox.js +9 -5
- package/src/agent-runner.js +4 -0
- package/src/interactive-cli.js +388 -24
- package/src/session-store.js +25 -5
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. Assistant responses render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, and tables. 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. Assistant responses start on a fresh line after the `aginti>` header and render common Markdown, including headings, inline code, bold text, lists, quotes, code fences, and tables. 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.
|
|
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.
|
|
369
369
|
|
|
370
370
|
Safe preflight endpoints:
|
|
371
371
|
|
|
@@ -9,6 +9,8 @@ AgInTiFlow keeps CLI and web runs equivalent by using the project folder as the
|
|
|
9
9
|
|
|
10
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.
|
|
11
11
|
|
|
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
|
+
|
|
12
14
|
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>`.
|
|
13
15
|
|
|
14
16
|
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
|
@@ -84,6 +84,17 @@ try {
|
|
|
84
84
|
if (hugePromptLayout.renderedRows.length > 12 || !hugePromptLayout.renderedRows.some((line) => line.includes("earlier input row"))) {
|
|
85
85
|
throw new Error("terminal prompt layout did not bound redraw size for large prompts");
|
|
86
86
|
}
|
|
87
|
+
const queuedPromptLayout = buildPromptLayout("follow up", 9, 90, 24, {
|
|
88
|
+
commandCwd: "/tmp/aginti-project",
|
|
89
|
+
pendingAsap: [{ content: "apply this to the running task" }],
|
|
90
|
+
pendingQueued: [{ content: "run this after the current task" }],
|
|
91
|
+
});
|
|
92
|
+
const queuedText = queuedPromptLayout.renderedRows
|
|
93
|
+
.map((line) => line.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ""))
|
|
94
|
+
.join("\n");
|
|
95
|
+
if (!queuedText.includes("→ apply this") || !queuedText.includes("↳ run this") || !queuedText.includes("cwd /tmp/aginti-project")) {
|
|
96
|
+
throw new Error("terminal prompt layout did not render live input queue and cwd footer");
|
|
97
|
+
}
|
|
87
98
|
|
|
88
99
|
await runCli(["init"], "");
|
|
89
100
|
const instructions = await fs.readFile(path.join(tempRoot, "AGINTI.md"), "utf8");
|
|
@@ -111,6 +122,9 @@ try {
|
|
|
111
122
|
if (!result.stdout.includes("status=running workingOn=") || !result.stdout.includes("status=idle session=")) {
|
|
112
123
|
throw new Error("interactive chat did not print simple run status updates");
|
|
113
124
|
}
|
|
125
|
+
if (!/aginti>\s+\|\r?\nMock run complete\./.test(result.stdout)) {
|
|
126
|
+
throw new Error("assistant response did not start on a fresh line after the aginti header");
|
|
127
|
+
}
|
|
114
128
|
|
|
115
129
|
const latest = await runCli(["resume"], "/exit\n");
|
|
116
130
|
if (!latest.stdout.includes("session=") || !latest.stdout.includes("Interactive agent chat")) {
|
|
@@ -122,7 +136,7 @@ try {
|
|
|
122
136
|
{
|
|
123
137
|
ok: true,
|
|
124
138
|
projectRoot: tempRoot,
|
|
125
|
-
checks: ["markdown-render", "prompt-layout", "aginti-md", "instructions-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest"],
|
|
139
|
+
checks: ["markdown-render", "prompt-layout", "live-input-layout", "agent-response-newline", "aginti-md", "instructions-command", "instructions-chat-edit", "interactive-chat", "mock-file-write", "run-status", "resume-latest"],
|
|
126
140
|
},
|
|
127
141
|
null,
|
|
128
142
|
2
|
package/scripts/smoke-inbox.js
CHANGED
|
@@ -12,20 +12,24 @@ function assert(condition, message) {
|
|
|
12
12
|
|
|
13
13
|
try {
|
|
14
14
|
const store = new SessionStore(path.join(tempRoot, ".sessions"), "inbox-smoke");
|
|
15
|
-
await store.appendInbox("first queued message", { source: "test" });
|
|
15
|
+
const first = await store.appendInbox("first queued message", { source: "test" });
|
|
16
16
|
await store.appendInbox("second queued message", { source: "test" });
|
|
17
|
+
await store.appendInbox("urgent piped message", { source: "test", priority: "asap" });
|
|
18
|
+
|
|
19
|
+
assert(first?.id, "appendInbox did not return an inbox id");
|
|
17
20
|
|
|
18
21
|
const drained = await store.drainInbox();
|
|
19
|
-
assert(drained.length ===
|
|
20
|
-
assert(drained[0].content === "
|
|
21
|
-
assert(drained[1].content === "
|
|
22
|
+
assert(drained.length === 3, "inbox did not drain all messages");
|
|
23
|
+
assert(drained[0].content === "urgent piped message", "asap message was not drained first");
|
|
24
|
+
assert(drained[1].content === "first queued message", "first queued message mismatch");
|
|
25
|
+
assert(drained[2].content === "second queued message", "second queued message mismatch");
|
|
22
26
|
assert((await store.drainInbox()).length === 0, "inbox was not cleared after drain");
|
|
23
27
|
|
|
24
28
|
console.log(
|
|
25
29
|
JSON.stringify(
|
|
26
30
|
{
|
|
27
31
|
ok: true,
|
|
28
|
-
checks: ["session-inbox-append", "session-inbox-drain"],
|
|
32
|
+
checks: ["session-inbox-append", "session-inbox-drain", "session-inbox-asap-priority"],
|
|
29
33
|
},
|
|
30
34
|
null,
|
|
31
35
|
2
|
package/src/agent-runner.js
CHANGED
|
@@ -727,12 +727,16 @@ async function injectQueuedUserMessages(store, state, observers) {
|
|
|
727
727
|
content: `Additional user message received while this run was active:\n${content}`,
|
|
728
728
|
});
|
|
729
729
|
await store.appendEvent("conversation.queued_input_applied", {
|
|
730
|
+
id: item.id || "",
|
|
730
731
|
prompt: content,
|
|
731
732
|
source: item.source || "inbox",
|
|
733
|
+
priority: item.priority || "normal",
|
|
732
734
|
});
|
|
733
735
|
observers.event("conversation.queued_input_applied", {
|
|
736
|
+
id: item.id || "",
|
|
734
737
|
prompt: content,
|
|
735
738
|
source: item.source || "inbox",
|
|
739
|
+
priority: item.priority || "normal",
|
|
736
740
|
});
|
|
737
741
|
}
|
|
738
742
|
}
|
package/src/interactive-cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { normalizePackageInstallPolicy, normalizeSandboxMode } from "./command-p
|
|
|
8
8
|
import { defaultMaxStepsForProfile, normalizeTaskProfile } from "./task-profiles.js";
|
|
9
9
|
import { recommendedMaxStepsForTask } from "./engineering-guidance.js";
|
|
10
10
|
import { promptAndSaveDeepSeekKey, promptHidden, shouldPromptForDeepSeek } from "./auth-onboarding.js";
|
|
11
|
+
import { SessionStore } from "./session-store.js";
|
|
11
12
|
|
|
12
13
|
const useColor = Boolean(input.isTTY && output.isTTY && process.env.AGINTIFLOW_NO_COLOR !== "1");
|
|
13
14
|
const ansi = {
|
|
@@ -56,6 +57,7 @@ const SLASH_COMMANDS = [
|
|
|
56
57
|
"/exit",
|
|
57
58
|
];
|
|
58
59
|
const promptHistory = [];
|
|
60
|
+
let activeRunInput = null;
|
|
59
61
|
|
|
60
62
|
function color(value, ...codes) {
|
|
61
63
|
if (!useColor || codes.length === 0) return String(value);
|
|
@@ -70,6 +72,14 @@ function label(name, bgCode) {
|
|
|
70
72
|
return color(` ${name} `, bgCode, ansi.bold);
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function outputLine(line = "") {
|
|
76
|
+
if (activeRunInput) {
|
|
77
|
+
activeRunInput.printLine(String(line));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(line);
|
|
81
|
+
}
|
|
82
|
+
|
|
73
83
|
function terminalWidth() {
|
|
74
84
|
return Math.max(Number(output.columns) || 80, 40);
|
|
75
85
|
}
|
|
@@ -133,6 +143,11 @@ function clamp(value, min, max) {
|
|
|
133
143
|
return Math.min(Math.max(value, min), max);
|
|
134
144
|
}
|
|
135
145
|
|
|
146
|
+
function compactLine(value = "", limit = 96) {
|
|
147
|
+
const text = stripAnsi(String(value || "").replace(/\s+/g, " ").trim());
|
|
148
|
+
return text.length <= limit ? text : `${text.slice(0, Math.max(limit - 1, 1))}…`;
|
|
149
|
+
}
|
|
150
|
+
|
|
136
151
|
export function stripMarkdown(text) {
|
|
137
152
|
const lines = String(text || "").split(/\r?\n/);
|
|
138
153
|
let inFence = false;
|
|
@@ -265,26 +280,29 @@ function printWrapped(prefix, text, { stripCode = "" } = {}) {
|
|
|
265
280
|
const lines = rendered.split("\n");
|
|
266
281
|
const visible = stripAnsi(prefix).length;
|
|
267
282
|
const gutter = `${" ".repeat(Math.max(visible - 2, 0))}${stripCode ? color("|", stripCode) : "|"} `;
|
|
268
|
-
|
|
283
|
+
outputLine(`${prefix}${lines[0] || ""}`);
|
|
269
284
|
for (const line of lines.slice(1)) {
|
|
270
|
-
|
|
285
|
+
outputLine(`${gutter}${line}`);
|
|
271
286
|
}
|
|
272
287
|
}
|
|
273
288
|
|
|
274
289
|
function printAgentMessage(text) {
|
|
275
|
-
|
|
290
|
+
outputLine(rolePrefix("aginti>", ansi.agentBg).trimEnd());
|
|
291
|
+
const rendered = stripMarkdown(text);
|
|
292
|
+
const lines = rendered.split("\n");
|
|
293
|
+
for (const line of lines) outputLine(line);
|
|
276
294
|
}
|
|
277
295
|
|
|
278
296
|
function printSystemLine(text) {
|
|
279
297
|
if (!String(text || "").trim()) {
|
|
280
|
-
|
|
298
|
+
outputLine("");
|
|
281
299
|
return;
|
|
282
300
|
}
|
|
283
|
-
|
|
301
|
+
outputLine(`${label("state", ansi.systemBg)} ${color(text, ansi.dim)}`);
|
|
284
302
|
}
|
|
285
303
|
|
|
286
304
|
function printHeading(text) {
|
|
287
|
-
|
|
305
|
+
outputLine(color(stripMarkdown(text), ansi.bold, ansi.cyan));
|
|
288
306
|
}
|
|
289
307
|
|
|
290
308
|
function shimmerText(text, frame) {
|
|
@@ -361,7 +379,9 @@ function printHelp() {
|
|
|
361
379
|
"",
|
|
362
380
|
"Type a normal request to run the agent. Example: write a Python CLI app with tests",
|
|
363
381
|
"Type / then Tab to autocomplete commands.",
|
|
364
|
-
"While a run is active,
|
|
382
|
+
"While a run is active, Enter pipes a message into the current run (→), Tab queues it after finish (↳).",
|
|
383
|
+
"Alt+Up edits the last piped message; Shift+Left edits the last queued message.",
|
|
384
|
+
"Esc or Ctrl+C stops gracefully and prints a resume command.",
|
|
365
385
|
].join("\n")
|
|
366
386
|
);
|
|
367
387
|
}
|
|
@@ -398,7 +418,7 @@ function promptVisibleWindow(rows, cursorRow, height = terminalHeight()) {
|
|
|
398
418
|
};
|
|
399
419
|
}
|
|
400
420
|
|
|
401
|
-
export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth(), height = terminalHeight()) {
|
|
421
|
+
export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth(), height = terminalHeight(), options = {}) {
|
|
402
422
|
const safeBuffer = String(buffer || "");
|
|
403
423
|
const safeCursor = clamp(Number(cursor) || 0, 0, safeBuffer.length);
|
|
404
424
|
const lineWidth = editorWidth(width);
|
|
@@ -494,6 +514,17 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
|
|
|
494
514
|
const renderedRows = [];
|
|
495
515
|
let renderedCursorRow = cursorRow - visible.start;
|
|
496
516
|
|
|
517
|
+
const pendingAsap = Array.isArray(options.pendingAsap) ? options.pendingAsap : [];
|
|
518
|
+
const pendingQueued = Array.isArray(options.pendingQueued) ? options.pendingQueued : [];
|
|
519
|
+
for (const item of pendingAsap.slice(-4)) {
|
|
520
|
+
renderedRows.push(panelLine(` → ${compactLine(item.content || item, lineWidth - 6)}`, ansi.systemBg, lineWidth));
|
|
521
|
+
renderedCursorRow += 1;
|
|
522
|
+
}
|
|
523
|
+
for (const item of pendingQueued.slice(-4)) {
|
|
524
|
+
renderedRows.push(panelLine(` ↳ ${compactLine(item.content || item, lineWidth - 6)}`, ansi.systemBg, lineWidth));
|
|
525
|
+
renderedCursorRow += 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
497
528
|
if (visible.topHidden > 0) {
|
|
498
529
|
renderedRows.push(panelLine(` ... ${visible.topHidden} earlier input row${visible.topHidden === 1 ? "" : "s"}`, ansi.systemBg, lineWidth));
|
|
499
530
|
renderedCursorRow += 1;
|
|
@@ -511,6 +542,9 @@ export function buildPromptLayout(buffer = "", cursor = 0, width = terminalWidth
|
|
|
511
542
|
if (suggestions.length > 0) {
|
|
512
543
|
renderedRows.push(panelLine(` hint ${suggestions.join(" ")}`, ansi.systemBg, lineWidth));
|
|
513
544
|
}
|
|
545
|
+
if (options.commandCwd) {
|
|
546
|
+
renderedRows.push(panelLine(` cwd ${options.commandCwd}`, ansi.systemBg, lineWidth));
|
|
547
|
+
}
|
|
514
548
|
|
|
515
549
|
return {
|
|
516
550
|
rows,
|
|
@@ -546,18 +580,47 @@ function clearRenderedPrompt(previous) {
|
|
|
546
580
|
}
|
|
547
581
|
}
|
|
548
582
|
|
|
549
|
-
function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
583
|
+
function moveFromPromptCursorToTop(previous) {
|
|
584
|
+
if (!previous.lineCount) return;
|
|
585
|
+
const below = previous.lineCount - 1 - previous.cursorRow;
|
|
586
|
+
if (below > 0) output.write(`\x1b[${below}B`);
|
|
587
|
+
const up = previous.lineCount - 1;
|
|
588
|
+
if (up > 0) output.write(`\x1b[${up}A`);
|
|
589
|
+
output.write("\r");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function renderPromptBuffer(buffer, cursor, previous = { lineCount: 0, cursorRow: 0, renderedRows: [] }, options = {}) {
|
|
593
|
+
const layout = buildPromptLayout(buffer, cursor, terminalWidth(), terminalHeight(), options);
|
|
594
|
+
const sameShape = previous.lineCount === layout.renderedRows.length && previous.renderedRows?.length === layout.renderedRows.length;
|
|
595
|
+
const changedRows = sameShape
|
|
596
|
+
? layout.renderedRows
|
|
597
|
+
.map((row, index) => (row === previous.renderedRows[index] ? -1 : index))
|
|
598
|
+
.filter((index) => index >= 0)
|
|
599
|
+
: layout.renderedRows.map((_row, index) => index);
|
|
600
|
+
|
|
601
|
+
if (!sameShape) {
|
|
602
|
+
output.write(ansi.cursorHide);
|
|
603
|
+
clearRenderedPrompt(previous);
|
|
604
|
+
output.write(layout.renderedRows.join("\n"));
|
|
605
|
+
} else if (changedRows.length > 0) {
|
|
606
|
+
output.write(ansi.cursorHide);
|
|
607
|
+
moveFromPromptCursorToTop(previous);
|
|
608
|
+
for (let index = 0; index < layout.renderedRows.length; index += 1) {
|
|
609
|
+
if (changedRows.includes(index)) output.write(`\r${layout.renderedRows[index]}`);
|
|
610
|
+
if (index < layout.renderedRows.length - 1) output.write("\x1b[1B\r");
|
|
611
|
+
}
|
|
612
|
+
} else {
|
|
613
|
+
moveFromPromptCursorToTop(previous);
|
|
614
|
+
}
|
|
615
|
+
|
|
554
616
|
const below = layout.renderedRows.length - 1 - layout.cursorRow;
|
|
555
617
|
if (below > 0) output.write(`\x1b[${below}A`);
|
|
556
618
|
output.write(`\r\x1b[${layout.cursorColumn + 1}G`);
|
|
557
|
-
output.write(ansi.cursorShow);
|
|
619
|
+
if (!sameShape || changedRows.length > 0) output.write(ansi.cursorShow);
|
|
558
620
|
return {
|
|
559
621
|
lineCount: layout.renderedRows.length,
|
|
560
622
|
cursorRow: layout.cursorRow,
|
|
623
|
+
renderedRows: layout.renderedRows,
|
|
561
624
|
};
|
|
562
625
|
}
|
|
563
626
|
|
|
@@ -605,7 +668,7 @@ function createAbortError(message = "Aborted with Ctrl+C") {
|
|
|
605
668
|
return error;
|
|
606
669
|
}
|
|
607
670
|
|
|
608
|
-
function readTtyPrompt() {
|
|
671
|
+
function readTtyPrompt(options = {}) {
|
|
609
672
|
return new Promise((resolve, reject) => {
|
|
610
673
|
emitKeypressEvents(input);
|
|
611
674
|
const wasRaw = Boolean(input.isRaw);
|
|
@@ -633,14 +696,14 @@ function readTtyPrompt() {
|
|
|
633
696
|
clearImmediate(redrawHandle);
|
|
634
697
|
redrawHandle = null;
|
|
635
698
|
}
|
|
636
|
-
rendered = renderPromptBuffer(buffer, cursor, rendered);
|
|
699
|
+
rendered = renderPromptBuffer(buffer, cursor, rendered, options);
|
|
637
700
|
};
|
|
638
701
|
|
|
639
702
|
const redraw = () => {
|
|
640
703
|
if (redrawHandle) return;
|
|
641
704
|
redrawHandle = setImmediate(() => {
|
|
642
705
|
redrawHandle = null;
|
|
643
|
-
rendered = renderPromptBuffer(buffer, cursor, rendered);
|
|
706
|
+
rendered = renderPromptBuffer(buffer, cursor, rendered, options);
|
|
644
707
|
});
|
|
645
708
|
};
|
|
646
709
|
|
|
@@ -800,13 +863,294 @@ function readTtyPrompt() {
|
|
|
800
863
|
});
|
|
801
864
|
}
|
|
802
865
|
|
|
803
|
-
async function readPromptAnswer(rl) {
|
|
866
|
+
async function readPromptAnswer(rl, state = {}) {
|
|
804
867
|
if (input.isTTY && output.isTTY && typeof input.setRawMode === "function") {
|
|
805
|
-
return readTtyPrompt();
|
|
868
|
+
return readTtyPrompt({ commandCwd: state.commandCwd || process.cwd() });
|
|
806
869
|
}
|
|
807
870
|
return rl.question(userPrompt());
|
|
808
871
|
}
|
|
809
872
|
|
|
873
|
+
class LiveRunInput {
|
|
874
|
+
constructor({ state, store, controller }) {
|
|
875
|
+
this.state = state;
|
|
876
|
+
this.store = store;
|
|
877
|
+
this.controller = controller;
|
|
878
|
+
this.buffer = "";
|
|
879
|
+
this.cursor = 0;
|
|
880
|
+
this.rendered = { lineCount: 0, cursorRow: 0, renderedRows: [] };
|
|
881
|
+
this.redrawHandle = null;
|
|
882
|
+
this.preferredColumn = null;
|
|
883
|
+
this.pendingAsap = [];
|
|
884
|
+
this.pendingQueued = [];
|
|
885
|
+
this.wasRaw = Boolean(input.isRaw);
|
|
886
|
+
this.started = false;
|
|
887
|
+
this.handler = this.handleKey.bind(this);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
get commandCwd() {
|
|
891
|
+
return this.state.commandCwd || process.cwd();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
start() {
|
|
895
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return false;
|
|
896
|
+
emitKeypressEvents(input);
|
|
897
|
+
input.resume();
|
|
898
|
+
input.setRawMode(true);
|
|
899
|
+
input.on("keypress", this.handler);
|
|
900
|
+
activeRunInput = this;
|
|
901
|
+
this.started = true;
|
|
902
|
+
this.renderNow();
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async stop() {
|
|
907
|
+
if (!this.started) return [];
|
|
908
|
+
if (this.redrawHandle) {
|
|
909
|
+
clearImmediate(this.redrawHandle);
|
|
910
|
+
this.redrawHandle = null;
|
|
911
|
+
}
|
|
912
|
+
input.off("keypress", this.handler);
|
|
913
|
+
if (typeof input.setRawMode === "function") input.setRawMode(this.wasRaw);
|
|
914
|
+
this.clearForExternalOutput();
|
|
915
|
+
output.write(ansi.cursorShow);
|
|
916
|
+
if (activeRunInput === this) activeRunInput = null;
|
|
917
|
+
this.started = false;
|
|
918
|
+
|
|
919
|
+
const unappliedAsap = [...this.pendingAsap];
|
|
920
|
+
if (unappliedAsap.length > 0) {
|
|
921
|
+
await this.removeInboxItems(unappliedAsap.map((item) => item.id));
|
|
922
|
+
}
|
|
923
|
+
return [
|
|
924
|
+
...unappliedAsap.map((item) => ({ ...item, kind: "asap" })),
|
|
925
|
+
...this.pendingQueued.map((item) => ({ ...item, kind: "queued" })),
|
|
926
|
+
];
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
printLine(line = "") {
|
|
930
|
+
this.clearForExternalOutput();
|
|
931
|
+
output.write(`${line}\n`);
|
|
932
|
+
this.renderNow();
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
clearForExternalOutput() {
|
|
936
|
+
if (!this.rendered.lineCount) return;
|
|
937
|
+
output.write(ansi.cursorHide);
|
|
938
|
+
clearRenderedPrompt(this.rendered);
|
|
939
|
+
this.rendered = { lineCount: 0, cursorRow: 0, renderedRows: [] };
|
|
940
|
+
output.write(ansi.cursorShow);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
renderNow() {
|
|
944
|
+
if (this.redrawHandle) {
|
|
945
|
+
clearImmediate(this.redrawHandle);
|
|
946
|
+
this.redrawHandle = null;
|
|
947
|
+
}
|
|
948
|
+
this.rendered = renderPromptBuffer(this.buffer, this.cursor, this.rendered, {
|
|
949
|
+
commandCwd: this.commandCwd,
|
|
950
|
+
pendingAsap: this.pendingAsap,
|
|
951
|
+
pendingQueued: this.pendingQueued,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
redraw() {
|
|
956
|
+
if (this.redrawHandle) return;
|
|
957
|
+
this.redrawHandle = setImmediate(() => {
|
|
958
|
+
this.redrawHandle = null;
|
|
959
|
+
this.renderNow();
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
setBuffer(nextBuffer, nextCursor = nextBuffer.length) {
|
|
964
|
+
this.buffer = String(nextBuffer || "");
|
|
965
|
+
this.cursor = clamp(nextCursor, 0, this.buffer.length);
|
|
966
|
+
this.preferredColumn = null;
|
|
967
|
+
this.redraw();
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
moveVertical(direction) {
|
|
971
|
+
const layout = buildPromptLayout(this.buffer, this.cursor, terminalWidth(), terminalHeight(), {
|
|
972
|
+
commandCwd: this.commandCwd,
|
|
973
|
+
pendingAsap: this.pendingAsap,
|
|
974
|
+
pendingQueued: this.pendingQueued,
|
|
975
|
+
});
|
|
976
|
+
const location = cursorLocation(layout, this.cursor);
|
|
977
|
+
const targetRowIndex = location.rowIndex + direction;
|
|
978
|
+
if (targetRowIndex < 0 || targetRowIndex >= layout.rows.length) return;
|
|
979
|
+
const currentColumn = this.preferredColumn ?? location.column;
|
|
980
|
+
const targetRow = layout.rows[targetRowIndex];
|
|
981
|
+
this.cursor = targetRow.start + Math.min(currentColumn, targetRow.end - targetRow.start);
|
|
982
|
+
this.preferredColumn = currentColumn;
|
|
983
|
+
this.redraw();
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
async removeInboxItems(ids = []) {
|
|
987
|
+
if (ids.length === 0) return;
|
|
988
|
+
const idSet = new Set(ids);
|
|
989
|
+
const inbox = await this.store.loadInbox().catch(() => []);
|
|
990
|
+
await this.store.saveInbox(inbox.filter((item) => !idSet.has(item.id))).catch(() => {});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async submitAsap() {
|
|
994
|
+
const content = this.buffer.trim();
|
|
995
|
+
if (!content) return;
|
|
996
|
+
const item = {
|
|
997
|
+
id: `cli-asap-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
998
|
+
content,
|
|
999
|
+
priority: "asap",
|
|
1000
|
+
source: "cli-live",
|
|
1001
|
+
};
|
|
1002
|
+
this.pendingAsap.push(item);
|
|
1003
|
+
this.setBuffer("");
|
|
1004
|
+
await this.store.appendInbox(content, item).catch((error) => {
|
|
1005
|
+
this.pendingAsap = this.pendingAsap.filter((pending) => pending.id !== item.id);
|
|
1006
|
+
this.printLine(`${label("error", ansi.systemBg)} failed to pipe message: ${error.message}`);
|
|
1007
|
+
});
|
|
1008
|
+
await this.store.appendEvent("conversation.piped_input", {
|
|
1009
|
+
id: item.id,
|
|
1010
|
+
prompt: content,
|
|
1011
|
+
source: item.source,
|
|
1012
|
+
priority: item.priority,
|
|
1013
|
+
}).catch(() => {});
|
|
1014
|
+
this.redraw();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
queueAfterFinish() {
|
|
1018
|
+
const content = this.buffer.trim();
|
|
1019
|
+
if (!content) return;
|
|
1020
|
+
this.pendingQueued.push({
|
|
1021
|
+
id: `cli-queued-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
1022
|
+
content,
|
|
1023
|
+
priority: "after-finish",
|
|
1024
|
+
source: "cli-live",
|
|
1025
|
+
});
|
|
1026
|
+
this.setBuffer("");
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
editLastQueued() {
|
|
1030
|
+
const item = this.pendingQueued.pop();
|
|
1031
|
+
if (!item) return;
|
|
1032
|
+
this.setBuffer(item.content);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
editLastAsap() {
|
|
1036
|
+
const item = this.pendingAsap.pop();
|
|
1037
|
+
if (!item) return;
|
|
1038
|
+
void this.removeInboxItems([item.id]);
|
|
1039
|
+
this.setBuffer(item.content);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
markApplied(data = {}) {
|
|
1043
|
+
const id = data.id || "";
|
|
1044
|
+
const prompt = String(data.prompt || "");
|
|
1045
|
+
if (id) {
|
|
1046
|
+
this.pendingAsap = this.pendingAsap.filter((item) => item.id !== id);
|
|
1047
|
+
} else if (prompt) {
|
|
1048
|
+
const index = this.pendingAsap.findIndex((item) => item.content === prompt);
|
|
1049
|
+
if (index >= 0) this.pendingAsap.splice(index, 1);
|
|
1050
|
+
}
|
|
1051
|
+
this.redraw();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
handleKey(str = "", key = {}) {
|
|
1055
|
+
if (key.ctrl && key.name === "c") {
|
|
1056
|
+
this.controller.abort(new Error("Interrupted by ctrl-c."));
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (key.name === "escape") {
|
|
1060
|
+
this.controller.abort(new Error("Interrupted by escape."));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (key.meta && key.name === "up") {
|
|
1064
|
+
this.editLastAsap();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if ((key.shift && key.name === "left") || key.sequence === "\x1b[1;2D") {
|
|
1068
|
+
this.editLastQueued();
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if ((key.ctrl && key.name === "j") || key.sequence === "\n") {
|
|
1072
|
+
({ buffer: this.buffer, cursor: this.cursor } = insertAt(this.buffer, this.cursor, "\n"));
|
|
1073
|
+
this.preferredColumn = null;
|
|
1074
|
+
this.redraw();
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (key.name === "return" || key.name === "enter" || key.sequence === "\r" || str === "\r") {
|
|
1078
|
+
void this.submitAsap();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (key.name === "tab") {
|
|
1082
|
+
this.queueAfterFinish();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (key.name === "backspace") {
|
|
1086
|
+
({ buffer: this.buffer, cursor: this.cursor } = removeBefore(this.buffer, this.cursor));
|
|
1087
|
+
this.preferredColumn = null;
|
|
1088
|
+
this.redraw();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (key.name === "delete") {
|
|
1092
|
+
({ buffer: this.buffer, cursor: this.cursor } = removeAt(this.buffer, this.cursor));
|
|
1093
|
+
this.preferredColumn = null;
|
|
1094
|
+
this.redraw();
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (key.name === "left") {
|
|
1098
|
+
this.cursor = Math.max(this.cursor - 1, 0);
|
|
1099
|
+
this.preferredColumn = null;
|
|
1100
|
+
this.redraw();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (key.name === "right") {
|
|
1104
|
+
this.cursor = Math.min(this.cursor + 1, this.buffer.length);
|
|
1105
|
+
this.preferredColumn = null;
|
|
1106
|
+
this.redraw();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
if (key.name === "up") {
|
|
1110
|
+
this.moveVertical(-1);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (key.name === "down") {
|
|
1114
|
+
this.moveVertical(1);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if ((key.ctrl && key.name === "a") || key.name === "home") {
|
|
1118
|
+
this.cursor = lineBounds(this.buffer, this.cursor).start;
|
|
1119
|
+
this.preferredColumn = null;
|
|
1120
|
+
this.redraw();
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if ((key.ctrl && key.name === "e") || key.name === "end") {
|
|
1124
|
+
this.cursor = lineBounds(this.buffer, this.cursor).end;
|
|
1125
|
+
this.preferredColumn = null;
|
|
1126
|
+
this.redraw();
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (key.ctrl && key.name === "u") {
|
|
1130
|
+
const bounds = lineBounds(this.buffer, this.cursor);
|
|
1131
|
+
this.buffer = `${this.buffer.slice(0, bounds.start)}${this.buffer.slice(this.cursor)}`;
|
|
1132
|
+
this.cursor = bounds.start;
|
|
1133
|
+
this.preferredColumn = null;
|
|
1134
|
+
this.redraw();
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (key.ctrl && key.name === "k") {
|
|
1138
|
+
const bounds = lineBounds(this.buffer, this.cursor);
|
|
1139
|
+
this.buffer = `${this.buffer.slice(0, this.cursor)}${this.buffer.slice(bounds.end)}`;
|
|
1140
|
+
this.preferredColumn = null;
|
|
1141
|
+
this.redraw();
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (key.ctrl || key.meta) return;
|
|
1145
|
+
if (str && !key.sequence?.startsWith("\x1b")) {
|
|
1146
|
+
const text = str.replace(/\r/g, "");
|
|
1147
|
+
({ buffer: this.buffer, cursor: this.cursor } = insertAt(this.buffer, this.cursor, text));
|
|
1148
|
+
this.preferredColumn = null;
|
|
1149
|
+
this.redraw();
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
810
1154
|
function printStatus(state) {
|
|
811
1155
|
printSystemLine(`project=${process.cwd()}`);
|
|
812
1156
|
printSystemLine(`cwd=${state.commandCwd || process.cwd()}`);
|
|
@@ -1221,8 +1565,12 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
1221
1565
|
printSystemLine(`session=${state.sessionId}`);
|
|
1222
1566
|
printSystemLine(`status=running workingOn=${state.activeGoal}`);
|
|
1223
1567
|
|
|
1224
|
-
const
|
|
1568
|
+
const store = new SessionStore(config.sessionsDir, state.sessionId);
|
|
1569
|
+
const liveInput = new LiveRunInput({ state, store, controller });
|
|
1570
|
+
const liveStarted = liveInput.start();
|
|
1571
|
+
const detachInterrupts = liveStarted ? () => {} : attachRunInterrupts(controller);
|
|
1225
1572
|
let result;
|
|
1573
|
+
let queuedAfterFinish = [];
|
|
1226
1574
|
try {
|
|
1227
1575
|
result = await runAgent({
|
|
1228
1576
|
...config,
|
|
@@ -1235,7 +1583,7 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
1235
1583
|
} else if (options.kind === "heading") {
|
|
1236
1584
|
printHeading(text);
|
|
1237
1585
|
} else if (options.error) {
|
|
1238
|
-
|
|
1586
|
+
outputLine(`${label("error", ansi.systemBg)} ${stripMarkdown(text)}`);
|
|
1239
1587
|
} else {
|
|
1240
1588
|
printSystemLine(text);
|
|
1241
1589
|
}
|
|
@@ -1252,7 +1600,8 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
1252
1600
|
} else if (type === "loop.guard") {
|
|
1253
1601
|
printStatusEvent(state, "loop_guard", data.toolName || "");
|
|
1254
1602
|
} else if (type === "conversation.queued_input_applied") {
|
|
1255
|
-
|
|
1603
|
+
liveInput.markApplied(data);
|
|
1604
|
+
printStatusEvent(state, "queued_input_applied", data.priority === "asap" ? "asap" : "");
|
|
1256
1605
|
} else if (type === "session.finished") {
|
|
1257
1606
|
printStatusEvent(state, "finished");
|
|
1258
1607
|
} else if (type === "session.stopped") {
|
|
@@ -1264,6 +1613,7 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
1264
1613
|
});
|
|
1265
1614
|
} finally {
|
|
1266
1615
|
detachInterrupts();
|
|
1616
|
+
queuedAfterFinish = await liveInput.stop();
|
|
1267
1617
|
}
|
|
1268
1618
|
state.sessionId = result.sessionId || state.sessionId;
|
|
1269
1619
|
state.status = result.stopped ? "stopped" : "idle";
|
|
@@ -1271,7 +1621,9 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
1271
1621
|
printSystemLine(`status=${state.status} session=${state.sessionId}`);
|
|
1272
1622
|
if (result.stopped && result.reason === "user_interrupt") {
|
|
1273
1623
|
await printResumeHint(state);
|
|
1624
|
+
return [];
|
|
1274
1625
|
}
|
|
1626
|
+
return queuedAfterFinish;
|
|
1275
1627
|
}
|
|
1276
1628
|
|
|
1277
1629
|
export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
|
|
@@ -1296,7 +1648,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
1296
1648
|
while (true) {
|
|
1297
1649
|
let answer = "";
|
|
1298
1650
|
try {
|
|
1299
|
-
answer = await readPromptAnswer(rl);
|
|
1651
|
+
answer = await readPromptAnswer(rl, state);
|
|
1300
1652
|
} catch (error) {
|
|
1301
1653
|
if (error?.code === "ERR_USE_AFTER_CLOSE") break;
|
|
1302
1654
|
if (isAbortError(error)) {
|
|
@@ -1314,7 +1666,19 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
1314
1666
|
}
|
|
1315
1667
|
|
|
1316
1668
|
try {
|
|
1317
|
-
|
|
1669
|
+
const pendingPrompts = [{ content: line }];
|
|
1670
|
+
while (pendingPrompts.length > 0) {
|
|
1671
|
+
const nextPrompt = pendingPrompts.shift();
|
|
1672
|
+
const content = String(nextPrompt.content || "").trim();
|
|
1673
|
+
if (!content) continue;
|
|
1674
|
+
if (content.startsWith("/")) {
|
|
1675
|
+
const keepGoing = await handleCommand(content, state, packageDir);
|
|
1676
|
+
if (!keepGoing) return;
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const queued = await runPrompt(content, state, packageDir);
|
|
1680
|
+
pendingPrompts.push(...queued.filter((item) => String(item.content || "").trim()));
|
|
1681
|
+
}
|
|
1318
1682
|
} catch (error) {
|
|
1319
1683
|
if (isAbortError(error)) {
|
|
1320
1684
|
await printResumeHint(state);
|
package/src/session-store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
|
|
4
5
|
export class SessionStore {
|
|
5
6
|
constructor(baseDir, sessionId) {
|
|
@@ -63,16 +64,20 @@ export class SessionStore {
|
|
|
63
64
|
async appendInbox(content, metadata = {}) {
|
|
64
65
|
await this.ensure();
|
|
65
66
|
const text = String(content || "").trim();
|
|
66
|
-
if (!text) return;
|
|
67
|
-
const
|
|
67
|
+
if (!text) return null;
|
|
68
|
+
const item = {
|
|
69
|
+
id: metadata.id || `inbox-${crypto.randomUUID()}`,
|
|
68
70
|
timestamp: new Date().toISOString(),
|
|
69
71
|
content: text,
|
|
72
|
+
priority: metadata.priority || "normal",
|
|
70
73
|
...metadata,
|
|
71
|
-
}
|
|
74
|
+
};
|
|
75
|
+
const line = JSON.stringify(item);
|
|
72
76
|
await fs.appendFile(this.inboxPath, `${line}\n`, "utf8");
|
|
77
|
+
return item;
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
async
|
|
80
|
+
async loadInbox() {
|
|
76
81
|
await this.ensure();
|
|
77
82
|
let raw = "";
|
|
78
83
|
try {
|
|
@@ -80,7 +85,6 @@ export class SessionStore {
|
|
|
80
85
|
} catch {
|
|
81
86
|
return [];
|
|
82
87
|
}
|
|
83
|
-
await fs.writeFile(this.inboxPath, "", "utf8");
|
|
84
88
|
return raw
|
|
85
89
|
.split("\n")
|
|
86
90
|
.map((line) => line.trim())
|
|
@@ -88,6 +92,22 @@ export class SessionStore {
|
|
|
88
92
|
.map((line) => JSON.parse(line));
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
async saveInbox(items = []) {
|
|
96
|
+
await this.ensure();
|
|
97
|
+
const lines = items.filter(Boolean).map((item) => JSON.stringify(item));
|
|
98
|
+
await fs.writeFile(this.inboxPath, lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf8");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async drainInbox() {
|
|
102
|
+
await this.ensure();
|
|
103
|
+
const items = await this.loadInbox();
|
|
104
|
+
await fs.writeFile(this.inboxPath, "", "utf8");
|
|
105
|
+
return items.sort((a, b) => {
|
|
106
|
+
const priority = (item) => (item.priority === "asap" ? 0 : 1);
|
|
107
|
+
return priority(a) - priority(b) || String(a.timestamp || "").localeCompare(String(b.timestamp || ""));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
async saveSnapshot(step, snapshot) {
|
|
92
112
|
await this.ensure();
|
|
93
113
|
const filename = `step-${String(step).padStart(3, "0")}.snapshot.json`;
|