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 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.3.0 --offline --no-tools --no-session -p "/jobs"
36
- pi install npm:pi-background-tasks@0.3.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.3.0
52
- git push origin v0.3.0
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.3.0 --offline --no-tools --no-session -p "/jobs"
59
- pi install git:github.com/ismailsaleekh/pi-background-tasks@v0.3.0
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.3.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.3.0
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.3.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
- - shortcut/UI: component coverage for focused dock list/detail/key handling, 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
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 | | | | Tail read, output box, return-to-list tested. |
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, and large telemetry records above the old 16KiB buffer; SDK/RPC cover runtime spawn/timeout/output-cap/shutdown; scripted provider covers wakeup integration. |
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.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",
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
+ }
@@ -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
- markToolStarted(event.toolCallId || event.tool_call_id, event.toolName || event.tool_name);
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.appendToOutput(task, data));
602
- child.stderr?.on("data", (data) => this.appendToOutput(task, data));
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.appendToOutput(task, `\n[background task spawn error: ${error.message}]\n`);
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.appendToOutput(task, `\n[background task timeout: ${task.error}]\n`);
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.appendToOutput(task, `\n[background task spawn exception: ${message}]\n`);
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 = latestContext;
765
- task.tokenUsage = latestTokens;
766
- task.toolUsage = latestTools;
767
- task.model = latestModel;
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
- private appendToOutput(task: BgTask, data: Buffer | string): void {
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 label = ` bg ${parts.join(" · ")} · ${entryHint} `;
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
- const DETAIL_TAIL_BYTES = 8 * 1024;
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 tailText = "";
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.tailText = "";
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.tailText = "";
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.tailText = read.content;
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 (!this.tailText) {
658
+ } else if (this.detailLines.length === 0) {
609
659
  lines.push(row(this.theme.fg("dim", "No output yet")));
610
660
  } else {
611
- const outputLines = this.tailText.replace(/\r/g, "").split("\n").filter((line, index, array) => line.length > 0 || index < array.length - 1);
612
- const visible = outputLines.slice(-DETAIL_VISIBLE_OUTPUT_LINES);
613
- for (const line of visible) lines.push(row(this.theme.fg("toolOutput", line)));
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
- const suffix = this.tailTruncated ? ` of ${formatSize(this.tailTotalBytes)}` : "";
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