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.
- package/CHANGELOG.md +81 -1
- package/CONTEXT.md +107 -0
- package/README.md +9 -6
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +10 -1
- package/dist/cwd.js +2 -2
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/footer.js +15 -3
- package/dist/git-state.js +31 -11
- package/dist/herdr.js +78 -0
- package/dist/index.js +79 -202
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/reclaim.js +0 -3
- package/dist/rex.js +289 -0
- package/dist/session.js +168 -0
- package/dist/siblings.js +14 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +5 -3
- package/release-notes.md +33 -12
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +10 -1
- package/src/cwd.ts +5 -4
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/footer.ts +23 -3
- package/src/git-state.ts +29 -10
- package/src/herdr.ts +78 -5
- package/src/index.ts +81 -195
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/reclaim.ts +0 -4
- package/src/rex.ts +304 -0
- package/src/session.ts +185 -0
- package/src/siblings.ts +38 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +24 -11
- package/test/footer-composition.test.ts +61 -0
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +202 -4
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /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
|
|
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 } =
|
|
24
|
+
const { phases, tasks } = readGauntletProjection(ctx);
|
|
25
25
|
const lines = [];
|
|
26
26
|
if (phases && phaseActive(phases)) {
|
|
27
27
|
const parts = PHASES.map((p) => {
|
package/dist/working-status.js
CHANGED
|
@@ -184,8 +184,9 @@ export function installWorkingStatus(pi) {
|
|
|
184
184
|
stop();
|
|
185
185
|
});
|
|
186
186
|
pi.on("session_shutdown", () => {
|
|
187
|
-
ctx
|
|
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
|
}
|
|
@@ -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 `
|
|
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
|
|
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;
|
|
78
|
-
|
|
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
|
|
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.
|
|
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
|
-
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 "./
|
|
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
|
-
|
|
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.
|
|
106
|
+
const unsub = sources.onChange(() => tui.requestRender())
|
|
106
107
|
return {
|
|
107
108
|
dispose: unsub,
|
|
108
109
|
invalidate() {},
|