pi-background-tasks 0.3.0 → 0.6.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/PUBLISHING.md +6 -6
- package/README.md +25 -5
- package/TESTING.md +5 -2
- package/TEST_PLAN.md +5 -3
- package/package.json +1 -1
- package/src/core/common.ts +151 -0
- package/src/core/registry.ts +152 -19
- package/src/core/update-check.ts +99 -0
- package/src/extension.ts +61 -2
- package/src/ui/background-tasks-manager.ts +74 -12
package/PUBLISHING.md
CHANGED
|
@@ -32,8 +32,8 @@ npm publish --access public
|
|
|
32
32
|
Pi install smoke after publish:
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
pi -e npm:pi-background-tasks@0.
|
|
36
|
-
pi install npm:pi-background-tasks@0.
|
|
35
|
+
pi -e npm:pi-background-tasks@0.6.0 --offline --no-tools --no-session -p "/jobs"
|
|
36
|
+
pi install npm:pi-background-tasks@0.6.0
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
## Publish to git
|
|
@@ -48,15 +48,15 @@ git commit -m "Release pi-background-tasks"
|
|
|
48
48
|
git branch -M main
|
|
49
49
|
git remote add origin git@github.com:ismailsaleekh/pi-background-tasks.git
|
|
50
50
|
git push -u origin main
|
|
51
|
-
git tag v0.
|
|
52
|
-
git push origin v0.
|
|
51
|
+
git tag v0.6.0
|
|
52
|
+
git push origin v0.6.0
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
Pi install smoke after git tag:
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
|
-
pi -e git:github.com/ismailsaleekh/pi-background-tasks@v0.
|
|
59
|
-
pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.
|
|
58
|
+
pi -e git:github.com/ismailsaleekh/pi-background-tasks@v0.6.0 --offline --no-tools --no-session -p "/jobs"
|
|
59
|
+
pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.6.0
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
## pi.dev/packages
|
package/README.md
CHANGED
|
@@ -9,19 +9,19 @@ This package adds named, tracked background shell jobs with durable output files
|
|
|
9
9
|
From npm after publish:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pi install npm:pi-background-tasks@0.
|
|
12
|
+
pi install npm:pi-background-tasks@0.6.0
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
From git after pushing this package to its standalone repository and tagging:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.
|
|
18
|
+
pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.6.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
For project-local install:
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
pi install -l npm:pi-background-tasks@0.
|
|
24
|
+
pi install -l npm:pi-background-tasks@0.6.0
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
## Commands
|
|
@@ -32,6 +32,7 @@ pi install -l npm:pi-background-tasks@0.3.0
|
|
|
32
32
|
- `/kill <id>` — stop a running task.
|
|
33
33
|
- `/tasks` or `/bg-tasks` — fallback command to open the task manager UI.
|
|
34
34
|
- `/bg-clear` — clear finished background-task footer notices.
|
|
35
|
+
- `/bg-update` — print update instructions when a newer published version exists (instruct-only; never self-installs).
|
|
35
36
|
|
|
36
37
|
## Footer dock UX
|
|
37
38
|
|
|
@@ -45,6 +46,10 @@ bg 2 done · Shift↓ · /bg-clear
|
|
|
45
46
|
|
|
46
47
|
Press `Shift+Down` to open the focused bottom dock. Arrow keys are captured only while the dock is focused. Each task row shows the latest context-window usage reported by that specific background task, for example `ctx 21.0%/200k`; tasks that do not report their own context show `ctx —` rather than the parent Pi session's usage. Background Pi-agent tasks also surface the LLM model they ran (`model gpt-5.5` in the compact row, fully-qualified such as `openai-codex/gpt-5.5` in the detail view), cumulative token usage (`tok 1.6k`), and tool-use counts (`tools 2/1 failed`) in the dock and detail view; missing model/token/tool telemetry is omitted in rows and shown as “not reported by this background task” in details. When a background command is explicitly marked as an agent and invokes a print/json child agent such as `pi -p ...` through the normal shell command name, the extension wraps that child Pi process with `--mode json`, parses real assistant usage/tool execution events, and emits task-owned telemetry automatically — including the model reported by the child assistant turns.
|
|
47
48
|
|
|
49
|
+
### Agent activity transcript
|
|
50
|
+
|
|
51
|
+
For those wrapped background Pi agents, the task output file and the dock's detail **Output tail** show a live, human-readable transcript of what the agent is actually doing — assistant messages, `→ tool args` calls, `… reasoning`, and `✗ tool failed` errors — as the agent loop runs. The machine telemetry (context/token/tool/model) is parsed out of the child stream and surfaced only as the metrics above and in `bg_status`/metadata, so the focused window reflects the agent's real activity rather than its raw instrumentation JSON. Child stderr is passed through to the transcript verbatim. Non-agent tasks and agent commands that cannot be wrapped (for example a path-qualified `pi`) keep streaming their raw stdout/stderr unchanged.
|
|
52
|
+
|
|
48
53
|
Finished-task badges intentionally remain visible until acknowledged. The reliable clear path is `/bg-clear`, which works in every terminal and clears finished background-task footer notices without opening the dock. `Ctrl+Alt+C` is still registered as an optional terminal-dependent fallback shortcut, but the footer advertises `/bg-clear` because some macOS terminals do not transmit `Ctrl+Alt+C` distinctly.
|
|
49
54
|
|
|
50
55
|
Dock controls:
|
|
@@ -54,8 +59,8 @@ Dock controls:
|
|
|
54
59
|
| `Shift+Down` | Open focused background-task dock |
|
|
55
60
|
| `/bg-clear` | Clear finished-task footer notices from the main UI |
|
|
56
61
|
| `Ctrl+Alt+C` | Optional terminal-dependent shortcut for `/bg-clear` |
|
|
57
|
-
| `↑` / `↓` | Select task |
|
|
58
|
-
| `PageUp` / `PageDown` | Page task list |
|
|
62
|
+
| `↑` / `↓` | Select task (list) · scroll the output tail (detail) |
|
|
63
|
+
| `PageUp` / `PageDown` | Page the task list (list) · page the output tail (detail) |
|
|
59
64
|
| `Enter` / `→` | Inspect logs/details |
|
|
60
65
|
| `←` | Return from details to list |
|
|
61
66
|
| `h` | Toggle recent history |
|
|
@@ -65,6 +70,21 @@ Dock controls:
|
|
|
65
70
|
| `R` | Rerun selected command |
|
|
66
71
|
| `c` | Show copyable output path |
|
|
67
72
|
| `x` / `Esc` / `q` | Close dock |
|
|
73
|
+
| `/bg-update` | Print update instructions when a newer version is published |
|
|
74
|
+
|
|
75
|
+
In the detail view the **Output tail** is scrollable: `↑`/`↓` and `PageUp`/`PageDown` move through the loaded output (a generous bounded window, not just the last lines). Scrolling up pauses the live tail and freezes the view so it stays stable as new output arrives; the status line shows your position (e.g. `lines 46–57 of 60`). Scrolling back to the bottom (or pressing `r`) resumes live tailing.
|
|
76
|
+
|
|
77
|
+
### Update-available notice
|
|
78
|
+
|
|
79
|
+
When a newer version of `pi-background-tasks` has been published to npm, the footer appends a compact, instruct-only segment after the entry hint:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
bg 1 running · 1 failed · Shift↓ · /bg-clear · ⬆ v0.7.0 /bg-update
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When no tasks are active, the notice still appears on its own (`bg ⬆ v0.7.0 /bg-update`) so the update hint is visible. The segment is rendered only when a strictly newer published version exists; `/bg-update` prints the npm and git update commands and never installs or self-updates.
|
|
86
|
+
|
|
87
|
+
The lookup runs at most once per session on `session_start`, is time-boxed, and is fully offline-safe: it never blocks or errors the footer/session, and on an offline or failed lookup it simply renders no segment (it never pins a misleading version). The check is skipped entirely when `PI_OFFLINE=1`, and can be disabled explicitly with `PI_BG_DISABLE_UPDATE_CHECK=1`. Set `PI_BG_REGISTRY_URL` to point the check at a registry mirror instead of `https://registry.npmjs.org`.
|
|
68
88
|
|
|
69
89
|
## LLM tools
|
|
70
90
|
|
package/TESTING.md
CHANGED
|
@@ -71,9 +71,11 @@ Tests must not use the user's real `~/.pi/agent`.
|
|
|
71
71
|
Implemented coverage includes:
|
|
72
72
|
|
|
73
73
|
- tools: `bg_run`, `bg_status`, `bg_logs`, `bg_kill`, including required `isAgent` schema/runtime validation, unknown/ambiguous IDs, completed-kill failure, legacy no-name preparation, head/tail truncation, and notification on/off behavior
|
|
74
|
-
- commands: `/bg`, `/jobs`, `/logs`, `/kill`, `/tasks`, `/bg-tasks`, `/bg-clear` discovery, happy paths, `/bg --agent` parsing, finished-notice clearing, malformed `/bg`, unknown/ambiguous IDs, completed-task `/kill`, byte-limit normalization, and RPC no-hang fallback behavior
|
|
75
|
-
-
|
|
74
|
+
- commands: `/bg`, `/jobs`, `/logs`, `/kill`, `/tasks`, `/bg-tasks`, `/bg-clear`, `/bg-update` discovery, happy paths, `/bg --agent` parsing, finished-notice clearing, malformed `/bg`, unknown/ambiguous IDs, completed-task `/kill`, byte-limit normalization, and RPC no-hang fallback behavior
|
|
75
|
+
- update-available notice: semver parse/compare/precedence, `formatUpdateSegment`, npm/`package.json` payload narrowing, and injected-fetch success/404/throw/timeout (unit); localhost-registry footer segment (idle + appended to an active footer), `/bg-update` non-installing instructions, and opt-out/offline/already-current/registry-failure no-segment-and-no-throw paths (SDK); `/bg-update` discovery and offline instructions (RPC). The check is one-shot on `session_start`, time-boxed, offline-safe, gated by `PI_OFFLINE`/`PI_BG_DISABLE_UPDATE_CHECK`, and `PI_BG_REGISTRY_URL` overrides the registry endpoint
|
|
76
|
+
- shortcut/UI: component coverage for focused dock list/detail/key handling, detail output-tail scrolling (arrow/page scroll, follow-pause-on-scroll, `lines X–Y of N` position indicator, resume-follow-at-bottom, and no-scroll when output fits), empty/history/unread states, paging, close aliases, stop/stop-all/rerun/path actions, missing output files; SDK coverage for explicit `/bg-clear` finished-notice clearing, `/bg-clear` footer hinting, optional `Ctrl+Alt+C` fallback shortcut registration, and mixed failed/stopped/done/focused footer status; RPC coverage that `/bg-clear` works as a terminal-independent clear path; and PTY coverage for `/tasks`, `/bg-tasks`, real `Shift+Down`, arrows, page keys, detail/back/history/stop/stop-all/rerun/path/close, failed unread badges, and running/completed/failed/killed rerun paths
|
|
76
77
|
- runtime files: output and metadata files under `.pi/tasks/`, persisted `isAgent` classification, task-owned context-window telemetry snapshots, cumulative background Pi-agent token usage, tool-use counts, agent model identifier (preferring the fully-qualified `provider/model` form), explicit `isAgent:true` telemetry wrapping for background `pi` agents, `isAgent:false` non-wrapping for scripts, real child `pi --mode json` tool-event parsing, split/large telemetry ingestion, metadata after completion/failure, local tarball install contents
|
|
78
|
+
- agent activity transcript: pure `parseAgentActivity`/`formatAgentActivityLine` coverage (assistant text, reasoning, tool start with arg summary, silent successful tool end, `✗ tool failed` errors, truncation, invalid/non-activity narrowing); registry-unit coverage that wrapped-agent stdout is reconstructed across split chunks into the human-readable transcript while telemetry/activity control JSON is stripped from the output file (telemetry fields still updated), stderr passes through, and the trailing partial line is flushed on finalize; SDK coverage that fake and real child `pi --mode json` runs surface `→ tool`/`✗ tool failed`/assistant text in `bg_logs` with no control JSON leaking into the visible output
|
|
77
79
|
- safety: kill, already-finished kill failure, timeout failure, spawn failure, low output-cap failure, multi-task shutdown cleanup, process-group kill fallback, Windows child-kill behavior, SIGKILL escalation, duplicate finalization/notification races, metadata/notification failure handling, and pruning
|
|
78
80
|
- agent loop: deterministic scripted-provider coverage for actual `bg_run` completion follow-up turns, `/bg` display-only behavior, `notifyOnCompletion:false`, and failed-task notification error fields
|
|
79
81
|
- package: manifest, docs, `pi.extensions`, peer dependency/import parity, packed runtime files, tarball-install smoke, and artifact exclusion
|
|
@@ -85,6 +87,7 @@ Implemented coverage includes:
|
|
|
85
87
|
- `/tasks` and `/bg-tasks` open the focused dock and close with `x`.
|
|
86
88
|
- A named `/bg` task appears in the dock when opened with xterm `Shift+Down` (`ESC [ 1 ; 2 B`).
|
|
87
89
|
- Secondary dock keys work in a real TUI: arrows, page keys, detail/back, history, stop selected, stop-all confirmation, rerun, output path, and failed/unread history surfacing.
|
|
90
|
+
- Detail output-tail scrolling works with real arrow/page keys: opening a 60-line task's detail and pressing `↑` shows the `lines X–Y of N` position indicator and pauses the live tail.
|
|
88
91
|
|
|
89
92
|
The detail-view `Model:` line and the compact `model <id>` dock row are also exercised deterministically by the component layer (`tests/component/background-tasks-manager.test.ts`), which is the lowest reliable layer for dock rendering.
|
|
90
93
|
|
package/TEST_PLAN.md
CHANGED
|
@@ -12,7 +12,7 @@ This package follows:
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| Package | `pi-background-tasks` |
|
|
14
14
|
| Extension entrypoint | `extensions/background-tasks.ts` |
|
|
15
|
-
| Public commands | `/bg`, `/jobs`, `/logs`, `/kill`, `/tasks`, `/bg-tasks`, `/bg-clear` |
|
|
15
|
+
| Public commands | `/bg`, `/jobs`, `/logs`, `/kill`, `/tasks`, `/bg-tasks`, `/bg-clear`, `/bg-update` |
|
|
16
16
|
| Public tools | `bg_run`, `bg_status`, `bg_logs`, `bg_kill` |
|
|
17
17
|
| Shortcuts | `Shift+Down`; optional fallback `Ctrl+Alt+C` |
|
|
18
18
|
| Custom UI | footer status + focused bottom dock overlay |
|
|
@@ -54,20 +54,22 @@ This package follows:
|
|
|
54
54
|
| Per-task token usage | background Pi-agent telemetry + `bg_status`/metadata/dock row/detail | yes | yes | | yes | | | | SDK verifies cumulative input/output/cache read/cache write/total token usage from explicit telemetry, fake `isAgent:true` wrapped child events, and real child `pi --mode json` with scripted provider; component verifies row/detail rendering. |
|
|
55
55
|
| Per-task tool-use counts | background Pi-agent telemetry + `bg_status`/metadata/dock row/detail | yes | yes | | yes | | | | SDK verifies total/failed/by-name tool counts from fake and real child Pi `tool_execution_start/end` events, including failed tools; component verifies row/detail rendering. |
|
|
56
56
|
| Per-task agent model | background Pi-agent telemetry + `bg_status`/metadata/dock row/detail | yes | yes | | yes | | | | Unit verifies `formatModelSummary`/snapshot-list rendering and telemetry ingestion of `model`; SDK verifies the model is captured from explicit telemetry, fake `isAgent:true` wrapped child `message_end` events (qualified `provider/model`), and a real child `pi --mode json` run (bare child model re-qualified from `--model`), plus that non-agent tasks report no model; component verifies compact `model <id>` row, fully-qualified `Model:` detail, and the “not reported by this background task” placeholder. |
|
|
57
|
+
| Agent activity transcript | wrapped Pi-agent output file + dock detail tail + `bg_logs` | yes | yes | | | | | | Unit covers `parseAgentActivity`/`formatAgentActivityLine` for assistant text, reasoning, tool start (arrow + collapsed/truncated arg summary), and tool end (silent success, `✗ tool failed[: error]`), plus blank/invalid/non-activity narrowing. Registry unit verifies wrapped-agent stdout is reconstructed across split chunks into the transcript, `background-task-telemetry`/`-context-usage`/`-activity` control lines are stripped from the output file while still updating telemetry fields, child stderr passes through verbatim, and the trailing partial line is flushed on finalize. SDK verifies fake and real child `pi --mode json` runs render `→ tool`, `✗ tool failed`, and assistant text in `bg_logs` while keeping the telemetry/activity control JSON out of the visible output. |
|
|
57
58
|
| Focused dock list | overlay component | | | | yes | yes | | | Selection/actions/history tested; PTY covers arrows, page keys, ordering with multiple tasks, failed/unread badges, and `/bg-tasks` fallback. |
|
|
58
|
-
| Focused dock detail | overlay component | | | | yes |
|
|
59
|
+
| Focused dock detail | overlay component | | | | yes | yes | | | Tail read, output box, and return-to-list tested. Component also verifies output-tail scrolling: ↑/↓ + PageUp/PageDown move through the loaded window, scrolling up pauses the live tail and shows a `lines X–Y of N` position, paging back to the bottom resumes follow, and output that fits the window never enters scroll mode. PTY drives the real arrow/page scroll keys in the detail tail. |
|
|
59
60
|
| Dock stop selected | `k` | | | | yes | | | | Component. |
|
|
60
61
|
| Dock stop all | `a`/`K` | | | | yes | yes | | | Component and PTY confirmation. |
|
|
61
62
|
| Dock rerun | `R` | | | | yes | yes | | | Component plus PTY running/completed/failed/killed rerun paths. |
|
|
62
63
|
| Dock close | `x`/`Esc`/`q` | | | | yes | yes | | | Component + PTY. |
|
|
63
64
|
| Shortcut opens dock | `Shift+Down` | | registration | | | yes | | | PTY sends xterm `ESC [ 1 ; 2 B`. |
|
|
64
65
|
| Clear finished notices | `/bg-clear`, optional `Ctrl+Alt+C` fallback | | yes | yes | | | | | `/bg-clear` is the canonical terminal-independent path and is advertised in the footer. SDK invokes the slash-command handler and verifies fallback shortcut registration; RPC verifies `/bg-clear` clears finished notices; finished notices remain until explicit clear. |
|
|
66
|
+
| Update-available footer notice | `⬆ v<latest> /bg-update` footer segment + `/bg-update` command | yes | yes | yes | | | | | Unit covers semver parse/compare/precedence, `isNewerVersion`, `formatUpdateSegment`, npm/`package.json` payload narrowing, injected-fetch success/404/throw/timeout, and `package.json` read/degrade. SDK uses a localhost registry to verify the idle and append-to-active footer segment, `/bg-update` non-installing instructions, and that opt-out (`PI_BG_DISABLE_UPDATE_CHECK=1`), offline (`PI_OFFLINE=1`), already-current, and registry-failure paths render no segment and never throw. RPC verifies `/bg-update` discovery and offline non-installing instructions. The check is one-shot per `session_start`, time-boxed, offline-safe, and never runs on the status tick. |
|
|
65
67
|
| Runtime output files | `.pi/tasks/...output` | yes | yes | | | | | | SDK asserts existence. |
|
|
66
68
|
| Runtime metadata files | `.pi/tasks/...json` | yes | yes | | | | | | SDK asserts shape/status/name/context usage; registry unit tests cover metadata failure/update ordering. |
|
|
67
69
|
| Timeout kills task | `timeoutSeconds` | | yes | | | | | | SDK. |
|
|
68
70
|
| Output cap kills task | `PI_BG_MAX_OUTPUT_BYTES` | | | yes | | | | | RPC runs with a low cap and asserts failed status/log notice. |
|
|
69
71
|
| Shutdown cleanup | `session_shutdown` | yes | yes | | | | | | SDK asserts multiple running tasks become killed; registry tests cover shared stop/wait behavior. |
|
|
70
|
-
| Process lifecycle/races | registry core | yes | yes | yes | | yes | | yes | Unit tests cover process-group fallback, Windows fallback, SIGKILL escalation, duplicate finalization/notification races, notification/metadata failures, pruning, malformed telemetry, split telemetry chunks,
|
|
72
|
+
| Process lifecycle/races | registry core | yes | yes | yes | | yes | | yes | Unit tests cover process-group fallback, Windows fallback, SIGKILL escalation, duplicate finalization/notification races, notification/metadata failures, pruning, malformed telemetry, split telemetry chunks, large telemetry records above the old 16KiB buffer, and wrapped-agent transcript/telemetry separation with split-chunk and trailing-partial flush; SDK/RPC cover runtime spawn/timeout/output-cap/shutdown; scripted provider covers wakeup integration. |
|
|
71
73
|
| Package manifest | `package.json` | | | | | | yes | | Keywords, `pi.extensions`, files. |
|
|
72
74
|
| Pack contents | `npm pack --dry-run` | | | | | | yes | | Runtime files included. |
|
|
73
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-background-tasks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Claude-Code-like named background shell task manager for Pi: bg_run tools, /bg commands, Shift+Down footer dock, bounded logs, kill/timeout safety, and completion wakeups.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "ISC",
|
package/src/core/common.ts
CHANGED
|
@@ -64,6 +64,10 @@ export type BgTask = Omit<BgTaskSnapshot, "name"> & {
|
|
|
64
64
|
capExceeded?: boolean | undefined;
|
|
65
65
|
finalized?: boolean | undefined;
|
|
66
66
|
contextUsageBuffer?: string | undefined;
|
|
67
|
+
/** True when this task launched a telemetry-wrapped Pi agent; its stdout carries control lines, not raw output. */
|
|
68
|
+
telemetryWrapped?: boolean | undefined;
|
|
69
|
+
/** Partial trailing stdout line held between chunks while reconstructing wrapped-agent control lines. */
|
|
70
|
+
agentStdoutBuffer?: string | undefined;
|
|
67
71
|
waiters: Array<() => void>;
|
|
68
72
|
};
|
|
69
73
|
|
|
@@ -299,6 +303,81 @@ export function formatModelSummary(model?: string): string | undefined {
|
|
|
299
303
|
return `model=${model}`;
|
|
300
304
|
}
|
|
301
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Human-readable activity transcript for telemetry-wrapped Pi agents.
|
|
308
|
+
*
|
|
309
|
+
* The wrapper emits one `background-task-activity` control line per meaningful
|
|
310
|
+
* child-agent event (assistant text, reasoning, tool start, tool end) so the
|
|
311
|
+
* registry can render "what the agent is actually doing" into the task output
|
|
312
|
+
* file instead of leaking raw telemetry JSON. Both the parser and the formatter
|
|
313
|
+
* are pure so the visible transcript is fully unit-testable.
|
|
314
|
+
*/
|
|
315
|
+
export const AGENT_ACTIVITY_TYPE = "background-task-activity";
|
|
316
|
+
const AGENT_ACTIVITY_DETAIL_MAX = 80;
|
|
317
|
+
|
|
318
|
+
export type AgentActivity =
|
|
319
|
+
| { kind: "assistant_text"; text: string }
|
|
320
|
+
| { kind: "reasoning"; text: string }
|
|
321
|
+
| { kind: "tool_start"; tool: string; argsSummary: string }
|
|
322
|
+
| { kind: "tool_end"; tool: string; isError: boolean; error?: string };
|
|
323
|
+
|
|
324
|
+
function readActivityString(record: Record<string, unknown>, key: string): string | undefined {
|
|
325
|
+
const value = record[key];
|
|
326
|
+
return typeof value === "string" ? value : undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Narrow a parsed `background-task-activity` control payload into a typed {@link AgentActivity}. */
|
|
330
|
+
export function parseAgentActivity(payload: unknown): AgentActivity | undefined {
|
|
331
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
332
|
+
const record = payload as Record<string, unknown>;
|
|
333
|
+
if (record["type"] !== AGENT_ACTIVITY_TYPE) return undefined;
|
|
334
|
+
const kind = record["kind"];
|
|
335
|
+
if (kind === "assistant_text" || kind === "reasoning") {
|
|
336
|
+
const text = readActivityString(record, "text");
|
|
337
|
+
if (text === undefined) return undefined;
|
|
338
|
+
return { kind, text };
|
|
339
|
+
}
|
|
340
|
+
if (kind === "tool_start") {
|
|
341
|
+
const tool = readActivityString(record, "tool");
|
|
342
|
+
if (!tool) return undefined;
|
|
343
|
+
return { kind, tool, argsSummary: readActivityString(record, "argsSummary") ?? "" };
|
|
344
|
+
}
|
|
345
|
+
if (kind === "tool_end") {
|
|
346
|
+
const tool = readActivityString(record, "tool");
|
|
347
|
+
if (!tool) return undefined;
|
|
348
|
+
const activity: AgentActivity = { kind, tool, isError: record["isError"] === true };
|
|
349
|
+
const error = readActivityString(record, "error");
|
|
350
|
+
if (error !== undefined && error.trim().length > 0) activity.error = error;
|
|
351
|
+
return activity;
|
|
352
|
+
}
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Render an {@link AgentActivity} into a single transcript line, or `undefined`
|
|
358
|
+
* when the event carries nothing worth showing (blank text, a successful tool
|
|
359
|
+
* end). Successful tool ends are intentionally silent: the matching `→` start
|
|
360
|
+
* line already announced the call, and the next line implies completion.
|
|
361
|
+
*/
|
|
362
|
+
export function formatAgentActivityLine(activity: AgentActivity): string | undefined {
|
|
363
|
+
if (activity.kind === "assistant_text") {
|
|
364
|
+
const text = activity.text.replace(/\s+$/u, "");
|
|
365
|
+
return text.trim().length > 0 ? text : undefined;
|
|
366
|
+
}
|
|
367
|
+
if (activity.kind === "reasoning") {
|
|
368
|
+
const text = activity.text.replace(/\s+$/u, "");
|
|
369
|
+
return text.trim().length > 0 ? `\u2026 ${text}` : undefined;
|
|
370
|
+
}
|
|
371
|
+
if (activity.kind === "tool_start") {
|
|
372
|
+
const summary = compactWhitespace(activity.argsSummary);
|
|
373
|
+
const suffix = summary.length > 0 ? ` ${truncateChars(summary, AGENT_ACTIVITY_DETAIL_MAX)}` : "";
|
|
374
|
+
return `\u2192 ${activity.tool}${suffix}`;
|
|
375
|
+
}
|
|
376
|
+
if (!activity.isError) return undefined;
|
|
377
|
+
const detail = activity.error ? `: ${truncateChars(compactWhitespace(activity.error), AGENT_ACTIVITY_DETAIL_MAX)}` : "";
|
|
378
|
+
return `\u2717 ${activity.tool} failed${detail}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
302
381
|
export function shellQuote(value: string): string {
|
|
303
382
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
304
383
|
}
|
|
@@ -395,3 +474,75 @@ export async function boundedRead(
|
|
|
395
474
|
export function escapeXml(value: string): string {
|
|
396
475
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
397
476
|
}
|
|
477
|
+
|
|
478
|
+
export const UPDATE_COMMAND = "/bg-update";
|
|
479
|
+
|
|
480
|
+
type ParsedSemver = {
|
|
481
|
+
major: number;
|
|
482
|
+
minor: number;
|
|
483
|
+
patch: number;
|
|
484
|
+
prerelease: string[];
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
488
|
+
|
|
489
|
+
export function parseSemver(value: string): ParsedSemver | undefined {
|
|
490
|
+
if (typeof value !== "string") return undefined;
|
|
491
|
+
const match = value.trim().match(SEMVER_PATTERN);
|
|
492
|
+
if (!match) return undefined;
|
|
493
|
+
const major = Number(match[1]);
|
|
494
|
+
const minor = Number(match[2]);
|
|
495
|
+
const patch = Number(match[3]);
|
|
496
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor) || !Number.isInteger(patch)) return undefined;
|
|
497
|
+
const prerelease = match[4] ? match[4].split(".") : [];
|
|
498
|
+
return { major, minor, patch, prerelease };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function comparePrerelease(a: string[], b: string[]): number {
|
|
502
|
+
if (a.length === 0 && b.length === 0) return 0;
|
|
503
|
+
// A version without prerelease identifiers outranks the same core with prerelease identifiers.
|
|
504
|
+
if (a.length === 0) return 1;
|
|
505
|
+
if (b.length === 0) return -1;
|
|
506
|
+
const shared = Math.min(a.length, b.length);
|
|
507
|
+
for (let i = 0; i < shared; i++) {
|
|
508
|
+
const idA = a[i];
|
|
509
|
+
const idB = b[i];
|
|
510
|
+
if (idA === undefined || idB === undefined) break;
|
|
511
|
+
if (idA === idB) continue;
|
|
512
|
+
const numericA = /^\d+$/.test(idA);
|
|
513
|
+
const numericB = /^\d+$/.test(idB);
|
|
514
|
+
if (numericA && numericB) {
|
|
515
|
+
const diff = Number(idA) - Number(idB);
|
|
516
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
// Numeric identifiers always have lower precedence than non-numeric identifiers.
|
|
520
|
+
if (numericA) return -1;
|
|
521
|
+
if (numericB) return 1;
|
|
522
|
+
return idA < idB ? -1 : 1;
|
|
523
|
+
}
|
|
524
|
+
if (a.length === b.length) return 0;
|
|
525
|
+
return a.length < b.length ? -1 : 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Compare two semver strings. Returns -1/0/1, or undefined when either side is not valid semver. */
|
|
529
|
+
export function compareSemver(a: string, b: string): number | undefined {
|
|
530
|
+
const left = parseSemver(a);
|
|
531
|
+
const right = parseSemver(b);
|
|
532
|
+
if (!left || !right) return undefined;
|
|
533
|
+
if (left.major !== right.major) return left.major < right.major ? -1 : 1;
|
|
534
|
+
if (left.minor !== right.minor) return left.minor < right.minor ? -1 : 1;
|
|
535
|
+
if (left.patch !== right.patch) return left.patch < right.patch ? -1 : 1;
|
|
536
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function isNewerVersion(latest: string, current: string): boolean {
|
|
540
|
+
return compareSemver(latest, current) === 1;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Footer segment shown only when a newer published version exists; undefined otherwise. */
|
|
544
|
+
export function formatUpdateSegment(latest: string | undefined, current: string): string | undefined {
|
|
545
|
+
if (!latest) return undefined;
|
|
546
|
+
if (!isNewerVersion(latest, current)) return undefined;
|
|
547
|
+
return `\u2b06 v${latest} ${UPDATE_COMMAND}`;
|
|
548
|
+
}
|
package/src/core/registry.ts
CHANGED
|
@@ -9,8 +9,10 @@ import {
|
|
|
9
9
|
boundedRead,
|
|
10
10
|
deriveTaskNameFromCommand,
|
|
11
11
|
escapeXml,
|
|
12
|
+
formatAgentActivityLine,
|
|
12
13
|
formatDuration,
|
|
13
14
|
normalizeTaskName,
|
|
15
|
+
parseAgentActivity,
|
|
14
16
|
sanitizePathSegment,
|
|
15
17
|
shellInvocation,
|
|
16
18
|
shellQuote,
|
|
@@ -221,6 +223,36 @@ function emitUnifiedTelemetry(payload) {
|
|
|
221
223
|
process.stdout.write(JSON.stringify(out) + "\\n");
|
|
222
224
|
}
|
|
223
225
|
|
|
226
|
+
function emitActivity(activity) {
|
|
227
|
+
process.stdout.write(JSON.stringify({ type: "background-task-activity", ...activity }) + "\\n");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function summarizeArgs(args) {
|
|
231
|
+
if (!args || typeof args !== "object") return "";
|
|
232
|
+
const pick = (value) => {
|
|
233
|
+
if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
|
|
234
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
235
|
+
return undefined;
|
|
236
|
+
};
|
|
237
|
+
const preferred = ["path", "file_path", "file", "filename", "command", "cmd", "pattern", "query", "url", "name", "value", "text", "message"];
|
|
238
|
+
for (const key of preferred) { const summary = pick(args[key]); if (summary) return summary; }
|
|
239
|
+
for (const key of Object.keys(args)) { const summary = pick(args[key]); if (summary) return summary; }
|
|
240
|
+
return "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function emitAssistantActivity(message) {
|
|
244
|
+
const content = message && Array.isArray(message.content) ? message.content : [];
|
|
245
|
+
for (const part of content) {
|
|
246
|
+
if (!part || typeof part !== "object") continue;
|
|
247
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
248
|
+
emitActivity({ kind: "assistant_text", text: part.text });
|
|
249
|
+
} else if (part.type === "thinking" || part.type === "reasoning") {
|
|
250
|
+
const text = typeof part.text === "string" ? part.text : (typeof part.thinking === "string" ? part.thinking : "");
|
|
251
|
+
if (text.trim()) emitActivity({ kind: "reasoning", text: text });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
|
|
225
257
|
const message = fromMessage ? String(fromMessage) : "";
|
|
226
258
|
const args = fromArgs ? String(fromArgs) : "";
|
|
@@ -320,7 +352,6 @@ function emitToolTelemetry() {
|
|
|
320
352
|
const parsed = parseInvocation(process.argv.slice(2));
|
|
321
353
|
const child = spawn("pi", parsed.args, { stdio: ["ignore", "pipe", "pipe"], env: process.env });
|
|
322
354
|
let buffer = "";
|
|
323
|
-
let finalText = "";
|
|
324
355
|
|
|
325
356
|
if (!parsed.parseJson) {
|
|
326
357
|
child.stdout.pipe(process.stdout);
|
|
@@ -338,7 +369,6 @@ child.on("error", (error) => {
|
|
|
338
369
|
});
|
|
339
370
|
child.on("close", (code, signal) => {
|
|
340
371
|
if (parsed.parseJson && buffer.trim()) processLine(buffer);
|
|
341
|
-
if (finalText) process.stdout.write(finalText.endsWith("\\n") ? finalText : finalText + "\\n");
|
|
342
372
|
if (signal) process.kill(process.pid, signal);
|
|
343
373
|
process.exit(code ?? 0);
|
|
344
374
|
});
|
|
@@ -353,22 +383,23 @@ function processLine(line) {
|
|
|
353
383
|
return;
|
|
354
384
|
}
|
|
355
385
|
if (event.type === "tool_execution_start") {
|
|
356
|
-
|
|
386
|
+
const toolName = event.toolName || event.tool_name || "tool";
|
|
387
|
+
markToolStarted(event.toolCallId || event.tool_call_id, toolName);
|
|
388
|
+
emitActivity({ kind: "tool_start", tool: String(toolName), argsSummary: summarizeArgs(event.args || event.arguments || event.input || event.parameters) });
|
|
357
389
|
emitToolTelemetry();
|
|
358
390
|
return;
|
|
359
391
|
}
|
|
360
392
|
if (event.type === "tool_execution_end") {
|
|
393
|
+
const toolName = event.toolName || event.tool_name || "tool";
|
|
361
394
|
if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
|
|
395
|
+
emitActivity({ kind: "tool_end", tool: String(toolName), isError: !!event.isError, error: typeof event.error === "string" ? event.error : undefined });
|
|
362
396
|
emitToolTelemetry();
|
|
363
397
|
return;
|
|
364
398
|
}
|
|
365
399
|
if (event.type === "message_end" && event.message && event.message.role === "assistant") {
|
|
400
|
+
emitAssistantActivity(event.message);
|
|
366
401
|
countToolCallsFromMessage(event.message);
|
|
367
402
|
emitMessageTelemetry(event.message, parsed.model, parsed.provider);
|
|
368
|
-
finalText = (event.message.content || [])
|
|
369
|
-
.filter((part) => part && part.type === "text" && part.text)
|
|
370
|
-
.map((part) => part.text)
|
|
371
|
-
.join("\\n");
|
|
372
403
|
}
|
|
373
404
|
}
|
|
374
405
|
`;
|
|
@@ -458,6 +489,13 @@ function normalizeToolUsage(value: unknown): TaskToolUsage | undefined {
|
|
|
458
489
|
return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
|
|
459
490
|
}
|
|
460
491
|
|
|
492
|
+
type TelemetryDelta = {
|
|
493
|
+
context?: TaskContextUsage | undefined;
|
|
494
|
+
tokens?: TaskTokenUsage | undefined;
|
|
495
|
+
tools?: TaskToolUsage | undefined;
|
|
496
|
+
model?: string | undefined;
|
|
497
|
+
};
|
|
498
|
+
|
|
461
499
|
export class BackgroundTaskRegistry {
|
|
462
500
|
private readonly tasks = new Map<string, BgTask>();
|
|
463
501
|
private runtimeDir: RuntimeDir | undefined;
|
|
@@ -585,6 +623,7 @@ export class BackgroundTaskRegistry {
|
|
|
585
623
|
const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
|
|
586
624
|
await writeFile(wrapperAbsPath, createPiTelemetryWrapperSource(buildModelWindowIndex(ctx)), "utf8");
|
|
587
625
|
commandToSpawn = `pi() { node ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
|
|
626
|
+
task.telemetryWrapped = true;
|
|
588
627
|
}
|
|
589
628
|
const invocation = shellInvocation(commandToSpawn, this.platform, this.env);
|
|
590
629
|
const child = this.spawn(invocation.shell, invocation.args, {
|
|
@@ -598,11 +637,11 @@ export class BackgroundTaskRegistry {
|
|
|
598
637
|
task.child = child;
|
|
599
638
|
task.pid = child.pid;
|
|
600
639
|
|
|
601
|
-
child.stdout?.on("data", (data) => this.
|
|
602
|
-
child.stderr?.on("data", (data) => this.
|
|
640
|
+
child.stdout?.on("data", (data) => this.appendChildOutput(task, data, "stdout"));
|
|
641
|
+
child.stderr?.on("data", (data) => this.appendChildOutput(task, data, "stderr"));
|
|
603
642
|
|
|
604
643
|
child.on("error", (error) => {
|
|
605
|
-
this.
|
|
644
|
+
this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
|
|
606
645
|
void this.finalizeTask(task, "failed", null, undefined, error.message);
|
|
607
646
|
});
|
|
608
647
|
|
|
@@ -631,7 +670,7 @@ export class BackgroundTaskRegistry {
|
|
|
631
670
|
if (task.status !== "running") return;
|
|
632
671
|
task.killKind = "timeout";
|
|
633
672
|
task.error = `Timed out after ${timeoutSeconds}s`;
|
|
634
|
-
this.
|
|
673
|
+
this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
|
|
635
674
|
try {
|
|
636
675
|
this.requestKill(task, "SIGTERM");
|
|
637
676
|
} catch (error) {
|
|
@@ -645,7 +684,7 @@ export class BackgroundTaskRegistry {
|
|
|
645
684
|
return task;
|
|
646
685
|
} catch (error) {
|
|
647
686
|
const message = error instanceof Error ? error.message : String(error);
|
|
648
|
-
this.
|
|
687
|
+
this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
|
|
649
688
|
await this.finalizeTask(task, "failed", null, undefined, message);
|
|
650
689
|
throw new Error(`Failed to start background task: ${message}`);
|
|
651
690
|
}
|
|
@@ -760,11 +799,16 @@ export class BackgroundTaskRegistry {
|
|
|
760
799
|
if (lastXmlOpen > lastXmlClose) retained = telemetryText.slice(lastXmlOpen);
|
|
761
800
|
task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
|
|
762
801
|
|
|
802
|
+
this.commitTelemetry(task, { context: latestContext, tokens: latestTokens, tools: latestTools, model: latestModel });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
|
|
806
|
+
private commitTelemetry(task: BgTask, next: TelemetryDelta): void {
|
|
763
807
|
const before = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
|
|
764
|
-
task.contextUsage =
|
|
765
|
-
task.tokenUsage =
|
|
766
|
-
task.toolUsage =
|
|
767
|
-
task.model =
|
|
808
|
+
if (next.context !== undefined) task.contextUsage = next.context;
|
|
809
|
+
if (next.tokens !== undefined) task.tokenUsage = next.tokens;
|
|
810
|
+
if (next.tools !== undefined) task.toolUsage = next.tools;
|
|
811
|
+
if (next.model !== undefined) task.model = next.model;
|
|
768
812
|
const after = JSON.stringify({ contextUsage: task.contextUsage, tokenUsage: task.tokenUsage, toolUsage: task.toolUsage, model: task.model });
|
|
769
813
|
if (before !== after) {
|
|
770
814
|
this.onChange();
|
|
@@ -774,11 +818,10 @@ export class BackgroundTaskRegistry {
|
|
|
774
818
|
}
|
|
775
819
|
}
|
|
776
820
|
|
|
777
|
-
|
|
821
|
+
/** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
|
|
822
|
+
private writeToStream(task: BgTask, buffer: Buffer): void {
|
|
778
823
|
if (!task.stream || task.stream.destroyed) return;
|
|
779
|
-
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
|
|
780
824
|
if (buffer.length === 0) return;
|
|
781
|
-
this.ingestTelemetry(task, buffer.toString("utf8"));
|
|
782
825
|
|
|
783
826
|
const nextBytes = task.bytesWritten + buffer.length;
|
|
784
827
|
if (nextBytes <= this.maxOutputBytes) {
|
|
@@ -809,6 +852,95 @@ export class BackgroundTaskRegistry {
|
|
|
809
852
|
}
|
|
810
853
|
}
|
|
811
854
|
|
|
855
|
+
/** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
|
|
856
|
+
private writeNotice(task: BgTask, text: string): void {
|
|
857
|
+
if (!text) return;
|
|
858
|
+
this.writeToStream(task, Buffer.from(text, "utf8"));
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
private appendChildOutput(task: BgTask, data: Buffer | string, source: "stdout" | "stderr"): void {
|
|
862
|
+
if (!task.stream || task.stream.destroyed) return;
|
|
863
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, "utf8");
|
|
864
|
+
if (buffer.length === 0) return;
|
|
865
|
+
if (task.telemetryWrapped) {
|
|
866
|
+
// Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
|
|
867
|
+
// stderr is raw diagnostics and is always passed through to the transcript verbatim.
|
|
868
|
+
if (source === "stdout") this.processAgentStdout(task, buffer.toString("utf8"));
|
|
869
|
+
else this.writeToStream(task, buffer);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
this.ingestTelemetry(task, buffer.toString("utf8"));
|
|
873
|
+
this.writeToStream(task, buffer);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
|
|
877
|
+
private processAgentStdout(task: BgTask, text: string): void {
|
|
878
|
+
const buffered = `${task.agentStdoutBuffer ?? ""}${text}`;
|
|
879
|
+
const lastNewline = buffered.lastIndexOf("\n");
|
|
880
|
+
task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
|
|
881
|
+
if (lastNewline < 0) return;
|
|
882
|
+
const latest: TelemetryDelta = {};
|
|
883
|
+
for (const line of buffered.slice(0, lastNewline).split("\n")) this.consumeAgentLine(task, line, latest);
|
|
884
|
+
this.commitTelemetry(task, latest);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
|
|
888
|
+
private flushAgentStdout(task: BgTask): void {
|
|
889
|
+
const remainder = task.agentStdoutBuffer;
|
|
890
|
+
if (!remainder) return;
|
|
891
|
+
task.agentStdoutBuffer = "";
|
|
892
|
+
const latest: TelemetryDelta = {};
|
|
893
|
+
this.consumeAgentLine(task, remainder, latest);
|
|
894
|
+
this.commitTelemetry(task, latest);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private consumeAgentLine(task: BgTask, rawLine: string, latest: TelemetryDelta): void {
|
|
898
|
+
const line = rawLine.replace(/\r$/, "");
|
|
899
|
+
const trimmed = line.trim();
|
|
900
|
+
if (!trimmed) return;
|
|
901
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
|
902
|
+
this.writeNotice(task, `${line}\n`);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
let parsed: unknown;
|
|
906
|
+
try {
|
|
907
|
+
parsed = JSON.parse(trimmed);
|
|
908
|
+
} catch {
|
|
909
|
+
this.writeNotice(task, `${line}\n`);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
913
|
+
this.writeNotice(task, `${line}\n`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const record = parsed as Record<string, unknown>;
|
|
917
|
+
const type = record["type"];
|
|
918
|
+
if (type === "background-task-context-usage") {
|
|
919
|
+
const context = normalizeContextUsage(parsed);
|
|
920
|
+
if (context) latest.context = context;
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (type === "background-task-telemetry") {
|
|
924
|
+
const context = normalizeContextUsage(record["contextUsage"]);
|
|
925
|
+
if (context) latest.context = context;
|
|
926
|
+
const tokens = normalizeTokenUsage(record["tokenUsage"]);
|
|
927
|
+
if (tokens) latest.tokens = tokens;
|
|
928
|
+
const tools = normalizeToolUsage(record["toolUsage"]);
|
|
929
|
+
if (tools) latest.tools = tools;
|
|
930
|
+
const model = normalizeModel(record["model"]);
|
|
931
|
+
if (model) latest.model = model;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const activity = parseAgentActivity(parsed);
|
|
935
|
+
if (activity) {
|
|
936
|
+
const formatted = formatAgentActivityLine(activity);
|
|
937
|
+
if (formatted) this.writeNotice(task, `${formatted}\n`);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// Unknown JSON object: pass through to the transcript rather than silently dropping it.
|
|
941
|
+
this.writeNotice(task, `${line}\n`);
|
|
942
|
+
}
|
|
943
|
+
|
|
812
944
|
private requestKill(task: BgTask, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
813
945
|
if (task.status !== "running") {
|
|
814
946
|
throw new Error(`Task ${task.id} is ${task.status}, not running`);
|
|
@@ -921,6 +1053,7 @@ export class BackgroundTaskRegistry {
|
|
|
921
1053
|
task.signal = signal ?? null;
|
|
922
1054
|
task.endTime = this.now();
|
|
923
1055
|
if (error) task.error = error;
|
|
1056
|
+
if (task.telemetryWrapped) this.flushAgentStdout(task);
|
|
924
1057
|
if (task.stream && !task.stream.destroyed) task.stream.end();
|
|
925
1058
|
|
|
926
1059
|
for (const waiter of task.waiters.splice(0)) waiter();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed, offline-safe boundary for the "update available" footer notice.
|
|
5
|
+
*
|
|
6
|
+
* All network access flows through an injectable {@link FetchLike} so unit tests
|
|
7
|
+
* never touch the real npm registry. Every failure path (offline, timeout, bad
|
|
8
|
+
* status, malformed payload) resolves to `undefined` and never throws, so the
|
|
9
|
+
* footer/session can never hang or error because of an update check.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_NPM_REGISTRY_URL = "https://registry.npmjs.org";
|
|
13
|
+
export const DEFAULT_UPDATE_TIMEOUT_MS = 2000;
|
|
14
|
+
|
|
15
|
+
export type FetchResponseLike = {
|
|
16
|
+
readonly ok: boolean;
|
|
17
|
+
readonly status: number;
|
|
18
|
+
json(): Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type FetchLike = (url: string, init: { signal: AbortSignal }) => Promise<FetchResponseLike>;
|
|
22
|
+
|
|
23
|
+
export type FetchLatestVersionOptions = {
|
|
24
|
+
packageName: string;
|
|
25
|
+
registryUrl?: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
fetchImpl?: FetchLike;
|
|
28
|
+
onError?: (error: Error) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PackageInfo = {
|
|
32
|
+
name?: string;
|
|
33
|
+
version?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
37
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
38
|
+
return value as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readNonEmptyString(record: Record<string, unknown>, key: string): string | undefined {
|
|
42
|
+
const value = record[key];
|
|
43
|
+
if (typeof value !== "string") return undefined;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Narrow an npm registry `<pkg>/latest` JSON payload to its `version` string. */
|
|
49
|
+
export function parseLatestVersionPayload(payload: unknown): string | undefined {
|
|
50
|
+
const record = asRecord(payload);
|
|
51
|
+
if (!record) return undefined;
|
|
52
|
+
return readNonEmptyString(record, "version");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Narrow a parsed `package.json` payload to the fields this extension needs. */
|
|
56
|
+
export function parsePackageInfo(payload: unknown): PackageInfo {
|
|
57
|
+
const record = asRecord(payload);
|
|
58
|
+
if (!record) return {};
|
|
59
|
+
const info: PackageInfo = {};
|
|
60
|
+
const name = readNonEmptyString(record, "name");
|
|
61
|
+
const version = readNonEmptyString(record, "version");
|
|
62
|
+
if (name !== undefined) info.name = name;
|
|
63
|
+
if (version !== undefined) info.version = version;
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const realFetch: FetchLike = (url, init) => fetch(url, init);
|
|
68
|
+
|
|
69
|
+
/** Time-boxed, never-throwing lookup of the latest published version of a package. */
|
|
70
|
+
export async function fetchLatestVersion(options: FetchLatestVersionOptions): Promise<string | undefined> {
|
|
71
|
+
const registryUrl = (options.registryUrl ?? DEFAULT_NPM_REGISTRY_URL).replace(/\/+$/, "");
|
|
72
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS;
|
|
73
|
+
const doFetch = options.fetchImpl ?? realFetch;
|
|
74
|
+
const controller = new AbortController();
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
76
|
+
try {
|
|
77
|
+
const url = `${registryUrl}/${encodeURIComponent(options.packageName)}/latest`;
|
|
78
|
+
const response = await doFetch(url, { signal: controller.signal });
|
|
79
|
+
if (!response.ok) return undefined;
|
|
80
|
+
const payload = await response.json();
|
|
81
|
+
return parseLatestVersionPayload(payload);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
84
|
+
return undefined;
|
|
85
|
+
} finally {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Read `name`/`version` from a local `package.json`, returning `{}` on every failure. */
|
|
91
|
+
export function readPackageInfo(packageJsonUrl: URL | string, onError?: (error: Error) => void): PackageInfo {
|
|
92
|
+
try {
|
|
93
|
+
const parsed: unknown = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
|
|
94
|
+
return parsePackageInfo(parsed);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/extension.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
MAX_LOG_BYTES,
|
|
8
8
|
deriveTaskNameFromCommand,
|
|
9
9
|
formatSnapshotList,
|
|
10
|
+
formatUpdateSegment,
|
|
11
|
+
isNewerVersion,
|
|
10
12
|
normalizeMaxBytes,
|
|
11
13
|
normalizeTaskName,
|
|
12
14
|
parseBgCommandArgs,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
type BgTaskSnapshot,
|
|
21
23
|
type StartTaskOptions,
|
|
22
24
|
} from "./core/common.js";
|
|
25
|
+
import { fetchLatestVersion, readPackageInfo, type FetchLatestVersionOptions } from "./core/update-check.js";
|
|
23
26
|
import { BackgroundTaskRegistry } from "./core/registry.js";
|
|
24
27
|
import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResult } from "./ui/background-tasks-manager.js";
|
|
25
28
|
|
|
@@ -35,6 +38,13 @@ import { BackgroundTasksManager, type BackgroundTaskForUi, type TaskManagerResul
|
|
|
35
38
|
|
|
36
39
|
const STATUS_INTERVAL_MS = 1000;
|
|
37
40
|
const COMMAND_PREVIEW_CHARS = 90;
|
|
41
|
+
const GIT_INSTALL_TARGET = "git:github.com/ismailsaleekh/pi-background-tasks";
|
|
42
|
+
|
|
43
|
+
const packageInfo = readPackageInfo(new URL("../package.json", import.meta.url), (error) => {
|
|
44
|
+
console.error(`[background-tasks] failed to read package version: ${error.message}`);
|
|
45
|
+
});
|
|
46
|
+
const PACKAGE_NAME = packageInfo.name ?? "pi-background-tasks";
|
|
47
|
+
const PACKAGE_VERSION = packageInfo.version;
|
|
38
48
|
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
39
49
|
const LIGHT_BLUE_FG = "\x1b[38;2;11;70;110m";
|
|
40
50
|
const ANSI_RESET = "\x1b[0m";
|
|
@@ -88,6 +98,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
88
98
|
let currentCtx: ExtensionContext | undefined;
|
|
89
99
|
let dockOpen = false;
|
|
90
100
|
let statusInterval: NodeJS.Timeout | undefined;
|
|
101
|
+
let latestKnownVersion: string | undefined;
|
|
102
|
+
let updateCheckStarted = false;
|
|
91
103
|
|
|
92
104
|
const registry = new BackgroundTaskRegistry({
|
|
93
105
|
onChange: () => updateUi(),
|
|
@@ -129,9 +141,10 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
129
141
|
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
|
|
130
142
|
const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
|
|
131
143
|
const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
|
|
144
|
+
const updateSegment = formatUpdateSegment(latestKnownVersion, PACKAGE_VERSION ?? "");
|
|
132
145
|
ctx.ui.setWidget("background-tasks", undefined);
|
|
133
146
|
if (running.length === 0 && unseenFinishedCount === 0) {
|
|
134
|
-
ctx.ui.setStatus("background-tasks", undefined);
|
|
147
|
+
ctx.ui.setStatus("background-tasks", updateSegment ? lightBlue(` bg ${updateSegment} `) : undefined);
|
|
135
148
|
return;
|
|
136
149
|
}
|
|
137
150
|
|
|
@@ -141,7 +154,9 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
141
154
|
if (unseenStopped.length > 0) parts.push(`${unseenStopped.length} stopped`);
|
|
142
155
|
if (unseenDone.length > 0) parts.push(`${unseenDone.length} done`);
|
|
143
156
|
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /bg-clear" : ""}`;
|
|
144
|
-
const
|
|
157
|
+
const segments = [...parts, entryHint];
|
|
158
|
+
if (updateSegment) segments.push(updateSegment);
|
|
159
|
+
const label = ` bg ${segments.join(" · ")} `;
|
|
145
160
|
ctx.ui.setStatus("background-tasks", lightBlue(label));
|
|
146
161
|
} catch (error) {
|
|
147
162
|
console.error(`[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -227,6 +242,26 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
227
242
|
return new Text(`${theme.fg(color, `[bg ${status}]`)} ${theme.fg("accent", name)} ${theme.fg("dim", `(${id})`)}${output}${error}`, 0, 0);
|
|
228
243
|
});
|
|
229
244
|
|
|
245
|
+
async function scheduleUpdateCheck(ctx: ExtensionContext): Promise<void> {
|
|
246
|
+
if (updateCheckStarted) return;
|
|
247
|
+
updateCheckStarted = true;
|
|
248
|
+
const env = process.env;
|
|
249
|
+
if (env["PI_BG_DISABLE_UPDATE_CHECK"] === "1") return;
|
|
250
|
+
if (env["PI_OFFLINE"] === "1") return;
|
|
251
|
+
if (!PACKAGE_VERSION) return;
|
|
252
|
+
const options: FetchLatestVersionOptions = {
|
|
253
|
+
packageName: PACKAGE_NAME,
|
|
254
|
+
onError: (error) => console.error(`[background-tasks] update check skipped: ${error.message}`),
|
|
255
|
+
};
|
|
256
|
+
const registryUrl = env["PI_BG_REGISTRY_URL"];
|
|
257
|
+
if (registryUrl) options.registryUrl = registryUrl;
|
|
258
|
+
const latest = await fetchLatestVersion(options);
|
|
259
|
+
if (latest && isNewerVersion(latest, PACKAGE_VERSION)) {
|
|
260
|
+
latestKnownVersion = latest;
|
|
261
|
+
updateUi(ctx);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
pi.on("session_start", async (_event, ctx) => {
|
|
231
266
|
registry.setShuttingDown(false);
|
|
232
267
|
currentCtx = ctx;
|
|
@@ -234,6 +269,8 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
234
269
|
updateUi(ctx);
|
|
235
270
|
if (statusInterval) clearInterval(statusInterval);
|
|
236
271
|
statusInterval = setInterval(() => updateUi(), STATUS_INTERVAL_MS);
|
|
272
|
+
// One-shot, non-blocking: never awaited on the session-start path or the status tick.
|
|
273
|
+
void scheduleUpdateCheck(ctx);
|
|
237
274
|
});
|
|
238
275
|
|
|
239
276
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -301,6 +338,28 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
301
338
|
},
|
|
302
339
|
});
|
|
303
340
|
|
|
341
|
+
pi.registerCommand("bg-update", {
|
|
342
|
+
description: "Show how to update pi-background-tasks to the latest published version",
|
|
343
|
+
handler: async (_args, ctx) => {
|
|
344
|
+
const current = PACKAGE_VERSION ?? "unknown";
|
|
345
|
+
const latest = latestKnownVersion;
|
|
346
|
+
const pinnedNpm = latest ? `${PACKAGE_NAME}@${latest}` : `${PACKAGE_NAME}@<version>`;
|
|
347
|
+
const pinnedGit = latest ? `${GIT_INSTALL_TARGET}@v${latest}` : `${GIT_INSTALL_TARGET}@<tag>`;
|
|
348
|
+
const lines = [
|
|
349
|
+
latest
|
|
350
|
+
? `pi-background-tasks ${current} is installed; ${latest} is the latest published version.`
|
|
351
|
+
: `pi-background-tasks ${current} is installed.`,
|
|
352
|
+
"Update from npm:",
|
|
353
|
+
` pi install npm:${PACKAGE_NAME}@latest`,
|
|
354
|
+
` pi install npm:${pinnedNpm}`,
|
|
355
|
+
"Or update from git tags:",
|
|
356
|
+
` pi install ${pinnedGit}`,
|
|
357
|
+
"This command only prints update instructions; it does not install or self-update.",
|
|
358
|
+
];
|
|
359
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
304
363
|
pi.registerShortcut("shift+down" satisfies KeyId, {
|
|
305
364
|
description: "Open focused background task footer dock",
|
|
306
365
|
handler: async (ctx) => {
|
|
@@ -16,7 +16,10 @@ export type BackgroundTaskForUi = BgTaskSnapshot & { name: string; outputAbsPath
|
|
|
16
16
|
type BgTask = BackgroundTaskForUi;
|
|
17
17
|
|
|
18
18
|
const STATUS_INTERVAL_MS = 1000;
|
|
19
|
-
|
|
19
|
+
// Detail-view scrollback reservoir. Larger than the model-facing log cap because
|
|
20
|
+
// this is a UI-only read; a local 128KiB tail read per second is negligible and
|
|
21
|
+
// gives thousands of lines of scrollback when the user pauses the live tail.
|
|
22
|
+
const DETAIL_TAIL_BYTES = 128 * 1024;
|
|
20
23
|
const LIST_VISIBLE_ROWS = 14;
|
|
21
24
|
const DETAIL_VISIBLE_OUTPUT_LINES = 12;
|
|
22
25
|
const LIGHT_BLUE_BG = "\x1b[48;2;183;223;255m";
|
|
@@ -28,6 +31,11 @@ function formatTime(timestamp: number): string {
|
|
|
28
31
|
return new Date(timestamp).toLocaleTimeString();
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/** Normalize raw output bytes into display lines, dropping only the trailing empty line from a final newline. */
|
|
35
|
+
function toOutputLines(content: string): string[] {
|
|
36
|
+
return content.replace(/\r/g, "").split("\n").filter((line, index, array) => line.length > 0 || index < array.length - 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
function padAnsi(value: string, width: number): string {
|
|
32
40
|
return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
|
|
33
41
|
}
|
|
@@ -168,7 +176,9 @@ export class BackgroundTasksManager implements Component {
|
|
|
168
176
|
private listScroll = 0;
|
|
169
177
|
private detailTaskId: string | undefined;
|
|
170
178
|
private showHistory = false;
|
|
171
|
-
private
|
|
179
|
+
private detailLines: string[] = [];
|
|
180
|
+
private detailFollow = true;
|
|
181
|
+
private detailScrollTop = 0;
|
|
172
182
|
private tailBytesRead = 0;
|
|
173
183
|
private tailTotalBytes = 0;
|
|
174
184
|
private tailTruncated = false;
|
|
@@ -309,7 +319,25 @@ export class BackgroundTasksManager implements Component {
|
|
|
309
319
|
this.tui.requestRender();
|
|
310
320
|
return;
|
|
311
321
|
}
|
|
322
|
+
if (matchesKey(data, "up")) {
|
|
323
|
+
this.scrollDetail(-1);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (matchesKey(data, "down")) {
|
|
327
|
+
this.scrollDetail(1);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (matchesKey(data, "pageUp")) {
|
|
331
|
+
this.scrollDetail(-DETAIL_VISIBLE_OUTPUT_LINES);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (matchesKey(data, "pageDown")) {
|
|
335
|
+
this.scrollDetail(DETAIL_VISIBLE_OUTPUT_LINES);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
312
338
|
if (data === "r") {
|
|
339
|
+
this.detailFollow = true;
|
|
340
|
+
this.detailScrollTop = 0;
|
|
313
341
|
void this.refreshTail();
|
|
314
342
|
return;
|
|
315
343
|
}
|
|
@@ -343,7 +371,9 @@ export class BackgroundTasksManager implements Component {
|
|
|
343
371
|
private openDetail(taskId: string): void {
|
|
344
372
|
this.detailTaskId = taskId;
|
|
345
373
|
this.mode = "detail";
|
|
346
|
-
this.
|
|
374
|
+
this.detailLines = [];
|
|
375
|
+
this.detailFollow = true;
|
|
376
|
+
this.detailScrollTop = 0;
|
|
347
377
|
this.tailError = undefined;
|
|
348
378
|
this.options.markSeen(taskId);
|
|
349
379
|
void this.refreshTail();
|
|
@@ -425,20 +455,40 @@ export class BackgroundTasksManager implements Component {
|
|
|
425
455
|
this.tui.requestRender();
|
|
426
456
|
}
|
|
427
457
|
|
|
458
|
+
/** Scroll the detail output window. Scrolling up pauses the live tail; reaching the bottom resumes it. */
|
|
459
|
+
private scrollDetail(delta: number): void {
|
|
460
|
+
const maxTop = Math.max(0, this.detailLines.length - DETAIL_VISIBLE_OUTPUT_LINES);
|
|
461
|
+
if (maxTop === 0) return;
|
|
462
|
+
if (this.detailFollow) {
|
|
463
|
+
this.detailFollow = false;
|
|
464
|
+
this.detailScrollTop = maxTop;
|
|
465
|
+
}
|
|
466
|
+
this.detailScrollTop = Math.min(maxTop, Math.max(0, this.detailScrollTop + delta));
|
|
467
|
+
if (this.detailScrollTop >= maxTop) {
|
|
468
|
+
this.detailFollow = true;
|
|
469
|
+
this.detailScrollTop = 0;
|
|
470
|
+
void this.refreshTail();
|
|
471
|
+
}
|
|
472
|
+
this.tui.requestRender();
|
|
473
|
+
}
|
|
474
|
+
|
|
428
475
|
private async refreshTail(): Promise<void> {
|
|
429
476
|
const task = this.detailTask();
|
|
430
477
|
if (!task) return;
|
|
478
|
+
// While the user has scrolled up, the buffer is frozen so their view stays stable.
|
|
479
|
+
if (!this.detailFollow) return;
|
|
431
480
|
try {
|
|
432
481
|
if (!existsSync(task.outputAbsPath)) {
|
|
433
|
-
this.
|
|
482
|
+
this.detailLines = [];
|
|
434
483
|
this.tailBytesRead = 0;
|
|
435
484
|
this.tailTotalBytes = 0;
|
|
436
485
|
this.tailTruncated = false;
|
|
437
486
|
this.tailError = `Output file not found: ${task.outputPath}`;
|
|
487
|
+
this.tui.requestRender();
|
|
438
488
|
return;
|
|
439
489
|
}
|
|
440
490
|
const read = await boundedRead(task.outputAbsPath, DETAIL_TAIL_BYTES, true);
|
|
441
|
-
this.
|
|
491
|
+
this.detailLines = toOutputLines(read.content);
|
|
442
492
|
this.tailBytesRead = read.bytesRead;
|
|
443
493
|
this.tailTotalBytes = read.totalBytes;
|
|
444
494
|
this.tailTruncated = read.truncated;
|
|
@@ -593,7 +643,7 @@ export class BackgroundTasksManager implements Component {
|
|
|
593
643
|
body.push(...this.renderOutputBox(width - 4));
|
|
594
644
|
if (this.actionMessage) body.push(this.theme.fg("warning", ` ${this.actionMessage}`));
|
|
595
645
|
const subtitle = `${task.id} · ${task.status === "running" ? "live tail refreshes every second" : "final output"}`;
|
|
596
|
-
const footer = ` ${this.theme.fg("dim", "← list · r refresh · k stop · R rerun · c path · x close")}`;
|
|
646
|
+
const footer = ` ${this.theme.fg("dim", "↑/↓ scroll · ← list · r refresh · k stop · R rerun · c path · x close")}`;
|
|
597
647
|
return this.frame(`bg: ${truncateChars(name, 64)}`, subtitle, body, footer, width);
|
|
598
648
|
}
|
|
599
649
|
|
|
@@ -605,18 +655,30 @@ export class BackgroundTasksManager implements Component {
|
|
|
605
655
|
const lines = [top];
|
|
606
656
|
if (this.tailError) {
|
|
607
657
|
lines.push(row(this.theme.fg("error", this.tailError)));
|
|
608
|
-
} else if (
|
|
658
|
+
} else if (this.detailLines.length === 0) {
|
|
609
659
|
lines.push(row(this.theme.fg("dim", "No output yet")));
|
|
610
660
|
} else {
|
|
611
|
-
const
|
|
612
|
-
const
|
|
613
|
-
|
|
661
|
+
const maxTop = Math.max(0, this.detailLines.length - DETAIL_VISIBLE_OUTPUT_LINES);
|
|
662
|
+
const start = this.detailFollow ? maxTop : Math.min(this.detailScrollTop, maxTop);
|
|
663
|
+
const windowLines = this.detailLines.slice(start, start + DETAIL_VISIBLE_OUTPUT_LINES);
|
|
664
|
+
for (const line of windowLines) lines.push(row(this.theme.fg("toolOutput", line)));
|
|
614
665
|
}
|
|
615
666
|
while (lines.length < DETAIL_VISIBLE_OUTPUT_LINES + 1) lines.push(row());
|
|
616
667
|
lines.push(bottom);
|
|
617
|
-
|
|
618
|
-
lines.push(` ${this.theme.fg("dim", `Showing tail ${formatSize(this.tailBytesRead)}${suffix}`)}`);
|
|
668
|
+
lines.push(` ${this.theme.fg("dim", this.outputStatusLine())}`);
|
|
619
669
|
return lines;
|
|
620
670
|
}
|
|
671
|
+
|
|
672
|
+
private outputStatusLine(): string {
|
|
673
|
+
const total = this.detailLines.length;
|
|
674
|
+
if (!this.detailFollow && total > 0) {
|
|
675
|
+
const maxTop = Math.max(0, total - DETAIL_VISIBLE_OUTPUT_LINES);
|
|
676
|
+
const start = Math.min(this.detailScrollTop, maxTop);
|
|
677
|
+
const end = Math.min(total, start + DETAIL_VISIBLE_OUTPUT_LINES);
|
|
678
|
+
return `lines ${start + 1}\u2013${end} of ${total} · ↑/↓ PgUp/PgDn scroll · ↓ at bottom follows`;
|
|
679
|
+
}
|
|
680
|
+
const suffix = this.tailTruncated ? ` of ${formatSize(this.tailTotalBytes)}` : "";
|
|
681
|
+
return `following tail ${formatSize(this.tailBytesRead)}${suffix} · ↑ scroll`;
|
|
682
|
+
}
|
|
621
683
|
}
|
|
622
684
|
|