@edgehero/pi-dispatch 4.3.0 → 5.0.0
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/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal chat's screens (issue #647, REQ-TERMINAL-CHAT): the LIVE view of a running job (its transcript as it is
|
|
3
|
+
* written, a status line, and an editor that steers it) and the REPLY view of a parked one (its question and an editor
|
|
4
|
+
* that answers it). Drawn with pi-tui, the renderer pi itself uses, so the operator sees a run the way pi shows a chat.
|
|
5
|
+
*
|
|
6
|
+
* EVERY ENTRY IS HOSTILE TEXT. The transcript is agent-written, and the agent read issues, files and web pages anyone
|
|
7
|
+
* could have authored. The console redacts the job's secrets and the worker escapes control characters before an entry
|
|
8
|
+
* is published, but a terminal is a far richer target than an HTML page (an escape sequence can retitle the window,
|
|
9
|
+
* rewrite the clipboard over OSC 52, draw a fake prompt, or turn a link's visible text into another target over OSC 8),
|
|
10
|
+
* so this module trusts neither and scrubs again: `scrubForTerminal` removes every C0 control but newline and tab, DEL,
|
|
11
|
+
* every C1 control (0x80 to 0x9f: a lone 0x9b is CSI on some terminals), ESC with it, the bidi controls that reorder
|
|
12
|
+
* what is shown (a U+202E makes `exe.txt` read as `txt.exe`), and the characters that are invisible or that pi-tui
|
|
13
|
+
* measures at a width the terminal does not draw them at (zero-width spaces and joiners, U+2060, U+FEFF, the soft
|
|
14
|
+
* hyphen, the tag block U+E0000 to U+E007F, Hangul fillers and leading jamo), so a line can neither hide text nor
|
|
15
|
+
* misalign the screen; it clips an entry to 64 KiB. Hyperlinks are turned off in pi-tui
|
|
16
|
+
* (`setCapabilityOverrides({hyperlinks: false})`; a test records that call and draws a link with and without it), so a
|
|
17
|
+
* markdown link prints its target beside its text and nothing on the screen can point somewhere it does not say.
|
|
18
|
+
* pi-tui's Markdown does not decode HTML entities, so an `` in the text stays those six characters (a test holds
|
|
19
|
+
* that).
|
|
20
|
+
*
|
|
21
|
+
* MARKDOWN IS A PARSER WITH LIMITS, SO ITS INPUT IS BOUNDED FIRST. pi-tui's Markdown recurses per nesting level (a
|
|
22
|
+
* blockquote 55 levels deep, or a LaTeX group 1,266 deep, overflows the stack) and its emphasis pass is quadratic (64
|
|
23
|
+
* KiB of `*a ` takes 20 s). LaTeX recurses on commands as well as braces: `\sqrt\sqrt...x` and `\frac a\frac a...`
|
|
24
|
+
* nest with no brace at all and overflow at 1,600 to 2,000 commands (measured, pi-tui 1.2.0, `$...$`, `$$...$$` and
|
|
25
|
+
* `\[...\]` alike), so a brace bound alone is not one. `markdownPlan` therefore draws an entry as plain text when a
|
|
26
|
+
* line stacks more than `MARKDOWN_MAX_DEPTH` quote or list markers or the entry holds more than
|
|
27
|
+
* `MARKDOWN_MAX_DELIMITERS` emphasis and link delimiters, and turns LaTeX off when braces nest past
|
|
28
|
+
* `LATEX_MAX_BRACE_DEPTH` or the entry holds more than `LATEX_MAX_COMMANDS` backslashes (every command starts with
|
|
29
|
+
* one, so this bounds command nesting of every kind without parsing LaTeX); a render that throws anyway falls back
|
|
30
|
+
* to plain text for that entry alone. And whatever throws, the terminal is given back: while a screen runs, an
|
|
31
|
+
* uncaught exception, an unhandled rejection, SIGINT, SIGTERM and SIGHUP each leave the alternate screen, show the
|
|
32
|
+
* cursor and end raw mode before the process exits (`guardTerminal`).
|
|
33
|
+
*
|
|
34
|
+
* pi-tui IS LOADED LAZILY (`loadPiTui`), so `chat-cli.mjs`, the fork path and every test of the pure helpers here run
|
|
35
|
+
* without it, and the worker's boot path never touches it.
|
|
36
|
+
*
|
|
37
|
+
* WHEN THE EDITOR IS HIDDEN, THE SCREEN SAYS WHY. A viewer token (the console's role rule: viewers read status only, and
|
|
38
|
+
* never a transcript), a read-only console, a run whose trigger did not arm steering, and an armed run whose inbox is
|
|
39
|
+
* closed each have their own sentence (`editorAvailability`), so a missing box is never mistaken for a broken one.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The most bytes of one entry's text the screen keeps; the rest is cut and the cut is said. */
|
|
43
|
+
export const ENTRY_MAX_BYTES = 64 * 1024;
|
|
44
|
+
|
|
45
|
+
/** How many lines of a tool call or result are shown until `e` expands them. */
|
|
46
|
+
export const TOOL_PREVIEW_LINES = 8;
|
|
47
|
+
|
|
48
|
+
/** The most entries one screen keeps; older ones scroll away for good (the run page keeps the rest for 24 hours). */
|
|
49
|
+
export const ENTRIES_MAX = 2000;
|
|
50
|
+
|
|
51
|
+
/** How often the status line asks `/api/run` again. */
|
|
52
|
+
export const STATUS_POLL_MS = 5000;
|
|
53
|
+
|
|
54
|
+
/** How long the reply view waits for the transcript stream to deliver the question before it points at the forge. */
|
|
55
|
+
export const QUESTION_TIMEOUT_MS = 10_000;
|
|
56
|
+
|
|
57
|
+
/** The most quote and list markers one line may stack and still be drawn as markdown. */
|
|
58
|
+
export const MARKDOWN_MAX_DEPTH = 8;
|
|
59
|
+
|
|
60
|
+
/** The most emphasis and link delimiters (`*`, `_`, `~`, `[`) an entry may hold and still be drawn as markdown. */
|
|
61
|
+
export const MARKDOWN_MAX_DELIMITERS = 2000;
|
|
62
|
+
|
|
63
|
+
/** The deepest brace nesting an entry may hold and still have its LaTeX rendered. */
|
|
64
|
+
export const LATEX_MAX_BRACE_DEPTH = 16;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The most backslashes an entry may hold and still have its LaTeX rendered: about a sixth of the 1,600 nested commands
|
|
68
|
+
* that overflow, since the stack left depends on where the render runs. A longer formula shows as its source.
|
|
69
|
+
*/
|
|
70
|
+
export const LATEX_MAX_COMMANDS = 256;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How an assistant entry may be drawn: `{markdown, latex}`. Linear in the text, and run before pi-tui's Markdown ever
|
|
74
|
+
* sees it (the module comment says why). Indentation-nested lists are not counted: pi-tui draws 80 levels of them in a
|
|
75
|
+
* millisecond; it is quote and list MARKERS stacked on one line that recurse.
|
|
76
|
+
*/
|
|
77
|
+
export function markdownPlan(text) {
|
|
78
|
+
const s = String(text ?? "");
|
|
79
|
+
let delimiters = 0;
|
|
80
|
+
let braces = 0;
|
|
81
|
+
let deepest = 0;
|
|
82
|
+
let commands = 0;
|
|
83
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
84
|
+
const c = s.charCodeAt(i);
|
|
85
|
+
if (c === 42 || c === 95 || c === 126 || c === 91) delimiters += 1;
|
|
86
|
+
else if (c === 92) commands += 1;
|
|
87
|
+
else if (c === 123) deepest = Math.max(deepest, (braces += 1));
|
|
88
|
+
else if (c === 125 && braces > 0) braces -= 1;
|
|
89
|
+
}
|
|
90
|
+
if (delimiters > MARKDOWN_MAX_DELIMITERS) return { markdown: false, latex: false };
|
|
91
|
+
for (const line of s.split("\n")) {
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let i = 0;
|
|
94
|
+
for (;;) {
|
|
95
|
+
while (line[i] === " " || line[i] === "\t") i += 1;
|
|
96
|
+
if (line[i] === ">") {
|
|
97
|
+
depth += 1;
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const m = /^(?:[-*+]|[0-9]{1,9}[.)])(?=[ \t])/.exec(line.slice(i, i + 12));
|
|
102
|
+
if (!m) break;
|
|
103
|
+
depth += 1;
|
|
104
|
+
i += m[0].length;
|
|
105
|
+
}
|
|
106
|
+
if (depth > MARKDOWN_MAX_DEPTH) return { markdown: false, latex: false };
|
|
107
|
+
}
|
|
108
|
+
return { markdown: true, latex: deepest <= LATEX_MAX_BRACE_DEPTH && commands <= LATEX_MAX_COMMANDS };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const EXIT_CODE_OF_SIGNAL = Object.freeze({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129 });
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* While a screen runs, give the terminal back however the process ends: an uncaught exception or unhandled rejection
|
|
115
|
+
* calls `restore()`, prints one scrubbed line naming the failure, and exits 1; SIGINT, SIGTERM and SIGHUP call
|
|
116
|
+
* `restore()` and exit 130, 143 and 129. (Ctrl+C in raw mode is a key, not a signal, and is the screen's own detach; a
|
|
117
|
+
* SIGINT here came from `kill`.) Without this a throw inside a render, which pi-tui runs from a timer, would leave the
|
|
118
|
+
* shell in the alternate screen with no cursor and no echo. Returns the function that removes the handlers. `proc` is
|
|
119
|
+
* the seam (`on`, `removeListener`, `exit`).
|
|
120
|
+
*/
|
|
121
|
+
export function guardTerminal({ restore, proc = process, err = (line) => process.stderr.write(`${line}\n`) }) {
|
|
122
|
+
const handlers = {};
|
|
123
|
+
const fail = (e) => {
|
|
124
|
+
restore();
|
|
125
|
+
err(`error: the chat screen failed (${scrubForTerminal(e?.message ?? String(e), { maxBytes: 512 }).replace(/\n/g, " ")}); the terminal was restored`);
|
|
126
|
+
proc.exit(1);
|
|
127
|
+
};
|
|
128
|
+
handlers.uncaughtException = fail;
|
|
129
|
+
handlers.unhandledRejection = fail;
|
|
130
|
+
for (const [signal, code] of Object.entries(EXIT_CODE_OF_SIGNAL)) {
|
|
131
|
+
handlers[signal] = () => {
|
|
132
|
+
restore();
|
|
133
|
+
proc.exit(code);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
for (const [event, handler] of Object.entries(handlers)) proc.on(event, handler);
|
|
137
|
+
return () => {
|
|
138
|
+
for (const [event, handler] of Object.entries(handlers)) proc.removeListener(event, handler);
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The pinned renderer, imported only when a screen is drawn. */
|
|
143
|
+
export function loadPiTui() {
|
|
144
|
+
return import("@earendil-works/pi-tui");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// C0 but tab and newline, DEL, C1 (ESC is in C0), the bidi embedding, override, isolate and mark controls, the soft
|
|
148
|
+
// hyphen, zero-width space, non-joiner and joiner, word joiner, BOM, Hangul leading jamo and fillers, and the tag block.
|
|
149
|
+
const UNSAFE_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u00ad\u061c\u1100-\u115f\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\u3164\ufeff\uffa0\u{e0000}-\u{e007f}]/gu;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Text that is safe to hand a terminal: `\r\n` and a lone `\r` become a newline (a bare CR would let a line overwrite
|
|
153
|
+
* itself), a tab becomes four spaces, every other control and bidi character is removed, and the result is cut to
|
|
154
|
+
* `maxBytes` of UTF-8 on a character boundary with "[cut: N more bytes]" said after it.
|
|
155
|
+
*/
|
|
156
|
+
export function scrubForTerminal(text, { maxBytes = ENTRY_MAX_BYTES } = {}) {
|
|
157
|
+
let s = String(text ?? "").replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(UNSAFE_RE, "");
|
|
158
|
+
const bytes = Buffer.byteLength(s, "utf8");
|
|
159
|
+
if (bytes <= maxBytes) return s;
|
|
160
|
+
let cut = Buffer.from(s, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
161
|
+
// A multi-byte character split by the byte cut decodes as U+FFFD; drop it rather than show a stray box.
|
|
162
|
+
if (cut.endsWith("�")) cut = cut.slice(0, -1);
|
|
163
|
+
s = `${cut}\n[cut: ${bytes - Buffer.byteLength(cut, "utf8")} more bytes]`;
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What the operator typed into the live view's editor:
|
|
169
|
+
* - `/later <text>`: a follow-up, read once the agent would otherwise stop (`{kind: "followUp", text}`);
|
|
170
|
+
* - `/withdraw <id>`: take a queued or written message back (`{kind: "withdraw", id}`);
|
|
171
|
+
* - any other text: a steer, read after the current tool calls (`{kind: "steer", text}`);
|
|
172
|
+
* - blank: `{kind: "empty"}`; a slash word the view does not know: `{refusal}` (so `/latr fix it` is never sent to the
|
|
173
|
+
* agent as a message by a typo).
|
|
174
|
+
* A message that should begin with a slash is sent with two (`//etc/hosts is wrong` sends `/etc/hosts is wrong`).
|
|
175
|
+
*/
|
|
176
|
+
export function parseChatInput(text) {
|
|
177
|
+
const s = String(text ?? "");
|
|
178
|
+
const trimmed = s.trim();
|
|
179
|
+
if (trimmed === "") return { kind: "empty" };
|
|
180
|
+
if (trimmed.startsWith("//")) return { kind: "steer", text: trimmed.slice(1) };
|
|
181
|
+
if (!trimmed.startsWith("/")) return { kind: "steer", text: s };
|
|
182
|
+
const m = /^\/([a-z]+)(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
183
|
+
const word = m?.[1] ?? "";
|
|
184
|
+
const rest = (m?.[2] ?? "").trim();
|
|
185
|
+
if (word === "later") return rest === "" ? { refusal: "/later needs the message after it: /later <text>" } : { kind: "followUp", text: rest };
|
|
186
|
+
if (word === "withdraw") {
|
|
187
|
+
if (!/^[0-9a-f]{32}$/.test(rest)) return { refusal: "/withdraw needs a message id (32 lowercase hex characters, as the send printed it)" };
|
|
188
|
+
return { kind: "withdraw", id: rest };
|
|
189
|
+
}
|
|
190
|
+
return { refusal: `/${word.slice(0, 20)} is not a chat command (use /later <text> or /withdraw <id>; start a message with // to send a leading slash)` };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Whether the editor is shown, and if not, the sentence the screen shows in its place. `mode` is `"live"` or
|
|
195
|
+
* `"reply"`; `who` is `/api/whoami`'s `{role, readOnly}`; `info` is `/api/run`'s answer (`steer: {armed, open}`).
|
|
196
|
+
*/
|
|
197
|
+
export function editorAvailability({ mode, who, info }) {
|
|
198
|
+
if (who?.role !== "operator") {
|
|
199
|
+
return { shown: false, reason: mode === "reply" ? "You are signed in with a viewer token: only an operator can answer a parked run." : "You are signed in with a viewer token: you see the run's status only (transcripts and messages are an operator's)." };
|
|
200
|
+
}
|
|
201
|
+
if (who?.readOnly) return { shown: false, reason: "The console is read-only (PI_CONSOLE_READ_ONLY=1), so nothing can be sent from here." };
|
|
202
|
+
if (mode === "reply") return { shown: true };
|
|
203
|
+
// null is the console saying the inbox could not be read, which is not the same answer as "not armed".
|
|
204
|
+
if (info?.steer?.armed === null) return { shown: false, reason: "Whether this run takes messages could not be read right now; the status line checks again every few seconds." };
|
|
205
|
+
if (info?.steer?.armed !== true) return { shown: false, reason: "This run's trigger does not take messages (no \"steer\": true), so you can watch it but not steer it." };
|
|
206
|
+
if (info?.steer?.open === null) return { shown: false, reason: "Whether the run's inbox is open could not be read right now; the status line checks again every few seconds." };
|
|
207
|
+
if (info?.steer?.open !== true) return { shown: false, reason: "The run's inbox is closed (it has not started taking messages yet, or it is ending), so nothing can be sent right now." };
|
|
208
|
+
return { shown: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const intOf = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : null);
|
|
212
|
+
|
|
213
|
+
/** The status line from `/api/run`'s answer: the run, its state, its progress counts and whether it takes messages. */
|
|
214
|
+
export function statusText(info) {
|
|
215
|
+
const run = info?.run ?? {};
|
|
216
|
+
const id = liveJobIdOf(info) ?? "?";
|
|
217
|
+
const parts = [`run ${id}`];
|
|
218
|
+
if (typeof run.target === "string" && run.target !== "") parts.push(run.target);
|
|
219
|
+
if (typeof info?.state === "string") parts.push(info.state);
|
|
220
|
+
const p = info?.progress ?? {};
|
|
221
|
+
const counts = [["turns", intOf(p.turns)], ["steps", intOf(p.steps)], ["failed", intOf(p.failed)]].filter(([, n]) => n !== null).map(([k, n]) => `${k} ${n}`);
|
|
222
|
+
if (counts.length > 0) parts.push(counts.join(", "));
|
|
223
|
+
if (info?.steer?.armed === true) parts.push(info.steer.open === true ? "inbox open" : "inbox closed");
|
|
224
|
+
if (info?.transcript?.ended === true) parts.push(`ended (${typeof info.transcript.outcome === "string" ? info.transcript.outcome : "unknown"})`);
|
|
225
|
+
return scrubForTerminal(parts.join(" | "), { maxBytes: 1024 }).replace(/\n/g, " ");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const KINDS = Object.freeze({ user: "user", assistant: "assistant", "tool-call": "tool call", "tool-result": "tool result", compaction: "compaction", note: "note", reset: "restart", skipped: "skipped", end: "end" });
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* One JSON transcript entry (`{id, kind, ts, text, tool, error, truncated, outcome}`, the console's
|
|
232
|
+
* `/api/transcript?format=json` data) as what the screen draws: a header, the scrubbed body, and whether it is drawn as
|
|
233
|
+
* markdown (assistant text) or plain. Null for an event that is not an entry. Every field is re-checked here; nothing
|
|
234
|
+
* the stream sent is used by shape alone.
|
|
235
|
+
*/
|
|
236
|
+
export function entryView(raw) {
|
|
237
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
238
|
+
const kind = Object.hasOwn(KINDS, raw.kind) ? raw.kind : "note";
|
|
239
|
+
const tool = typeof raw.tool === "string" && /^[A-Za-z0-9_.:-]{1,64}$/.test(raw.tool) ? raw.tool : null;
|
|
240
|
+
const ts = Number.isSafeInteger(raw.ts) && raw.ts >= 0 && raw.ts < 8.64e15 ? new Date(raw.ts).toISOString().slice(11, 19) : null;
|
|
241
|
+
const header = [KINDS[kind], tool, raw.error === true ? "error" : null, ts ? `${ts} UTC` : null, raw.truncated === true ? "truncated" : null].filter(Boolean).join(" ");
|
|
242
|
+
let body;
|
|
243
|
+
if (kind === "reset") body = "The session file was rewritten; what follows starts over.";
|
|
244
|
+
else if (kind === "skipped") body = "A line was left out (too large or unreadable).";
|
|
245
|
+
else if (kind === "end") body = `The run is over (${typeof raw.outcome === "string" ? scrubForTerminal(raw.outcome, { maxBytes: 64 }) : "unknown"}).`;
|
|
246
|
+
else body = typeof raw.text === "string" ? scrubForTerminal(raw.text) : "";
|
|
247
|
+
return { id: typeof raw.id === "string" ? raw.id : null, kind, header, body, markdown: kind === "assistant", tool: kind === "tool-call" || kind === "tool-result" };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A tool entry's body as shown: its first `TOOL_PREVIEW_LINES` lines and how many more, or all of it when expanded. */
|
|
251
|
+
export function toolBody(body, expanded) {
|
|
252
|
+
const lines = String(body).split("\n");
|
|
253
|
+
if (expanded || lines.length <= TOOL_PREVIEW_LINES) return lines.join("\n");
|
|
254
|
+
return `${lines.slice(0, TOOL_PREVIEW_LINES).join("\n")}\n[${lines.length - TOOL_PREVIEW_LINES} more lines: press e to expand]`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The last assistant entry's scrubbed text in a list of raw entries, or null: the parked run's question. */
|
|
258
|
+
export function lastAssistantText(entries) {
|
|
259
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
260
|
+
const v = entryView(entries[i]);
|
|
261
|
+
if (v?.kind === "assistant" && v.body.trim() !== "") return v.body;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Plain SGR, so the theme needs no colour library; tests strip exactly these and then require no ESC at all.
|
|
267
|
+
const sgr = (open, close) => (s) => `\u001b[${open}m${s}\u001b[${close}m`;
|
|
268
|
+
const ident = (s) => s;
|
|
269
|
+
|
|
270
|
+
/** The screen's colours. `plain` (identity everywhere) is what tests draw with. */
|
|
271
|
+
export function chatTheme({ plain = false } = {}) {
|
|
272
|
+
const f = plain ? () => ident : sgr;
|
|
273
|
+
const bold = f(1, 22);
|
|
274
|
+
const dim = f(2, 22);
|
|
275
|
+
return {
|
|
276
|
+
bold,
|
|
277
|
+
dim,
|
|
278
|
+
accent: f(36, 39),
|
|
279
|
+
warn: f(33, 39),
|
|
280
|
+
bad: f(31, 39),
|
|
281
|
+
markdown: { heading: bold, link: f(4, 24), linkUrl: dim, code: f(36, 39), codeBlock: ident, codeBlockBorder: dim, quote: f(3, 23), quoteBorder: dim, hr: dim, listBullet: f(36, 39), bold, italic: f(3, 23), strikethrough: f(9, 29), underline: f(4, 24) },
|
|
282
|
+
editor: { borderColor: dim, selectList: { selectedPrefix: ident, selectedText: ident, description: dim, scrollInfo: dim, noMatch: dim } },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* One chat screen on pi-tui: the transcript in a follow-the-end scroll view, a status line, a notice area (results,
|
|
288
|
+
* refusals, a pending y/n), and an editor or the sentence that replaces it. Returns its handles; nothing here talks to
|
|
289
|
+
* the console. `pi` is the pi-tui module, `terminal` a pi-tui Terminal (a `ProcessTerminal`, or a test's fake).
|
|
290
|
+
*
|
|
291
|
+
* KEYS: Ctrl+C detaches (`onDetach`); Esc moves focus between the editor and the transcript; with the transcript
|
|
292
|
+
* focused (or no editor) `e` expands and folds every tool entry, and the arrows and PgUp/PgDn scroll (pi-tui's own
|
|
293
|
+
* viewport keys); Enter in the editor calls `onSubmit(text)`, Shift+Enter starts a new line. While `confirm` waits, only
|
|
294
|
+
* y, n, Esc and Ctrl+C are read (Ctrl+C answers no AND detaches).
|
|
295
|
+
*
|
|
296
|
+
* `proc` is the process seam `guardTerminal` hooks from `start` to `stop`.
|
|
297
|
+
*/
|
|
298
|
+
export function makeChatScreen({ pi, terminal, theme = chatTheme(), onSubmit = () => {}, onDetach = () => {}, proc = process, err }) {
|
|
299
|
+
// No OSC 8: a link's target is printed beside its text, so nothing on the screen points somewhere it does not say.
|
|
300
|
+
pi.setCapabilityOverrides({ hyperlinks: false, images: null });
|
|
301
|
+
const tui = new pi.TuiAltScreen(terminal, true);
|
|
302
|
+
const entries = [];
|
|
303
|
+
let expanded = false;
|
|
304
|
+
let pending = null;
|
|
305
|
+
let running = false;
|
|
306
|
+
let unguard = null;
|
|
307
|
+
|
|
308
|
+
/** Leave the alternate screen, show the cursor, end raw mode: once, and never throwing. */
|
|
309
|
+
const restore = () => {
|
|
310
|
+
if (!running) return;
|
|
311
|
+
running = false;
|
|
312
|
+
try {
|
|
313
|
+
tui.stop({ preserveScreen: true });
|
|
314
|
+
} catch {
|
|
315
|
+
try {
|
|
316
|
+
terminal.write("\u001b[?2004l\u001b[?1049l\u001b[?25h");
|
|
317
|
+
} catch {
|
|
318
|
+
// nothing more can be written
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
if (proc?.stdin?.isTTY) proc.stdin.setRawMode?.(false);
|
|
323
|
+
} catch {
|
|
324
|
+
// not a TTY after all
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const plainBody = (v) => new pi.Text(v.tool ? toolBody(v.body, expanded) : v.body, 1, 0);
|
|
329
|
+
const blockOf = (v) => {
|
|
330
|
+
const plan = v.markdown ? markdownPlan(v.body) : null;
|
|
331
|
+
const plainNote = plan && !plan.markdown ? " plain text (nested too deeply for markdown)" : "";
|
|
332
|
+
const header = new pi.Text(theme.dim(`${v.header}${plainNote}`), 0, 0);
|
|
333
|
+
const body = plan?.markdown ? new pi.Markdown(v.body, 1, 0, theme.markdown, undefined, { renderLatex: plan.latex }) : plainBody(v);
|
|
334
|
+
return { v, header, body };
|
|
335
|
+
};
|
|
336
|
+
const transcriptDoc = {
|
|
337
|
+
render(width) {
|
|
338
|
+
const out = [];
|
|
339
|
+
for (const b of entries) {
|
|
340
|
+
let lines = [];
|
|
341
|
+
if (b.v.body !== "") {
|
|
342
|
+
try {
|
|
343
|
+
lines = b.body.render(width);
|
|
344
|
+
} catch {
|
|
345
|
+
// A parser limit the plan did not foresee: this entry alone is drawn as plain text from now on.
|
|
346
|
+
b.body = plainBody({ ...b.v, tool: false });
|
|
347
|
+
b.header.setText(theme.dim(`${b.v.header} plain text (markdown could not draw it)`));
|
|
348
|
+
lines = b.body.render(width);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
out.push(...b.header.render(width), ...lines, "");
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
},
|
|
355
|
+
invalidate() {
|
|
356
|
+
for (const b of entries) {
|
|
357
|
+
b.header.invalidate();
|
|
358
|
+
b.body.invalidate();
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
const transcript = new pi.ScrollView(transcriptDoc, { follow: "end", primary: true, overscroll: "chain", scrollbar: "auto" });
|
|
363
|
+
const status = new pi.Text("", 0, 0);
|
|
364
|
+
const notice = new pi.Text("", 0, 0);
|
|
365
|
+
const hiddenReason = new pi.Text("", 0, 0);
|
|
366
|
+
const editor = new pi.Editor(tui, theme.editor, { paddingX: 1 });
|
|
367
|
+
let editorShown = false;
|
|
368
|
+
const slot = {
|
|
369
|
+
render: (width) => (editorShown ? editor.render(width) : hiddenReason.render(width)),
|
|
370
|
+
invalidate() {
|
|
371
|
+
editor.invalidate();
|
|
372
|
+
hiddenReason.invalidate();
|
|
373
|
+
},
|
|
374
|
+
handleInput: (data) => (editorShown ? editor.handleInput(data) : undefined),
|
|
375
|
+
};
|
|
376
|
+
// Focus holder for the transcript: `e` toggles tool expansion; everything else is the viewport's.
|
|
377
|
+
const transcriptKeys = { render: () => [], invalidate() {}, handleInput() {} };
|
|
378
|
+
editor.onSubmit = (text) => {
|
|
379
|
+
editor.addToHistory?.(text);
|
|
380
|
+
onSubmit(text);
|
|
381
|
+
};
|
|
382
|
+
const dock = new pi.VStack([
|
|
383
|
+
{ component: status, shrink: 1, minSize: 0 },
|
|
384
|
+
{ component: notice, shrink: 1, minSize: 0 },
|
|
385
|
+
{ component: slot, shrink: 1, minSize: 1 },
|
|
386
|
+
]);
|
|
387
|
+
const root = new pi.VStack([
|
|
388
|
+
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
389
|
+
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
|
390
|
+
]);
|
|
391
|
+
tui.setLayoutRoot(root);
|
|
392
|
+
|
|
393
|
+
tui.addInputListener((data) => {
|
|
394
|
+
if (pi.isKeyRelease(data)) return undefined;
|
|
395
|
+
if (pi.matchesKey(data, "ctrl+c")) {
|
|
396
|
+
if (pending) settle(false);
|
|
397
|
+
onDetach();
|
|
398
|
+
return { consume: true };
|
|
399
|
+
}
|
|
400
|
+
if (pending) {
|
|
401
|
+
if (data === "y" || data === "Y") settle(true);
|
|
402
|
+
else if (data === "n" || data === "N" || pi.matchesKey(data, "escape")) settle(false);
|
|
403
|
+
return { consume: true };
|
|
404
|
+
}
|
|
405
|
+
if (pi.matchesKey(data, "escape") && editorShown) {
|
|
406
|
+
tui.setFocus(tui.getFocusedComponent() === editor ? transcriptKeys : editor);
|
|
407
|
+
tui.requestRender();
|
|
408
|
+
return { consume: true };
|
|
409
|
+
}
|
|
410
|
+
if (data === "e" && (!editorShown || tui.getFocusedComponent() !== editor)) {
|
|
411
|
+
expanded = !expanded;
|
|
412
|
+
for (const b of entries) if (b.v.tool) b.body.setText(toolBody(b.v.body, expanded));
|
|
413
|
+
tui.requestRender();
|
|
414
|
+
return { consume: true };
|
|
415
|
+
}
|
|
416
|
+
return undefined;
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
function settle(answer) {
|
|
420
|
+
const p = pending;
|
|
421
|
+
pending = null;
|
|
422
|
+
setNotice(answer ? "sending..." : "not sent");
|
|
423
|
+
p?.(answer);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function setNotice(text, tone = null) {
|
|
427
|
+
const scrubbed = scrubForTerminal(text, { maxBytes: 4096 });
|
|
428
|
+
notice.setText(scrubbed === "" ? "" : tone ? theme[tone](scrubbed) : scrubbed);
|
|
429
|
+
tui.requestRender();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
tui,
|
|
434
|
+
root,
|
|
435
|
+
editor,
|
|
436
|
+
/** Add one raw transcript entry (the stream's JSON); returns false for a value that is not one. */
|
|
437
|
+
addEntry(raw) {
|
|
438
|
+
const v = entryView(raw);
|
|
439
|
+
if (!v) return false;
|
|
440
|
+
entries.push(blockOf(v));
|
|
441
|
+
if (entries.length > ENTRIES_MAX) entries.splice(0, entries.length - ENTRIES_MAX);
|
|
442
|
+
tui.requestRender();
|
|
443
|
+
return true;
|
|
444
|
+
},
|
|
445
|
+
/** A plain block that is not a transcript entry (the reply view's question, a pointer to the forge). */
|
|
446
|
+
addBlock(header, body, { markdown = false } = {}) {
|
|
447
|
+
entries.push(blockOf({ id: null, kind: "note", header: scrubForTerminal(header, { maxBytes: 1024 }), body: scrubForTerminal(body), markdown, tool: false }));
|
|
448
|
+
tui.requestRender();
|
|
449
|
+
},
|
|
450
|
+
setStatus(text) {
|
|
451
|
+
status.setText(theme.bold(scrubForTerminal(text, { maxBytes: 1024 }).replace(/\n/g, " ")));
|
|
452
|
+
tui.requestRender();
|
|
453
|
+
},
|
|
454
|
+
setNotice,
|
|
455
|
+
/** Show the editor (focused) or, in its place, `reason`. */
|
|
456
|
+
setEditor(availability) {
|
|
457
|
+
editorShown = availability?.shown === true;
|
|
458
|
+
hiddenReason.setText(editorShown ? "" : theme.warn(scrubForTerminal(availability?.reason ?? "", { maxBytes: 1024 })));
|
|
459
|
+
tui.setFocus(editorShown ? editor : transcriptKeys);
|
|
460
|
+
tui.requestRender();
|
|
461
|
+
},
|
|
462
|
+
get editorShown() {
|
|
463
|
+
return editorShown;
|
|
464
|
+
},
|
|
465
|
+
get expanded() {
|
|
466
|
+
return expanded;
|
|
467
|
+
},
|
|
468
|
+
/** Show `lines` and ask y/n in the notice area; resolves true for y. */
|
|
469
|
+
confirm(lines) {
|
|
470
|
+
if (pending) settle(false);
|
|
471
|
+
setNotice([...lines, "Send it? y/n"].join("\n"), "accent");
|
|
472
|
+
return new Promise((resolve) => {
|
|
473
|
+
pending = resolve;
|
|
474
|
+
});
|
|
475
|
+
},
|
|
476
|
+
start() {
|
|
477
|
+
running = true;
|
|
478
|
+
unguard = guardTerminal({ restore, proc, ...(err ? { err } : {}) });
|
|
479
|
+
tui.start();
|
|
480
|
+
},
|
|
481
|
+
stop() {
|
|
482
|
+
if (pending) settle(false);
|
|
483
|
+
unguard?.();
|
|
484
|
+
unguard = null;
|
|
485
|
+
restore();
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The id a run's messages and stream go to: its active (recovery) job when it has one, else its own. */
|
|
491
|
+
export function liveJobIdOf(info) {
|
|
492
|
+
if (typeof info?.activeJobId === "string" && info.activeJobId !== "") return info.activeJobId;
|
|
493
|
+
if (typeof info?.run?.jobId === "string" && info.run.jobId !== "") return info.run.jobId;
|
|
494
|
+
// A running job has no record yet, so `run.jobId` is null and the id asked about is the job's own.
|
|
495
|
+
return typeof info?.run?.id === "string" && info.run.id !== "" ? info.run.id : null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The live view. Streams the run's JSON transcript into the screen (operators only: a viewer gets the status line and
|
|
500
|
+
* the sentence that says why), polls `/api/run` every `pollMs` for the status line and whether the inbox is open, and
|
|
501
|
+
* sends what the editor submits as the console's `steer` or `steer-withdraw` action, each previewed and confirmed on
|
|
502
|
+
* screen (y/n). Resolves `{detached: true}` on Ctrl+C (the run goes on), or `{ended: true, info}` once the run is no
|
|
503
|
+
* longer live (its stream sent `done`, or the poll says another mode), with `/api/run`'s fresh answer so the caller can
|
|
504
|
+
* switch to the reply or fork mode.
|
|
505
|
+
*
|
|
506
|
+
* `lookup` is the `/api/run` query (`{id}` or `{url}`); `timers` is the seam for the poll (`setInterval`/`clearInterval`);
|
|
507
|
+
* `onScreen(screen)` hands a test the screen to draw and type into.
|
|
508
|
+
*/
|
|
509
|
+
export async function runLiveView({ client, lookup, info: first, who, pi, terminal, theme = chatTheme(), pollMs = STATUS_POLL_MS, timers = { setInterval, clearInterval }, onScreen = () => {}, proc = process }) {
|
|
510
|
+
let info = first;
|
|
511
|
+
let finished = false;
|
|
512
|
+
let poller = null;
|
|
513
|
+
let streaming = false;
|
|
514
|
+
const abort = new AbortController();
|
|
515
|
+
let resolveView;
|
|
516
|
+
const result = new Promise((resolve) => {
|
|
517
|
+
resolveView = resolve;
|
|
518
|
+
});
|
|
519
|
+
const finish = (value) => {
|
|
520
|
+
if (finished) return;
|
|
521
|
+
finished = true;
|
|
522
|
+
abort.abort();
|
|
523
|
+
if (poller !== null) timers.clearInterval(poller);
|
|
524
|
+
screen.stop();
|
|
525
|
+
resolveView(value);
|
|
526
|
+
};
|
|
527
|
+
const requery = async () => {
|
|
528
|
+
const got = await client.run(lookup).catch(() => null);
|
|
529
|
+
return got?.status === 200 && got.json ? got.json : null;
|
|
530
|
+
};
|
|
531
|
+
const screen = makeChatScreen({
|
|
532
|
+
pi,
|
|
533
|
+
terminal,
|
|
534
|
+
theme,
|
|
535
|
+
proc,
|
|
536
|
+
onDetach: () => finish({ detached: true }),
|
|
537
|
+
onSubmit: (text) => void submit(text),
|
|
538
|
+
});
|
|
539
|
+
onScreen(screen);
|
|
540
|
+
const jobId = liveJobIdOf(info);
|
|
541
|
+
|
|
542
|
+
async function submit(text) {
|
|
543
|
+
// pi-tui's Editor empties itself on Enter; a message that is refused or not confirmed is put back to be fixed.
|
|
544
|
+
const keep = () => screen.editor.getText() === "" && screen.editor.setText(text);
|
|
545
|
+
const parsed = parseChatInput(text);
|
|
546
|
+
if (parsed.kind === "empty") return;
|
|
547
|
+
if (parsed.refusal) {
|
|
548
|
+
keep();
|
|
549
|
+
return screen.setNotice(parsed.refusal, "warn");
|
|
550
|
+
}
|
|
551
|
+
const target = liveJobIdOf(info) ?? jobId;
|
|
552
|
+
const [name, body] = parsed.kind === "withdraw" ? ["steer-withdraw", { jobId: target, id: parsed.id }] : ["steer", { jobId: target, text: parsed.text, mode: parsed.kind }];
|
|
553
|
+
const lines = [];
|
|
554
|
+
let got;
|
|
555
|
+
try {
|
|
556
|
+
got = await client.act(name, body, { print: (l) => lines.push(l), confirm: () => screen.confirm(lines) });
|
|
557
|
+
} catch (e) {
|
|
558
|
+
keep();
|
|
559
|
+
return screen.setNotice(e?.message ?? "the console could not be asked", "bad");
|
|
560
|
+
}
|
|
561
|
+
if (got.refusal) {
|
|
562
|
+
keep();
|
|
563
|
+
return screen.setNotice(got.refusal, "bad");
|
|
564
|
+
}
|
|
565
|
+
if (got.declined) {
|
|
566
|
+
keep();
|
|
567
|
+
return screen.setNotice("not sent");
|
|
568
|
+
}
|
|
569
|
+
if (got.unchanged) return screen.setNotice(lines.join("\n"));
|
|
570
|
+
const r = got.result ?? {};
|
|
571
|
+
if (name === "steer-withdraw") return screen.setNotice(`withdrew message ${r.id ?? parsed.id}${r.guaranteed === true ? " (it was still queued: the run never sees it)" : " (best effort: the agent may already have read it)"}`, "accent");
|
|
572
|
+
screen.setNotice(`sent message ${r.id ?? "?"} as a ${parsed.kind === "followUp" ? "follow-up" : "steer"}${typeof r.state === "string" ? ` (${r.state})` : ""}; /withdraw ${r.id ?? "<id>"} takes it back`, "accent");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
screen.setStatus(statusText(info));
|
|
576
|
+
screen.setEditor(editorAvailability({ mode: "live", who, info }));
|
|
577
|
+
if (who?.role !== "operator") screen.setNotice("Waiting for the run to end; Ctrl+C detaches.");
|
|
578
|
+
screen.start();
|
|
579
|
+
|
|
580
|
+
poller = timers.setInterval(async () => {
|
|
581
|
+
const fresh = await requery();
|
|
582
|
+
if (finished || !fresh) return;
|
|
583
|
+
info = fresh;
|
|
584
|
+
screen.setStatus(statusText(info));
|
|
585
|
+
const keep = screen.editor.getText();
|
|
586
|
+
const was = screen.editorShown;
|
|
587
|
+
screen.setEditor(editorAvailability({ mode: "live", who, info }));
|
|
588
|
+
if (was && screen.editorShown) screen.editor.setText(keep);
|
|
589
|
+
// A viewer has no stream to say `done` (nor an operator whose stream failed or never existed): for them the poll
|
|
590
|
+
// is the way to learn the run is no longer live. A running stream is left to reach `done`, so nothing is cut short.
|
|
591
|
+
if (info.mode !== "live" && !streaming) finish({ ended: true, info });
|
|
592
|
+
}, pollMs);
|
|
593
|
+
|
|
594
|
+
if (who?.role === "operator" && jobId !== null && info?.transcript?.exists !== false) {
|
|
595
|
+
streaming = true;
|
|
596
|
+
const path = `/api/transcript?${new URLSearchParams({ id: jobId, format: "json" })}`;
|
|
597
|
+
client
|
|
598
|
+
.stream(path, {
|
|
599
|
+
signal: abort.signal,
|
|
600
|
+
onState: (s) => {
|
|
601
|
+
if (s.relogin) screen.setNotice("the console ended this sign-in; signing in again");
|
|
602
|
+
else if (s.waiting) screen.setNotice(`the stream dropped; reconnecting in ${Math.ceil(s.waiting / 1000)} s`);
|
|
603
|
+
},
|
|
604
|
+
onEvent: (ev) => {
|
|
605
|
+
if (ev.event === "oversize") return screen.addBlock("skipped", "An entry larger than 256 KiB was left out.");
|
|
606
|
+
if (ev.event !== "entry") return;
|
|
607
|
+
let parsed = null;
|
|
608
|
+
try {
|
|
609
|
+
parsed = JSON.parse(ev.data);
|
|
610
|
+
} catch {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
screen.addEntry(parsed);
|
|
614
|
+
},
|
|
615
|
+
})
|
|
616
|
+
.then(async (end) => {
|
|
617
|
+
if (end?.detached || finished) return;
|
|
618
|
+
const fresh = await requery();
|
|
619
|
+
finish({ ended: true, info: fresh ?? info, outcome: end?.done ?? null });
|
|
620
|
+
})
|
|
621
|
+
.catch((e) => {
|
|
622
|
+
// The poll is now the only way to learn the run ended.
|
|
623
|
+
streaming = false;
|
|
624
|
+
if (finished) return;
|
|
625
|
+
screen.setNotice(`${e?.message ?? "the transcript stream failed"}. The status line still updates; Ctrl+C detaches.`, "bad");
|
|
626
|
+
});
|
|
627
|
+
} else if (who?.role === "operator") {
|
|
628
|
+
screen.setNotice("No live transcript: not published (PI_LIVE_TRANSCRIPTS=0), expired, or not started yet. The status line still updates.");
|
|
629
|
+
}
|
|
630
|
+
return result;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The parked run's question: the last assistant entry of its JSON transcript (kept 24 hours after the run), read to the
|
|
635
|
+
* stream's `done` within `timeoutMs`. Returns `{question}` or `{pointer}` (a sentence naming the forge link) when the
|
|
636
|
+
* transcript cannot be read (a viewer, no stream, an expired one, nothing said).
|
|
637
|
+
*/
|
|
638
|
+
export async function questionOf({ client, info, who, timeoutMs = QUESTION_TIMEOUT_MS, timers = { setTimeout, clearTimeout } }) {
|
|
639
|
+
const link = typeof info?.forgeLink === "string" && /^https?:\/\//.test(info.forgeLink) ? scrubForTerminal(info.forgeLink, { maxBytes: 2048 }) : null;
|
|
640
|
+
const pointer = { pointer: link ? `The question is on ${link}` : "The question is in the run's last comment on its issue (no transcript to read it from)." };
|
|
641
|
+
const jobId = typeof info?.run?.jobId === "string" ? info.run.jobId : null;
|
|
642
|
+
if (who?.role !== "operator" || jobId === null || info?.transcript?.exists !== true) return pointer;
|
|
643
|
+
const entries = [];
|
|
644
|
+
const abort = new AbortController();
|
|
645
|
+
const timer = timers.setTimeout(() => abort.abort(), timeoutMs);
|
|
646
|
+
try {
|
|
647
|
+
await client.stream(`/api/transcript?${new URLSearchParams({ id: jobId, format: "json" })}`, {
|
|
648
|
+
signal: abort.signal,
|
|
649
|
+
onEvent: (ev) => {
|
|
650
|
+
if (ev.event !== "entry" || entries.length >= ENTRIES_MAX) return;
|
|
651
|
+
try {
|
|
652
|
+
entries.push(JSON.parse(ev.data));
|
|
653
|
+
} catch {
|
|
654
|
+
// not an entry
|
|
655
|
+
}
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
} catch {
|
|
659
|
+
return pointer;
|
|
660
|
+
} finally {
|
|
661
|
+
timers.clearTimeout(timer);
|
|
662
|
+
}
|
|
663
|
+
const text = lastAssistantText(entries);
|
|
664
|
+
return text === null ? pointer : { question: text };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The reply view: the question (as markdown) and a multi-line editor whose Enter sends the console's `parked-answer`
|
|
669
|
+
* action for the run's job, previewed and confirmed on screen. A refusal is printed as the console sent it, and the
|
|
670
|
+
* editor stays for another try. Resolves `{answered: true, result}` or `{detached: true}`.
|
|
671
|
+
*/
|
|
672
|
+
export async function runReplyView({ client, info, who, question, pi, terminal, theme = chatTheme(), onScreen = () => {}, proc = process }) {
|
|
673
|
+
let finished = false;
|
|
674
|
+
let resolveView;
|
|
675
|
+
const result = new Promise((resolve) => {
|
|
676
|
+
resolveView = resolve;
|
|
677
|
+
});
|
|
678
|
+
const finish = (value) => {
|
|
679
|
+
if (finished) return;
|
|
680
|
+
finished = true;
|
|
681
|
+
screen.stop();
|
|
682
|
+
resolveView(value);
|
|
683
|
+
};
|
|
684
|
+
const jobId = typeof info?.run?.jobId === "string" ? info.run.jobId : null;
|
|
685
|
+
const screen = makeChatScreen({ pi, terminal, theme, proc, onDetach: () => finish({ detached: true }), onSubmit: (text) => void submit(text) });
|
|
686
|
+
onScreen(screen);
|
|
687
|
+
|
|
688
|
+
async function submit(text) {
|
|
689
|
+
if (String(text).trim() === "") return;
|
|
690
|
+
// pi-tui's Editor empties itself on Enter; an answer that is refused or not confirmed is put back for another try.
|
|
691
|
+
const keep = () => screen.editor.getText() === "" && screen.editor.setText(text);
|
|
692
|
+
const lines = [];
|
|
693
|
+
let got;
|
|
694
|
+
try {
|
|
695
|
+
got = await client.act("parked-answer", { jobId, text }, { print: (l) => lines.push(l), confirm: () => screen.confirm(lines) });
|
|
696
|
+
} catch (e) {
|
|
697
|
+
keep();
|
|
698
|
+
return screen.setNotice(e?.message ?? "the console could not be asked", "bad");
|
|
699
|
+
}
|
|
700
|
+
if (got.refusal) {
|
|
701
|
+
keep();
|
|
702
|
+
return screen.setNotice(got.refusal, "bad");
|
|
703
|
+
}
|
|
704
|
+
if (got.declined) {
|
|
705
|
+
keep();
|
|
706
|
+
return screen.setNotice("not sent; edit the answer and press Enter again");
|
|
707
|
+
}
|
|
708
|
+
finish({ answered: true, result: got.result ?? {} });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
screen.setStatus(statusText(info));
|
|
712
|
+
const approval = info?.run?.reason === "awaiting-approval" ? info.run.approval : null;
|
|
713
|
+
if (approval) screen.addBlock("approval gate", `${approval.action}\n${approval.action === "budget-raise" ? "Worker-enforced: the declared cap remains subject to deployment and scoped budgets." : "Advisory: the worker does not enforce this external action."}`);
|
|
714
|
+
if (question?.question) screen.addBlock("the run asked", question.question, { markdown: true });
|
|
715
|
+
else screen.addBlock("the run asked", question?.pointer ?? "The question could not be read.");
|
|
716
|
+
screen.setEditor(editorAvailability({ mode: "reply", who, info }));
|
|
717
|
+
screen.setNotice(screen.editorShown ? approval ? "Type /approve, /decline [reason], or a question (no approval). Enter previews it; Ctrl+C leaves without deciding." : "Type the answer: Enter previews it, Shift+Enter starts a new line, Ctrl+C leaves without answering." : "Ctrl+C leaves.");
|
|
718
|
+
screen.start();
|
|
719
|
+
return result;
|
|
720
|
+
}
|