pi-minimal 0.4.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/CONTEXT.md +107 -0
  3. package/README.md +9 -6
  4. package/dist/background-overlay.js +95 -0
  5. package/dist/background.js +206 -0
  6. package/dist/config.js +10 -1
  7. package/dist/cwd.js +2 -2
  8. package/dist/edit-diff.js +74 -0
  9. package/dist/footer-contribution-events.js +5 -3
  10. package/dist/footer-contributions.js +15 -13
  11. package/dist/footer.js +15 -3
  12. package/dist/git-state.js +31 -11
  13. package/dist/herdr.js +78 -0
  14. package/dist/index.js +79 -202
  15. package/dist/listeners.js +23 -0
  16. package/dist/prs-command.js +76 -0
  17. package/dist/prs.js +270 -0
  18. package/dist/reclaim.js +0 -3
  19. package/dist/rex.js +289 -0
  20. package/dist/session.js +168 -0
  21. package/dist/siblings.js +14 -0
  22. package/dist/tools.js +213 -374
  23. package/dist/tracker.js +2 -2
  24. package/dist/working-status.js +2 -1
  25. package/doc/archive/README.md +3 -0
  26. package/doc/footer-contributions.md +7 -7
  27. package/package.json +5 -3
  28. package/release-notes.md +33 -12
  29. package/src/background-overlay.ts +103 -0
  30. package/src/background.ts +250 -0
  31. package/src/config.ts +10 -1
  32. package/src/cwd.ts +5 -4
  33. package/src/edit-diff.ts +73 -0
  34. package/src/footer-contribution-events.ts +7 -4
  35. package/src/footer-contributions.ts +15 -13
  36. package/src/footer.ts +23 -3
  37. package/src/git-state.ts +29 -10
  38. package/src/herdr.ts +78 -5
  39. package/src/index.ts +81 -195
  40. package/src/listeners.ts +22 -0
  41. package/src/prs-command.ts +81 -0
  42. package/src/prs.ts +348 -0
  43. package/src/reclaim.ts +0 -4
  44. package/src/rex.ts +304 -0
  45. package/src/session.ts +185 -0
  46. package/src/siblings.ts +38 -0
  47. package/src/tools.ts +261 -389
  48. package/src/tracker.ts +11 -5
  49. package/src/working-status.ts +2 -1
  50. package/test/background.test.ts +198 -0
  51. package/test/config.test.ts +24 -11
  52. package/test/footer-composition.test.ts +61 -0
  53. package/test/footer-contribution-events.test.ts +15 -0
  54. package/test/git-state.test.ts +35 -0
  55. package/test/index.test.ts +202 -4
  56. package/test/prs.test.ts +272 -0
  57. package/test/rex.test.ts +249 -0
  58. package/test/tools.test.ts +36 -3
  59. package/test/working-status.test.ts +53 -0
  60. /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
  61. /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/dist/tracker.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"];
2
- function readSnapshot(ctx) {
2
+ export function readGauntletProjection(ctx) {
3
3
  let phases;
4
4
  let tasks;
5
5
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -21,7 +21,7 @@ function phaseActive(phases) {
21
21
  return PHASES.some((p) => phases[p]?.status && phases[p].status !== "pending");
22
22
  }
23
23
  export function formatTrackerLines(ctx, theme) {
24
- const { phases, tasks } = readSnapshot(ctx);
24
+ const { phases, tasks } = readGauntletProjection(ctx);
25
25
  const lines = [];
26
26
  if (phases && phaseActive(phases)) {
27
27
  const parts = PHASES.map((p) => {
@@ -184,8 +184,9 @@ export function installWorkingStatus(pi) {
184
184
  stop();
185
185
  });
186
186
  pi.on("session_shutdown", () => {
187
- ctx = undefined;
187
+ // Clear the message while ctx is still known; dropping ctx first left it on screen.
188
188
  stop();
189
+ ctx = undefined;
189
190
  });
190
191
  return stop;
191
192
  }
@@ -0,0 +1,3 @@
1
+ # Archive
2
+
3
+ The spec and plan here describe the 0.1 package (September 2026) and are superseded by [README.md](../../README.md) and [CONTEXT.md](../../CONTEXT.md).
@@ -4,14 +4,14 @@ Pi-minimal remains the single footer owner. A trusted companion extension can pu
4
4
 
5
5
  Order: **input → existing hints → plan summary → activity rows last**. When either contribution is visible, a blank row separates the footer from input and separates each nonempty group. Agent rows stay compact, and empty groups leave no extra gaps. Legacy-only output is unchanged.
6
6
 
7
- Two independent slots are supported: `activity` and `summary`, with one owner per slot. An explicit plan summary replaces the legacy tracker projection while present, avoiding duplicate plan bars; clearing it restores the legacy summary. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
7
+ Two independent slots are supported: `activity` and `plan`, with one owner per slot. (`summary` is the legacy wire name of the plan slot and remains accepted.) A rendered plan line replaces the legacy pi-gauntlet projection, avoiding duplicate plan bars; a plan snapshot that omits `plan`, or clearing the slot, lets the legacy projection show again. This is display composition, not a task tracker, subagent launcher, or PR/workspace display.
8
8
 
9
9
  ## Events
10
10
 
11
11
  | Event | Direction | Payload |
12
12
  |---|---|---|
13
13
  | `pi-minimal:footer:v1:register` | consumer → host | Mutable registration request; host assigns `result` synchronously |
14
- | `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "summary"] }` |
14
+ | `pi-minimal:footer:v1:ready` | host → consumers | `{ version: 1, sessionId, slots: ["activity", "plan", "summary"] }` |
15
15
  | `pi-minimal:footer:v1:unavailable` | host → consumers | `{ version: 1, sessionId }` |
16
16
 
17
17
  Use `ctx.sessionManager.getSessionId()`, not a file path. Events are in-process only, not a cross-process API. One consumer owns each slot at a time; a tracker and activity extension can register independently. Names/keys are display identity, not permission or process-control tokens.
@@ -51,7 +51,7 @@ type RegisterRequest = {
51
51
  version: 1;
52
52
  sessionId: string;
53
53
  owner: string;
54
- slot: "activity" | "summary";
54
+ slot: "activity" | "plan" | "summary"; // "summary" is the legacy alias of "plan"
55
55
  result?:
56
56
  | { ok: true; registration: {
57
57
  publish(snapshot: unknown): { ok: true } | { ok: false; code: string };
@@ -63,7 +63,7 @@ type RegisterRequest = {
63
63
 
64
64
  No result means the host is absent/not ready. Registration errors include `invalid`, `wrong-session`, `occupied`, and `not-tui`. Publish errors include `invalid`, `stale`, and `disposed`.
65
65
 
66
- ## Plan summary snapshot (`summary` slot)
66
+ ## Plan snapshot (`plan` slot; legacy name `summary`)
67
67
 
68
68
  ```ts
69
69
  {
@@ -74,8 +74,8 @@ No result means the host is absent/not ready. Registration errors include `inval
74
74
  ```
75
75
 
76
76
  The plan ID follows row-key bounds. Counts/revision are nonnegative safe integers;
77
- accepted cannot exceed total; review and blocked counts cannot individually exceed
78
- remaining tasks. Counts are supplied by the tracker, never inferred from agent
77
+ accepted cannot exceed total; `inReview` and `blocked` are each bounded by
78
+ `total - accepted`, but their sum is not checked (a task may be both). Counts are supplied by the tracker, never inferred from agent
79
79
  runs. Omit `plan` on a newer revision to clear the contribution without releasing
80
80
  ownership. Snapshot objects are cloned/frozen, and revision/disposal rules match
81
81
  the activity slot. Each slot has independent ownership and revision tracking.
@@ -109,7 +109,7 @@ Metrics are right-aligned only if the full activity text still fits with at leas
109
109
 
110
110
  Task IDs appear beside activity. In-progress tasks need no extra badge; blocked/review/accepted/cancelled tasks get a distinct badge, and plain mode retains textual status. A completed agent with an `in_review` task shows a green run check plus an accent review diamond. This optional data does not create a tracker or automatically accept a task.
111
111
 
112
- Owner uses the same character/length rules as row keys. Display text has ANSI/OSC, C0/C1, and bidi controls removed, with whitespace collapsed. The host clones and freezes validated snapshots. Invalid/stale publishes preserve the previous valid snapshot. Consumers must handle publish failure and send an explicit unknown/error observation when appropriate; an absent update does not prove a run ended.
112
+ Owner uses the same character/length rules as row keys. Display text has ANSI/OSC sequences stripped; remaining C0/C1 and bidi control characters are replaced with spaces, then whitespace is collapsed and trimmed. The host clones and freezes validated snapshots. Invalid/stale publishes preserve the previous valid snapshot. Consumers must handle publish failure and send an explicit unknown/error observation when appropriate; an absent update does not prove a run ended.
113
113
 
114
114
  ## Presentation and responsibility boundaries
115
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-minimal",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -28,7 +28,8 @@
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@earendil-works/pi-coding-agent": "*",
31
- "@earendil-works/pi-tui": "*"
31
+ "@earendil-works/pi-tui": "*",
32
+ "@earendil-works/pi-ai": "*"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@earendil-works/pi-coding-agent": "^1.4.0",
@@ -37,7 +38,8 @@
37
38
  "@types/node": "*",
38
39
  "eslint": "^10.11.0",
39
40
  "typescript": "*",
40
- "typescript-eslint": "^8.70.1"
41
+ "typescript-eslint": "^8.70.1",
42
+ "@earendil-works/pi-ai": "^1.4.0"
41
43
  },
42
44
  "scripts": {
43
45
  "build": "tsc -p tsconfig.json",
package/release-notes.md CHANGED
@@ -1,23 +1,44 @@
1
+ Rex support and a footer hints line that carries live state: sibling agents from Rex as well as
2
+ Herdr, background processes left by bash calls, and the repo's open PRs as clickable chips.
3
+
1
4
  ### Added
2
5
 
3
- - `editDiff` setting in `pi-minimal.json`: controls whether an `edit` row shows the
4
- change itself. `"expanded"` (default) shows the unified diff in the `│`-ruled
5
- block when the row is expanded with `ctrl+o`, where the bare "Successfully
6
- replaced" text used to be. `"always"` shows it inline as soon as the edit lands,
7
- capped at 40 lines with `… N more lines (ctrl+o)` for the rest. `"never"` keeps
8
- the stats-only row. Lines use the theme's `toolDiffAdded` / `toolDiffRemoved` /
9
- `toolDiffContext` colours and keep pi's line-number gutter; when one line
10
- replaces one line, only the changed tokens are inverse-highlighted, as in pi's
11
- own edit renderer. Failed edits and results without a diff are unchanged.
6
+ - Open PRs: the footer shows `#1675 #1670 +3` chips, right-aligned on the hints line below the editor, for open PRs in the repo of cwd that you
7
+ authored or were asked to review, each an OSC 8 hyperlink to the PR, coloured accent (mine), dim
8
+ (my draft), amber (needs my review) or red (failing CI / changes requested). `/prs [mine|review|all]`
9
+ lists them with open / paste URL / checkout actions; `list_open_prs` tool for the model. Uses the
10
+ `gh` CLI; works on GitHub.com and GitHub Enterprise. PRs created by the session (`gh pr create`,
11
+ `gt submit`) are remembered as session entries and shown from any directory, as `repo#n` when
12
+ outside the cwd repo, until merged. New peer dependency `@earendil-works/pi-ai` (supplied by pi)
13
+ for the tool schema.
14
+ - Background processes: `⚙ N bg` after the key hints in the footer while processes left behind by `bash` tool
15
+ calls are running; `ctrl+b` / `/bg` opens an overlay listing each one with running/ended state and
16
+ duration, `k` kills the selection, `K` its process group. Attribution diffs `ps` around each bash
17
+ call using pi's detached process groups, so no command is rewritten. New `backgroundShortcut`
18
+ setting (`"ctrl+b"`; `null` disables the key).
19
+
20
+ - Rex siblings: inside a Rex session (`REX_SESSION` + `REX_BLOCK`) the status line shows other
21
+ panes' agent state, read from Rex's Program Status Protocol (OSC 7501) over one
22
+ `rex events --json` stream instead of polling. Needs a reporter in each pi (an extension that
23
+ writes OSC 7501 on `agent_start` / `agent_settled`); programs such as claude-code or terraform
24
+ that already speak the protocol appear as well. `error` renders like `blocked`.
25
+
26
+ ### Changed
27
+
28
+ - Sibling status now comes from a `SiblingSource` (`src/siblings.ts`) picked from the environment;
29
+ the Herdr poller moved from `session.ts` into `herdr.ts` unchanged. Herdr wins when both hosts
30
+ are present. The status line re-renders when the sibling list changes, not only on git changes.
12
31
 
13
32
  ## What's Changed
14
- * feat: editDiff setting — show edit diffs always, never, or behind ctrl+o (0.4.0) by @raviy06 in https://github.com/raviy06/pi-minimal/pull/18
33
+ * feat: Rex siblings on the status line via OSC 7501 event stream by @raviy06 in https://github.com/raviy06/pi-minimal/pull/22
34
+ * feat: background processes on the status line with a ctrl+b overlay by @raviy06 in https://github.com/raviy06/pi-minimal/pull/23
35
+ * feat: open PR chips and background count on the footer hints line by @raviy06 in https://github.com/raviy06/pi-minimal/pull/24
15
36
 
16
37
 
17
- **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.3.1...v0.4.0
38
+ **Full Changelog**: https://github.com/raviy06/pi-minimal/compare/v0.5.0...v0.6.0
18
39
 
19
40
  ---
20
41
  **Install / upgrade**
21
42
  ```bash
22
- pi install npm:pi-minimal@0.4.0 # or: pi update --extensions
43
+ pi install npm:pi-minimal@0.6.0 # or: pi update --extensions
23
44
  ```
@@ -0,0 +1,103 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"
3
+ import type { BackgroundProcess, BackgroundSnapshot, BackgroundTracker } from "./background.js"
4
+ import { formatElapsed } from "./working-status.js"
5
+
6
+ export type BackgroundRow = {
7
+ pid: number
8
+ command: string
9
+ status: "running" | "ended"
10
+ /** Running: time since start. Ended: how long it ran. */
11
+ durationMs: number
12
+ }
13
+
14
+ export function backgroundRows(snap: BackgroundSnapshot, now: number): BackgroundRow[] {
15
+ const row = (proc: BackgroundProcess): BackgroundRow => ({
16
+ pid: proc.pid,
17
+ command: proc.command,
18
+ status: proc.endedAt === undefined ? "running" : "ended",
19
+ durationMs: (proc.endedAt ?? now) - proc.startedAt,
20
+ })
21
+ return [...snap.running.map(row), ...snap.ended.map(row)]
22
+ }
23
+
24
+ /** Status-line segment: `⚙ 2 bg`; undefined when nothing is running. */
25
+ export function backgroundSegment(snap: BackgroundSnapshot): string | undefined {
26
+ if (snap.running.length === 0) return undefined
27
+ return `⚙ ${snap.running.length} bg`
28
+ }
29
+
30
+ type Theme = { fg(color: string, text: string): string }
31
+
32
+ /** Lines of the overlay body for a given width; pure so it can be tested without a TUI. */
33
+ export function renderBackgroundLines(rows: BackgroundRow[], selected: number, width: number, theme: Theme, shortcut?: string): string[] {
34
+ const title = theme.fg("accent", "Background processes")
35
+ const hint = theme.fg("dim", shortcut ? `↑↓ select · k kill · K kill group · esc close (${shortcut})` : "↑↓ select · k kill · K kill group · esc close")
36
+ if (rows.length === 0) {
37
+ return [title, theme.fg("muted", "Nothing running in the background."), hint].map((line) => truncateToWidth(line, width))
38
+ }
39
+ const statusWidth = 7
40
+ const durations = rows.map((row) => (row.status === "running" ? formatElapsed(row.durationMs) : `ran ${formatElapsed(row.durationMs)}`))
41
+ const durationWidth = Math.max(...durations.map((text) => text.length))
42
+ const commandWidth = Math.max(8, width - 2 - 1 - statusWidth - 2 - durationWidth)
43
+ const lines = rows.map((row, index) => {
44
+ const marker = index === selected ? theme.fg("accent", "› ") : " "
45
+ const command = truncateToWidth(row.command, commandWidth, "…").padEnd(commandWidth)
46
+ const status = row.status === "running" ? theme.fg("success", "running") : theme.fg("muted", "ended ")
47
+ const duration = theme.fg("dim", (durations[index] ?? "").padStart(durationWidth))
48
+ const text = `${marker}${index === selected ? theme.fg("text", command) : theme.fg("muted", command)} ${status} ${duration}`
49
+ return visibleWidth(text) > width ? truncateToWidth(text, width) : text
50
+ })
51
+ return [title, ...lines, hint]
52
+ }
53
+
54
+ /** Shows the overlay until the user closes it. Resolves when closed. */
55
+ export async function showBackgroundOverlay(ctx: ExtensionContext, tracker: BackgroundTracker, shortcut?: string): Promise<void> {
56
+ if (ctx.mode !== "tui") {
57
+ const rows = backgroundRows(tracker.get(), Date.now())
58
+ ctx.ui.notify(rows.length === 0 ? "Nothing running in the background." : rows.map((row) => `${row.pid} ${row.status} ${formatElapsed(row.durationMs)} ${row.command}`).join("\n"), "info")
59
+ return
60
+ }
61
+ await ctx.ui.custom<void>(
62
+ (tui, theme, keybindings, done) => {
63
+ let selected = 0
64
+ let cached: { width: number; lines: string[] } | undefined
65
+ const invalidate = () => { cached = undefined }
66
+ const redraw = () => { invalidate(); tui.requestRender() }
67
+ const unsubscribe = tracker.onChange(redraw)
68
+ const ticker = setInterval(redraw, 1000)
69
+ const rows = () => backgroundRows(tracker.get(), Date.now())
70
+ const finish = () => {
71
+ clearInterval(ticker)
72
+ unsubscribe()
73
+ done()
74
+ }
75
+ return {
76
+ render(width: number): string[] {
77
+ if (cached && cached.width === width) return cached.lines
78
+ const current = rows()
79
+ if (selected >= current.length) selected = Math.max(0, current.length - 1)
80
+ cached = { width, lines: renderBackgroundLines(current, selected, width, theme, shortcut) }
81
+ return cached.lines
82
+ },
83
+ handleInput(data: string): void {
84
+ const current = rows()
85
+ if (keybindings.matches(data, "tui.select.cancel")) { finish(); return }
86
+ if (keybindings.matches(data, "tui.select.up")) selected = current.length === 0 ? 0 : (selected - 1 + current.length) % current.length
87
+ else if (keybindings.matches(data, "tui.select.down")) selected = current.length === 0 ? 0 : (selected + 1) % current.length
88
+ else if (data === "k" || data === "K") {
89
+ const row = current[selected]
90
+ if (row?.status === "running") tracker.kill(row.pid, { group: data === "K" })
91
+ } else return
92
+ redraw()
93
+ },
94
+ invalidate,
95
+ dispose() {
96
+ clearInterval(ticker)
97
+ unsubscribe()
98
+ },
99
+ }
100
+ },
101
+ { overlay: true, overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" } },
102
+ )
103
+ }
@@ -0,0 +1,250 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { createListeners } from "./listeners.js"
3
+
4
+ /**
5
+ * Background processes: programs a `bash` tool call left running after it returned (`npm run dev &`,
6
+ * `nohup ./watch.sh`). Pi spawns every bash call detached in its own process group, so whatever the
7
+ * shell leaves behind keeps that group's id after the shell exits and is reparented to pid 1.
8
+ *
9
+ * Attribution needs no command rewriting: snapshot `ps` when the call starts and when it ends; a
10
+ * process that is new, orphaned (ppid 1) and belongs to a group whose leader was born and died inside
11
+ * the call came from that call's shell. Tracked groups are then polled until they are empty.
12
+ * Pi kills these groups when it exits, so "background" means "alive while this pi is".
13
+ */
14
+
15
+ export type PsEntry = {
16
+ pid: number
17
+ ppid: number
18
+ pgid: number
19
+ /** Seconds since the process started, from `etime`. */
20
+ elapsedSec: number
21
+ command: string
22
+ }
23
+
24
+ export type BackgroundProcess = {
25
+ pid: number
26
+ pgid: number
27
+ command: string
28
+ /** Wall-clock ms when the process started (derived from etime at first sight). */
29
+ startedAt: number
30
+ /** Wall-clock ms when it was first seen gone; undefined while running. */
31
+ endedAt?: number
32
+ }
33
+
34
+ export type BackgroundSnapshot = {
35
+ running: BackgroundProcess[]
36
+ /** Recently ended, newest first; kept for ENDED_TTL_MS. */
37
+ ended: BackgroundProcess[]
38
+ }
39
+
40
+ export const PS_ARGS = ["-ax", "-o", "pid=,ppid=,pgid=,etime=,command="]
41
+ const ENDED_TTL_MS = 60_000
42
+ const POLL_MS = 2000
43
+ const PS_TIMEOUT_MS = 3000
44
+
45
+ /** Parses `etime` (`SS`, `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`) into seconds. */
46
+ export function parseEtime(text: string): number {
47
+ const [daysPart, clock] = text.includes("-") ? text.split("-", 2) : [undefined, text]
48
+ const parts = (clock ?? "").split(":").map((part) => Number.parseInt(part, 10))
49
+ if (parts.some((part) => !Number.isFinite(part))) return 0
50
+ let seconds = 0
51
+ for (const part of parts) seconds = seconds * 60 + part
52
+ const days = daysPart === undefined ? 0 : Number.parseInt(daysPart, 10)
53
+ return seconds + (Number.isFinite(days) ? days : 0) * 86400
54
+ }
55
+
56
+ /** Parses `ps -ax -o pid=,ppid=,pgid=,etime=,command=` output. */
57
+ export function parsePs(stdout: string): Map<number, PsEntry> {
58
+ const entries = new Map<number, PsEntry>()
59
+ for (const line of stdout.split("\n")) {
60
+ const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line)
61
+ if (!match) continue
62
+ const pid = Number(match[1])
63
+ entries.set(pid, {
64
+ pid,
65
+ ppid: Number(match[2]),
66
+ pgid: Number(match[3]),
67
+ elapsedSec: parseEtime(match[4] ?? ""),
68
+ command: (match[5] ?? "").trim(),
69
+ })
70
+ }
71
+ return entries
72
+ }
73
+
74
+ /**
75
+ * Process groups a finished tool call left behind: processes present only in `after`, orphaned
76
+ * (ppid 1), whose group leader is a pid that did not exist in `before` and is gone in `after`.
77
+ * Any group that was alive before the call, or whose leader still runs, belongs to someone else.
78
+ */
79
+ export function findOrphanGroups(before: Map<number, PsEntry>, after: Map<number, PsEntry>): Set<number> {
80
+ const groupsBefore = new Set<number>()
81
+ for (const entry of before.values()) groupsBefore.add(entry.pgid)
82
+ const groups = new Set<number>()
83
+ for (const [pid, entry] of after) {
84
+ if (before.has(pid) || entry.ppid !== 1) continue
85
+ if (groupsBefore.has(entry.pgid) || after.has(entry.pgid)) continue
86
+ groups.add(entry.pgid)
87
+ }
88
+ return groups
89
+ }
90
+
91
+ export type BackgroundState = {
92
+ groups: Set<number>
93
+ processes: Map<number, BackgroundProcess>
94
+ }
95
+
96
+ export function createBackgroundState(): BackgroundState {
97
+ return { groups: new Set(), processes: new Map() }
98
+ }
99
+
100
+ /**
101
+ * Reconciles tracked processes with a fresh `ps` listing: records new members of tracked groups,
102
+ * marks vanished ones ended, forgets ended ones after their TTL, and drops empty groups.
103
+ * Returns whether anything changed.
104
+ */
105
+ export function applyPoll(state: BackgroundState, ps: Map<number, PsEntry>, now: number): boolean {
106
+ let changed = false
107
+ const seen = new Set<number>()
108
+ for (const entry of ps.values()) {
109
+ if (!state.groups.has(entry.pgid) || entry.pid === entry.pgid) continue
110
+ seen.add(entry.pid)
111
+ const known = state.processes.get(entry.pid)
112
+ if (known && known.endedAt === undefined) continue
113
+ state.processes.set(entry.pid, {
114
+ pid: entry.pid,
115
+ pgid: entry.pgid,
116
+ command: entry.command,
117
+ startedAt: now - entry.elapsedSec * 1000,
118
+ })
119
+ changed = true
120
+ }
121
+ for (const [pid, proc] of state.processes) {
122
+ if (seen.has(pid)) continue
123
+ if (proc.endedAt === undefined) {
124
+ proc.endedAt = now
125
+ changed = true
126
+ } else if (now - proc.endedAt > ENDED_TTL_MS) {
127
+ state.processes.delete(pid)
128
+ changed = true
129
+ }
130
+ }
131
+ for (const pgid of state.groups) {
132
+ const live = [...state.processes.values()].some((proc) => proc.pgid === pgid)
133
+ if (!live) {
134
+ state.groups.delete(pgid)
135
+ changed = true
136
+ }
137
+ }
138
+ return changed
139
+ }
140
+
141
+ export function snapshot(state: BackgroundState): BackgroundSnapshot {
142
+ const running: BackgroundProcess[] = []
143
+ const ended: BackgroundProcess[] = []
144
+ for (const proc of state.processes.values()) (proc.endedAt === undefined ? running : ended).push(proc)
145
+ running.sort((a, b) => a.startedAt - b.startedAt)
146
+ ended.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
147
+ return { running, ended }
148
+ }
149
+
150
+ export type BackgroundTracker = {
151
+ get(): BackgroundSnapshot
152
+ onChange(listener: () => void): () => void
153
+ toolStart(toolCallId: string, toolName: string): void
154
+ toolEnd(toolCallId: string, toolName: string): void
155
+ /** SIGTERM one process (or its whole group with `group: true`). */
156
+ kill(pid: number, options?: { group?: boolean; signal?: NodeJS.Signals }): boolean
157
+ dispose(): void
158
+ }
159
+
160
+ export type BackgroundTrackerDeps = {
161
+ pi: ExtensionAPI
162
+ now?: () => number
163
+ killProcess?: (pid: number, signal: NodeJS.Signals) => void
164
+ /** Tool names whose calls are inspected; defaults to pi's shell tools. */
165
+ tools?: readonly string[]
166
+ }
167
+
168
+ export function createBackgroundTracker(deps: BackgroundTrackerDeps): BackgroundTracker {
169
+ const now = deps.now ?? Date.now
170
+ const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal))
171
+ const tools = new Set(deps.tools ?? ["bash", "powershell"])
172
+ const state = createBackgroundState()
173
+ const listeners = createListeners()
174
+ const starts = new Map<string, Promise<Map<number, PsEntry> | undefined>>()
175
+ let alive = true
176
+ let timer: ReturnType<typeof setTimeout> | undefined
177
+ let polling = false
178
+
179
+ const listProcesses = async (): Promise<Map<number, PsEntry> | undefined> => {
180
+ try {
181
+ const run = await deps.pi.exec("ps", PS_ARGS, { timeout: PS_TIMEOUT_MS })
182
+ if (run.code !== 0 || run.killed) return undefined
183
+ return parsePs(run.stdout)
184
+ } catch {
185
+ return undefined
186
+ }
187
+ }
188
+
189
+ const schedule = () => {
190
+ if (!alive || timer || state.groups.size === 0) return
191
+ timer = setTimeout(() => {
192
+ timer = undefined
193
+ void poll()
194
+ }, POLL_MS)
195
+ }
196
+
197
+ const poll = async () => {
198
+ if (!alive || polling) return
199
+ polling = true
200
+ try {
201
+ const ps = await listProcesses()
202
+ if (!alive) return
203
+ if (ps && applyPoll(state, ps, now())) listeners.notify()
204
+ } finally {
205
+ polling = false
206
+ schedule()
207
+ }
208
+ }
209
+
210
+ return {
211
+ get: () => snapshot(state),
212
+ onChange: listeners.add,
213
+ toolStart(toolCallId, toolName) {
214
+ if (!alive || !tools.has(toolName)) return
215
+ starts.set(toolCallId, listProcesses())
216
+ },
217
+ toolEnd(toolCallId, toolName) {
218
+ const before = starts.get(toolCallId)
219
+ starts.delete(toolCallId)
220
+ if (!alive || !before || !tools.has(toolName)) return
221
+ void (async () => {
222
+ const [beforePs, afterPs] = await Promise.all([before, listProcesses()])
223
+ if (!alive || !beforePs || !afterPs) return
224
+ const groups = findOrphanGroups(beforePs, afterPs)
225
+ if (groups.size === 0) return
226
+ for (const pgid of groups) state.groups.add(pgid)
227
+ if (applyPoll(state, afterPs, now())) listeners.notify()
228
+ schedule()
229
+ })()
230
+ },
231
+ kill(pid, options = {}) {
232
+ const proc = state.processes.get(pid)
233
+ if (!proc) return false
234
+ try {
235
+ killProcess(options.group ? -proc.pgid : pid, options.signal ?? "SIGTERM")
236
+ return true
237
+ } catch {
238
+ return false
239
+ }
240
+ },
241
+ dispose() {
242
+ if (!alive) return
243
+ alive = false
244
+ if (timer) clearTimeout(timer)
245
+ timer = undefined
246
+ starts.clear()
247
+ listeners.clear()
248
+ },
249
+ }
250
+ }
package/src/config.ts CHANGED
@@ -9,11 +9,15 @@ export const EDIT_DIFF_MODES: readonly EditDiffMode[] = ["always", "never", "exp
9
9
  export type PiMinimalConfig = {
10
10
  hideGauntletWidgets: boolean
11
11
  editDiff: EditDiffMode
12
+ /** Key that opens the background-process overlay; `null` disables the shortcut (`/bg` still works). */
13
+ backgroundShortcut: string | null
12
14
  }
13
15
 
14
16
  const DEFAULTS: PiMinimalConfig = {
15
- hideGauntletWidgets: true,
17
+ // Hiding another extension's widgets is opt-in.
18
+ hideGauntletWidgets: false,
16
19
  editDiff: "expanded",
20
+ backgroundShortcut: "ctrl+b",
17
21
  }
18
22
 
19
23
  export function loadPiMinimalConfig(options: {
@@ -57,6 +61,11 @@ function applyLayer(config: PiMinimalConfig, path: string): void {
57
61
  if (typeof value === "boolean") config.hideGauntletWidgets = value
58
62
  else console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
59
63
  }
64
+ if (Object.hasOwn(record, "backgroundShortcut")) {
65
+ const value = record.backgroundShortcut
66
+ if (value === null || (typeof value === "string" && value.trim().length > 0)) config.backgroundShortcut = value === null ? null : value.trim()
67
+ else console.error(`pi-minimal: backgroundShortcut must be a key name such as "ctrl+b" or null in ${path}`)
68
+ }
60
69
  if (Object.hasOwn(record, "editDiff")) {
61
70
  const value = record.editDiff
62
71
  if (isEditDiffMode(value)) config.editDiff = value
package/src/cwd.ts CHANGED
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
3
3
  import path from "node:path"
4
4
  import * as chrome from "./chrome.js"
5
5
  import { formatGitState, type GitState } from "./git-state.js"
6
- import type { Sibling } from "./herdr.js"
6
+ import type { Sibling } from "./siblings.js"
7
7
 
8
8
  /** Tolerates a stale chrome module after /reload, where only CHROME_MARGIN may exist. */
9
9
  const margin = (): number => (typeof chrome.chromeMargin === "function" ? chrome.chromeMargin() : (chrome.CHROME_MARGIN ?? 1))
@@ -88,21 +88,22 @@ export function splitRow(left: string, rightSegments: string[], width: number, s
88
88
  function siblingColor(status: string): "success" | "warning" | "error" | "accent" | "muted" {
89
89
  if (status === "idle" || status === "done") return "success"
90
90
  if (status === "working") return "accent"
91
- if (status === "blocked") return "error"
91
+ if (status === "blocked" || status === "error") return "error"
92
92
  return "muted"
93
93
  }
94
94
 
95
95
  export type StatusLineSources = {
96
96
  gitState: () => GitState | undefined
97
97
  siblings: () => Sibling[] | undefined
98
- onGitChange: (listener: () => void) => () => void
98
+ /** Fires when git state or the sibling list may have changed; the widget re-renders. */
99
+ onChange: (listener: () => void) => () => void
99
100
  }
100
101
 
101
102
  export function installCwd(ctx: ExtensionContext, sources: StatusLineSources): void {
102
103
  ctx.ui.setWidget(
103
104
  "tw-cwd",
104
105
  (tui, theme) => {
105
- const unsub = sources.onGitChange(() => tui.requestRender())
106
+ const unsub = sources.onChange(() => tui.requestRender())
106
107
  return {
107
108
  dispose: unsub,
108
109
  invalidate() {},