pi-minimal 0.5.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 +35 -1
- package/CONTEXT.md +27 -3
- package/README.md +6 -3
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +8 -0
- package/dist/cwd.js +2 -2
- package/dist/footer.js +15 -3
- package/dist/herdr.js +78 -0
- package/dist/index.js +62 -1
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/rex.js +289 -0
- package/dist/session.js +72 -59
- package/dist/siblings.js +14 -0
- package/package.json +5 -3
- package/release-notes.md +29 -42
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +8 -0
- package/src/cwd.ts +5 -4
- package/src/footer.ts +23 -3
- package/src/herdr.ts +78 -5
- package/src/index.ts +63 -2
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/rex.ts +304 -0
- package/src/session.ts +76 -50
- package/src/siblings.ts +38 -0
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +23 -10
- package/test/footer-composition.test.ts +61 -0
- package/test/index.test.ts +157 -1
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,39 @@ Releases are cut automatically: when the `version` in `package.json` changes on
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.6.0] - 2026-10-29
|
|
14
|
+
|
|
15
|
+
Rex support and a footer hints line that carries live state: sibling agents from Rex as well as
|
|
16
|
+
Herdr, background processes left by bash calls, and the repo's open PRs as clickable chips.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- 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
|
|
21
|
+
authored or were asked to review, each an OSC 8 hyperlink to the PR, coloured accent (mine), dim
|
|
22
|
+
(my draft), amber (needs my review) or red (failing CI / changes requested). `/prs [mine|review|all]`
|
|
23
|
+
lists them with open / paste URL / checkout actions; `list_open_prs` tool for the model. Uses the
|
|
24
|
+
`gh` CLI; works on GitHub.com and GitHub Enterprise. PRs created by the session (`gh pr create`,
|
|
25
|
+
`gt submit`) are remembered as session entries and shown from any directory, as `repo#n` when
|
|
26
|
+
outside the cwd repo, until merged. New peer dependency `@earendil-works/pi-ai` (supplied by pi)
|
|
27
|
+
for the tool schema.
|
|
28
|
+
- Background processes: `⚙ N bg` after the key hints in the footer while processes left behind by `bash` tool
|
|
29
|
+
calls are running; `ctrl+b` / `/bg` opens an overlay listing each one with running/ended state and
|
|
30
|
+
duration, `k` kills the selection, `K` its process group. Attribution diffs `ps` around each bash
|
|
31
|
+
call using pi's detached process groups, so no command is rewritten. New `backgroundShortcut`
|
|
32
|
+
setting (`"ctrl+b"`; `null` disables the key).
|
|
33
|
+
|
|
34
|
+
- Rex siblings: inside a Rex session (`REX_SESSION` + `REX_BLOCK`) the status line shows other
|
|
35
|
+
panes' agent state, read from Rex's Program Status Protocol (OSC 7501) over one
|
|
36
|
+
`rex events --json` stream instead of polling. Needs a reporter in each pi (an extension that
|
|
37
|
+
writes OSC 7501 on `agent_start` / `agent_settled`); programs such as claude-code or terraform
|
|
38
|
+
that already speak the protocol appear as well. `error` renders like `blocked`.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Sibling status now comes from a `SiblingSource` (`src/siblings.ts`) picked from the environment;
|
|
43
|
+
the Herdr poller moved from `session.ts` into `herdr.ts` unchanged. Herdr wins when both hosts
|
|
44
|
+
are present. The status line re-renders when the sibling list changes, not only on git changes.
|
|
45
|
+
|
|
13
46
|
## [0.5.0] - 2026-10-27
|
|
14
47
|
|
|
15
48
|
Session lifecycle, one tool-row lifecycle, and a domain glossary. `hideGauntletWidgets`
|
|
@@ -201,7 +234,8 @@ is now opt-in and the 200 ms chrome steal interval is gone.
|
|
|
201
234
|
- Initial release: header/footer chrome, compact tool activity rows, working
|
|
202
235
|
status line, Herdr sibling status, Gauntlet widget hiding.
|
|
203
236
|
|
|
204
|
-
[Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.
|
|
237
|
+
[Unreleased]: https://github.com/raviy06/pi-minimal/compare/v0.6.0...HEAD
|
|
238
|
+
[0.6.0]: https://github.com/raviy06/pi-minimal/compare/v0.5.0...v0.6.0
|
|
205
239
|
[0.2.3]: https://github.com/raviy06/pi-minimal/compare/v0.2.2...v0.2.3
|
|
206
240
|
[0.2.2]: https://github.com/raviy06/pi-minimal/compare/v0.2.1...v0.2.2
|
|
207
241
|
[0.2.1]: https://github.com/raviy06/pi-minimal/compare/v0.2.0...v0.2.1
|
package/CONTEXT.md
CHANGED
|
@@ -12,8 +12,15 @@ When a word below has a narrower meaning than in everyday use, the narrower mean
|
|
|
12
12
|
- **Reclaim** — re-installing chrome after an event that may have let another extension draw over
|
|
13
13
|
it (`src/reclaim.ts`). A *reclaim reason* (`session_start`, `session_settle`, `turn_end`,
|
|
14
14
|
`tool_execution_end`) maps to a policy: whether to reset the editor and whether to reinstall chrome.
|
|
15
|
-
- **Status line** — `model · thinking · context meter · cwd · ⎇ branch ±dirty ·
|
|
16
|
-
(`src/cwd.ts`). *Siblings* are other
|
|
15
|
+
- **Status line** — `model · thinking · context meter · cwd · ⎇ branch ±dirty · siblings`
|
|
16
|
+
(`src/cwd.ts`). *Siblings* are other agents in the same host workspace with their state
|
|
17
|
+
(`working | blocked | idle | done | error`).
|
|
18
|
+
- **Sibling source** — where siblings come from (`src/siblings.ts`, `SiblingSource`: `get`,
|
|
19
|
+
`onChange`, `dispose`). Chosen from the environment at session start: the *Herdr source*
|
|
20
|
+
polls `herdr agent list` / `herdr tab list` (`src/herdr.ts`, `HERDR_ENV=1`); the *Rex source*
|
|
21
|
+
follows one `rex events -s SESSION --json` stream and reads OSC 7501 program-status records
|
|
22
|
+
(`src/rex.ts`, `REX_SESSION` + `REX_BLOCK`). Herdr wins when both are set. A sibling's name is
|
|
23
|
+
its tab label, or the program's `app` when the tab only has a Rex auto-label (`Window 1a2b…`).
|
|
17
24
|
- **Hints** — the keybinding labels in the footer (`send · thinking · expand tools`, plus
|
|
18
25
|
`/compact` from 70 % context), shown only when idle with an empty editor (`src/footer.ts`).
|
|
19
26
|
Not task hints and not the change hints other extensions emit.
|
|
@@ -31,6 +38,23 @@ When a word below has a narrower meaning than in everyday use, the narrower mean
|
|
|
31
38
|
- **Code box** — the faint frame around fenced code blocks (`src/code-box.ts`, a pi-tui
|
|
32
39
|
`Markdown.prototype.renderToken` patch).
|
|
33
40
|
|
|
41
|
+
- **Open PR** — an open pull request in the repo of `cwd` that I authored (*mine*) or that requests
|
|
42
|
+
my review (*review*); PRs that merely mention me are not shown (`src/prs.ts`, `PullRequest`,
|
|
43
|
+
`kind`). Found with `gh repo view` → `gh api user` (login differs per host) → `gh pr list
|
|
44
|
+
--search involves:@me`. Shown as *chips* right-aligned on the footer's hints line (`src/footer.ts`): `#1675`, an OSC 8 hyperlink to the
|
|
45
|
+
PR, coloured by `prColor` (trouble > review > draft > mine). `/prs` and the `list_open_prs` tool
|
|
46
|
+
live in `src/prs-command.ts`. The *PR source* (`PrSource`) polls every 3 min and on `refreshGit`,
|
|
47
|
+
throttled to once a minute. A **session PR** is one this session created: a `gh pr create` /
|
|
48
|
+
`gt submit` bash call whose output prints a PR URL (`findPrUrls`). Its URL is stored as a
|
|
49
|
+
`pi-minimal.pr` custom session entry (restored on resume) and fetched with `gh pr view URL`
|
|
50
|
+
whatever cwd is, labelled `repo#n` outside the cwd repo, until it is merged or closed.
|
|
51
|
+
- **Background process** — a process a `bash` tool call left running after it returned
|
|
52
|
+
(`src/background.ts`). Found by diffing `ps` before/after the call: new, orphaned (ppid 1) and in a
|
|
53
|
+
*tracked group*, a process group whose leader (the tool shell) was born and died inside the call.
|
|
54
|
+
Groups are polled every 2 s until empty. A process is `running` or `ended` (gone from `ps`, kept
|
|
55
|
+
60 s; no exit code is knowable). Shown as `⚙ N bg` after the hints on the footer's hints line (`src/footer.ts`) and listed in the
|
|
56
|
+
*background overlay* (`src/background-overlay.ts`, `/bg` or `backgroundShortcut`, default `ctrl+b`).
|
|
57
|
+
|
|
34
58
|
## Footer contributions (the extension point)
|
|
35
59
|
|
|
36
60
|
- **Contribution** — data another extension publishes for pi-minimal to render in the footer,
|
|
@@ -77,7 +101,7 @@ When a word below has a narrower meaning than in everyday use, the narrower mean
|
|
|
77
101
|
- **Load-time hooks** — installed once per extension instance: tool renderers, the markdown
|
|
78
102
|
transformer, the code-box patch, working status and the turn rule (`src/index.ts`).
|
|
79
103
|
- **Session** — everything scoped to one pi session: config, cwd, the contribution registry,
|
|
80
|
-
git
|
|
104
|
+
git poller, sibling source, settle timers and chrome (`src/session.ts`, when present). Created on
|
|
81
105
|
`session_start`, disposed on `session_shutdown` or when the next session starts.
|
|
82
106
|
- **Chrome margin** — pi's `outputPad`, mirrored on `globalThis` so module copies reloaded by
|
|
83
107
|
`/reload` agree (`src/chrome.ts`).
|
package/README.md
CHANGED
|
@@ -7,12 +7,13 @@ A Pi coding-agent package.
|
|
|
7
7
|
- Node.js `>=22.19.0`
|
|
8
8
|
- Pi `>=1.4.0` (uses `registerToolRenderer`, `registerEntryRenderer`, `registerMarkdownTransformer`)
|
|
9
9
|
|
|
10
|
-
No other Pi extension is required. Pi supplies the peer packages used by this extension.
|
|
10
|
+
No other Pi extension is required. Pi supplies the peer packages used by this extension. The `gh` CLI is needed only for the open-PR chips.
|
|
11
11
|
|
|
12
12
|
Optional integrations:
|
|
13
13
|
|
|
14
14
|
- `pi-gauntlet`: supplies the phase and task data shown in the footer. Set `hideGauntletWidgets: true` to also hide its own widgets, since the footer already shows that data.
|
|
15
|
-
- Herdr: sibling status appears
|
|
15
|
+
- Herdr: sibling status (other agents in the workspace, `working` / `blocked` / `idle`) appears when Pi runs inside a Herdr pane (`HERDR_ENV=1`). Herdr's own pi integration reports each pane's state.
|
|
16
|
+
- Rex: the same sibling status appears when Pi runs inside a [Rex](https://www.superlogical.com/rex) session (`REX_SESSION` + `REX_BLOCK`). Rex learns a pane's state from the [Program Status Protocol (OSC 7501)](https://www.superlogical.com/rex/docs/build/program-status), so each pi needs an extension that reports it (e.g. a `rex-agent-state.ts` that writes `ESC ] 7501 ; state=working:app=pi ESC \\` on `agent_start`); other programs that speak the protocol show up too. Herdr wins when both are present.
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
@@ -33,8 +34,10 @@ Do not load this package beside the live `grok-chrome` extension. Disable or rem
|
|
|
33
34
|
- **Tool rows** – every built-in tool call (`bash`, `read`, `edit`, `write`, `grep`, `find`, `ls`) renders as one line: status glyph (`◐` running, `✓` ok, `✗` failed), verb, item, and a right-aligned outcome (`34 lines · 1.0s`, `exit 1`, `+3 −1`, `4 matches`). Failed rows show the first line of output underneath; `ctrl+o` expands full output in a `│`-ruled block. `codemode` renders the same way, as a `code` row with one child row per nested tool call; `ctrl+o` shows the script and its output. Tools without a renderer of their own (most MCP tools) get the same one-line row; tools that draw themselves keep their look. Bash rows drop a leading `cd <session cwd> &&` and shorten other `cd` prefixes to the directory name.
|
|
34
35
|
- **Turn rule** – after each agent run a dim `── 15:51 · 12s · 4 tools ──` separator is appended (stored as a custom entry; never sent to the model).
|
|
35
36
|
- **Thinking gutter** – every line of a thinking paragraph, wrapped lines included, starts with `┊`, so thinking reads as a column beside prose.
|
|
36
|
-
- **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · Herdr
|
|
37
|
+
- **Status line** – one line above the editor: model · thinking level · context meter (amber at 70 %, red at 90 %) · cwd · `⎇ branch ±dirty` · siblings (other agents in the same Herdr workspace or Rex session, with their state).
|
|
37
38
|
- **Footer** – key hints appear only when the agent is idle and the editor is empty; `/compact` is suggested under context pressure.
|
|
39
|
+
- **Open PRs** – for the GitHub repo in the working directory (any host `gh` is logged in to), the footer shows chips for open PRs you authored or were asked to review, right-aligned on the key-hints line below the editor: `#1675 #1670 +3`. The chips stay while you type or the agent works, even though the hints hide. PRs the session itself creates (a `bash` call running `gh pr create` or `gt submit`) are remembered — also across `/resume` — and shown wherever the session is, even outside a repo, labelled `repo#1675` when they are not in the cwd repo; they drop off once merged or closed. Each chip is a terminal hyperlink to the PR (OSC 8; click or cmd-click in Rex, Ghostty, iTerm2, WezTerm, Kitty). Colours: accent = mine, dim = my draft, amber = waiting on my review, red = failing CI or changes requested. `/prs [mine|review|all]` lists them with actions (open in browser, paste URL into the editor, `gh pr checkout`); the `list_open_prs` tool gives the model the same list. Refreshed every 3 minutes and after each turn (at most once a minute). Needs the `gh` CLI.
|
|
40
|
+
- **Background processes** – when a `bash` tool call leaves something running (`npm run dev &`, `nohup ./watch.sh`), the footer shows `⚙ 2 bg` after the key hints below the editor (it stays while the hints hide). `ctrl+b` (or `/bg`) opens a list with each process, whether it is running or has ended, and for how long; `k` kills the selected process, `K` its whole process group. Pi kills these groups when it exits, so the list is scoped to this pi. Attribution uses process groups (pi spawns each bash call detached), so commands are never rewritten; a process that has already exited shows as `ended` without an exit code, since it was reparented away from pi. Set `backgroundShortcut` in `pi-minimal.json` to another key, or `null` to keep only `/bg`.
|
|
38
41
|
- **Code blocks** – fenced code renders as a closed box with the language as its title, keeping syntax highlighting, instead of literal ``` lines; long lines wrap inside the box.
|
|
39
42
|
- **Alignment** – chrome follows pi's `outputPad` setting. Pi's editor uses the separate `editorPaddingX` setting (default `0`); set `"editorPaddingX": 1` for a flush left edge with the transcript.
|
|
40
43
|
- **Theme** – ships `minimal-dark`: no background behind tool rows (failures excepted), a grey ladder whose readable tiers stay near 4.5:1, syntax colours kept off the status hues, visible user-message tint, dimmer thinking text, violet headings (yellow is reserved for warnings), and faint code-block frames. Select it in `/settings` → Theme, or set `"theme": "minimal-dark"`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { formatElapsed } from "./working-status.js";
|
|
3
|
+
export function backgroundRows(snap, now) {
|
|
4
|
+
const row = (proc) => ({
|
|
5
|
+
pid: proc.pid,
|
|
6
|
+
command: proc.command,
|
|
7
|
+
status: proc.endedAt === undefined ? "running" : "ended",
|
|
8
|
+
durationMs: (proc.endedAt ?? now) - proc.startedAt,
|
|
9
|
+
});
|
|
10
|
+
return [...snap.running.map(row), ...snap.ended.map(row)];
|
|
11
|
+
}
|
|
12
|
+
/** Status-line segment: `⚙ 2 bg`; undefined when nothing is running. */
|
|
13
|
+
export function backgroundSegment(snap) {
|
|
14
|
+
if (snap.running.length === 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
return `⚙ ${snap.running.length} bg`;
|
|
17
|
+
}
|
|
18
|
+
/** Lines of the overlay body for a given width; pure so it can be tested without a TUI. */
|
|
19
|
+
export function renderBackgroundLines(rows, selected, width, theme, shortcut) {
|
|
20
|
+
const title = theme.fg("accent", "Background processes");
|
|
21
|
+
const hint = theme.fg("dim", shortcut ? `↑↓ select · k kill · K kill group · esc close (${shortcut})` : "↑↓ select · k kill · K kill group · esc close");
|
|
22
|
+
if (rows.length === 0) {
|
|
23
|
+
return [title, theme.fg("muted", "Nothing running in the background."), hint].map((line) => truncateToWidth(line, width));
|
|
24
|
+
}
|
|
25
|
+
const statusWidth = 7;
|
|
26
|
+
const durations = rows.map((row) => (row.status === "running" ? formatElapsed(row.durationMs) : `ran ${formatElapsed(row.durationMs)}`));
|
|
27
|
+
const durationWidth = Math.max(...durations.map((text) => text.length));
|
|
28
|
+
const commandWidth = Math.max(8, width - 2 - 1 - statusWidth - 2 - durationWidth);
|
|
29
|
+
const lines = rows.map((row, index) => {
|
|
30
|
+
const marker = index === selected ? theme.fg("accent", "› ") : " ";
|
|
31
|
+
const command = truncateToWidth(row.command, commandWidth, "…").padEnd(commandWidth);
|
|
32
|
+
const status = row.status === "running" ? theme.fg("success", "running") : theme.fg("muted", "ended ");
|
|
33
|
+
const duration = theme.fg("dim", (durations[index] ?? "").padStart(durationWidth));
|
|
34
|
+
const text = `${marker}${index === selected ? theme.fg("text", command) : theme.fg("muted", command)} ${status} ${duration}`;
|
|
35
|
+
return visibleWidth(text) > width ? truncateToWidth(text, width) : text;
|
|
36
|
+
});
|
|
37
|
+
return [title, ...lines, hint];
|
|
38
|
+
}
|
|
39
|
+
/** Shows the overlay until the user closes it. Resolves when closed. */
|
|
40
|
+
export async function showBackgroundOverlay(ctx, tracker, shortcut) {
|
|
41
|
+
if (ctx.mode !== "tui") {
|
|
42
|
+
const rows = backgroundRows(tracker.get(), Date.now());
|
|
43
|
+
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");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
47
|
+
let selected = 0;
|
|
48
|
+
let cached;
|
|
49
|
+
const invalidate = () => { cached = undefined; };
|
|
50
|
+
const redraw = () => { invalidate(); tui.requestRender(); };
|
|
51
|
+
const unsubscribe = tracker.onChange(redraw);
|
|
52
|
+
const ticker = setInterval(redraw, 1000);
|
|
53
|
+
const rows = () => backgroundRows(tracker.get(), Date.now());
|
|
54
|
+
const finish = () => {
|
|
55
|
+
clearInterval(ticker);
|
|
56
|
+
unsubscribe();
|
|
57
|
+
done();
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
render(width) {
|
|
61
|
+
if (cached && cached.width === width)
|
|
62
|
+
return cached.lines;
|
|
63
|
+
const current = rows();
|
|
64
|
+
if (selected >= current.length)
|
|
65
|
+
selected = Math.max(0, current.length - 1);
|
|
66
|
+
cached = { width, lines: renderBackgroundLines(current, selected, width, theme, shortcut) };
|
|
67
|
+
return cached.lines;
|
|
68
|
+
},
|
|
69
|
+
handleInput(data) {
|
|
70
|
+
const current = rows();
|
|
71
|
+
if (keybindings.matches(data, "tui.select.cancel")) {
|
|
72
|
+
finish();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
76
|
+
selected = current.length === 0 ? 0 : (selected - 1 + current.length) % current.length;
|
|
77
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
78
|
+
selected = current.length === 0 ? 0 : (selected + 1) % current.length;
|
|
79
|
+
else if (data === "k" || data === "K") {
|
|
80
|
+
const row = current[selected];
|
|
81
|
+
if (row?.status === "running")
|
|
82
|
+
tracker.kill(row.pid, { group: data === "K" });
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
return;
|
|
86
|
+
redraw();
|
|
87
|
+
},
|
|
88
|
+
invalidate,
|
|
89
|
+
dispose() {
|
|
90
|
+
clearInterval(ticker);
|
|
91
|
+
unsubscribe();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}, { overlay: true, overlayOptions: { width: "70%", maxHeight: "60%", anchor: "center" } });
|
|
95
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createListeners } from "./listeners.js";
|
|
2
|
+
export const PS_ARGS = ["-ax", "-o", "pid=,ppid=,pgid=,etime=,command="];
|
|
3
|
+
const ENDED_TTL_MS = 60_000;
|
|
4
|
+
const POLL_MS = 2000;
|
|
5
|
+
const PS_TIMEOUT_MS = 3000;
|
|
6
|
+
/** Parses `etime` (`SS`, `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`) into seconds. */
|
|
7
|
+
export function parseEtime(text) {
|
|
8
|
+
const [daysPart, clock] = text.includes("-") ? text.split("-", 2) : [undefined, text];
|
|
9
|
+
const parts = (clock ?? "").split(":").map((part) => Number.parseInt(part, 10));
|
|
10
|
+
if (parts.some((part) => !Number.isFinite(part)))
|
|
11
|
+
return 0;
|
|
12
|
+
let seconds = 0;
|
|
13
|
+
for (const part of parts)
|
|
14
|
+
seconds = seconds * 60 + part;
|
|
15
|
+
const days = daysPart === undefined ? 0 : Number.parseInt(daysPart, 10);
|
|
16
|
+
return seconds + (Number.isFinite(days) ? days : 0) * 86400;
|
|
17
|
+
}
|
|
18
|
+
/** Parses `ps -ax -o pid=,ppid=,pgid=,etime=,command=` output. */
|
|
19
|
+
export function parsePs(stdout) {
|
|
20
|
+
const entries = new Map();
|
|
21
|
+
for (const line of stdout.split("\n")) {
|
|
22
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
|
|
23
|
+
if (!match)
|
|
24
|
+
continue;
|
|
25
|
+
const pid = Number(match[1]);
|
|
26
|
+
entries.set(pid, {
|
|
27
|
+
pid,
|
|
28
|
+
ppid: Number(match[2]),
|
|
29
|
+
pgid: Number(match[3]),
|
|
30
|
+
elapsedSec: parseEtime(match[4] ?? ""),
|
|
31
|
+
command: (match[5] ?? "").trim(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return entries;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Process groups a finished tool call left behind: processes present only in `after`, orphaned
|
|
38
|
+
* (ppid 1), whose group leader is a pid that did not exist in `before` and is gone in `after`.
|
|
39
|
+
* Any group that was alive before the call, or whose leader still runs, belongs to someone else.
|
|
40
|
+
*/
|
|
41
|
+
export function findOrphanGroups(before, after) {
|
|
42
|
+
const groupsBefore = new Set();
|
|
43
|
+
for (const entry of before.values())
|
|
44
|
+
groupsBefore.add(entry.pgid);
|
|
45
|
+
const groups = new Set();
|
|
46
|
+
for (const [pid, entry] of after) {
|
|
47
|
+
if (before.has(pid) || entry.ppid !== 1)
|
|
48
|
+
continue;
|
|
49
|
+
if (groupsBefore.has(entry.pgid) || after.has(entry.pgid))
|
|
50
|
+
continue;
|
|
51
|
+
groups.add(entry.pgid);
|
|
52
|
+
}
|
|
53
|
+
return groups;
|
|
54
|
+
}
|
|
55
|
+
export function createBackgroundState() {
|
|
56
|
+
return { groups: new Set(), processes: new Map() };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Reconciles tracked processes with a fresh `ps` listing: records new members of tracked groups,
|
|
60
|
+
* marks vanished ones ended, forgets ended ones after their TTL, and drops empty groups.
|
|
61
|
+
* Returns whether anything changed.
|
|
62
|
+
*/
|
|
63
|
+
export function applyPoll(state, ps, now) {
|
|
64
|
+
let changed = false;
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
for (const entry of ps.values()) {
|
|
67
|
+
if (!state.groups.has(entry.pgid) || entry.pid === entry.pgid)
|
|
68
|
+
continue;
|
|
69
|
+
seen.add(entry.pid);
|
|
70
|
+
const known = state.processes.get(entry.pid);
|
|
71
|
+
if (known && known.endedAt === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
state.processes.set(entry.pid, {
|
|
74
|
+
pid: entry.pid,
|
|
75
|
+
pgid: entry.pgid,
|
|
76
|
+
command: entry.command,
|
|
77
|
+
startedAt: now - entry.elapsedSec * 1000,
|
|
78
|
+
});
|
|
79
|
+
changed = true;
|
|
80
|
+
}
|
|
81
|
+
for (const [pid, proc] of state.processes) {
|
|
82
|
+
if (seen.has(pid))
|
|
83
|
+
continue;
|
|
84
|
+
if (proc.endedAt === undefined) {
|
|
85
|
+
proc.endedAt = now;
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
else if (now - proc.endedAt > ENDED_TTL_MS) {
|
|
89
|
+
state.processes.delete(pid);
|
|
90
|
+
changed = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const pgid of state.groups) {
|
|
94
|
+
const live = [...state.processes.values()].some((proc) => proc.pgid === pgid);
|
|
95
|
+
if (!live) {
|
|
96
|
+
state.groups.delete(pgid);
|
|
97
|
+
changed = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return changed;
|
|
101
|
+
}
|
|
102
|
+
export function snapshot(state) {
|
|
103
|
+
const running = [];
|
|
104
|
+
const ended = [];
|
|
105
|
+
for (const proc of state.processes.values())
|
|
106
|
+
(proc.endedAt === undefined ? running : ended).push(proc);
|
|
107
|
+
running.sort((a, b) => a.startedAt - b.startedAt);
|
|
108
|
+
ended.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
|
|
109
|
+
return { running, ended };
|
|
110
|
+
}
|
|
111
|
+
export function createBackgroundTracker(deps) {
|
|
112
|
+
const now = deps.now ?? Date.now;
|
|
113
|
+
const killProcess = deps.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
|
114
|
+
const tools = new Set(deps.tools ?? ["bash", "powershell"]);
|
|
115
|
+
const state = createBackgroundState();
|
|
116
|
+
const listeners = createListeners();
|
|
117
|
+
const starts = new Map();
|
|
118
|
+
let alive = true;
|
|
119
|
+
let timer;
|
|
120
|
+
let polling = false;
|
|
121
|
+
const listProcesses = async () => {
|
|
122
|
+
try {
|
|
123
|
+
const run = await deps.pi.exec("ps", PS_ARGS, { timeout: PS_TIMEOUT_MS });
|
|
124
|
+
if (run.code !== 0 || run.killed)
|
|
125
|
+
return undefined;
|
|
126
|
+
return parsePs(run.stdout);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const schedule = () => {
|
|
133
|
+
if (!alive || timer || state.groups.size === 0)
|
|
134
|
+
return;
|
|
135
|
+
timer = setTimeout(() => {
|
|
136
|
+
timer = undefined;
|
|
137
|
+
void poll();
|
|
138
|
+
}, POLL_MS);
|
|
139
|
+
};
|
|
140
|
+
const poll = async () => {
|
|
141
|
+
if (!alive || polling)
|
|
142
|
+
return;
|
|
143
|
+
polling = true;
|
|
144
|
+
try {
|
|
145
|
+
const ps = await listProcesses();
|
|
146
|
+
if (!alive)
|
|
147
|
+
return;
|
|
148
|
+
if (ps && applyPoll(state, ps, now()))
|
|
149
|
+
listeners.notify();
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
polling = false;
|
|
153
|
+
schedule();
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
get: () => snapshot(state),
|
|
158
|
+
onChange: listeners.add,
|
|
159
|
+
toolStart(toolCallId, toolName) {
|
|
160
|
+
if (!alive || !tools.has(toolName))
|
|
161
|
+
return;
|
|
162
|
+
starts.set(toolCallId, listProcesses());
|
|
163
|
+
},
|
|
164
|
+
toolEnd(toolCallId, toolName) {
|
|
165
|
+
const before = starts.get(toolCallId);
|
|
166
|
+
starts.delete(toolCallId);
|
|
167
|
+
if (!alive || !before || !tools.has(toolName))
|
|
168
|
+
return;
|
|
169
|
+
void (async () => {
|
|
170
|
+
const [beforePs, afterPs] = await Promise.all([before, listProcesses()]);
|
|
171
|
+
if (!alive || !beforePs || !afterPs)
|
|
172
|
+
return;
|
|
173
|
+
const groups = findOrphanGroups(beforePs, afterPs);
|
|
174
|
+
if (groups.size === 0)
|
|
175
|
+
return;
|
|
176
|
+
for (const pgid of groups)
|
|
177
|
+
state.groups.add(pgid);
|
|
178
|
+
if (applyPoll(state, afterPs, now()))
|
|
179
|
+
listeners.notify();
|
|
180
|
+
schedule();
|
|
181
|
+
})();
|
|
182
|
+
},
|
|
183
|
+
kill(pid, options = {}) {
|
|
184
|
+
const proc = state.processes.get(pid);
|
|
185
|
+
if (!proc)
|
|
186
|
+
return false;
|
|
187
|
+
try {
|
|
188
|
+
killProcess(options.group ? -proc.pgid : pid, options.signal ?? "SIGTERM");
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
dispose() {
|
|
196
|
+
if (!alive)
|
|
197
|
+
return;
|
|
198
|
+
alive = false;
|
|
199
|
+
if (timer)
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
timer = undefined;
|
|
202
|
+
starts.clear();
|
|
203
|
+
listeners.clear();
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -6,6 +6,7 @@ const DEFAULTS = {
|
|
|
6
6
|
// Hiding another extension's widgets is opt-in.
|
|
7
7
|
hideGauntletWidgets: false,
|
|
8
8
|
editDiff: "expanded",
|
|
9
|
+
backgroundShortcut: "ctrl+b",
|
|
9
10
|
};
|
|
10
11
|
export function loadPiMinimalConfig(options) {
|
|
11
12
|
const config = { ...DEFAULTS };
|
|
@@ -44,6 +45,13 @@ function applyLayer(config, path) {
|
|
|
44
45
|
else
|
|
45
46
|
console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`);
|
|
46
47
|
}
|
|
48
|
+
if (Object.hasOwn(record, "backgroundShortcut")) {
|
|
49
|
+
const value = record.backgroundShortcut;
|
|
50
|
+
if (value === null || (typeof value === "string" && value.trim().length > 0))
|
|
51
|
+
config.backgroundShortcut = value === null ? null : value.trim();
|
|
52
|
+
else
|
|
53
|
+
console.error(`pi-minimal: backgroundShortcut must be a key name such as "ctrl+b" or null in ${path}`);
|
|
54
|
+
}
|
|
47
55
|
if (Object.hasOwn(record, "editDiff")) {
|
|
48
56
|
const value = record.editDiff;
|
|
49
57
|
if (isEditDiffMode(value))
|
package/dist/cwd.js
CHANGED
|
@@ -86,13 +86,13 @@ function siblingColor(status) {
|
|
|
86
86
|
return "success";
|
|
87
87
|
if (status === "working")
|
|
88
88
|
return "accent";
|
|
89
|
-
if (status === "blocked")
|
|
89
|
+
if (status === "blocked" || status === "error")
|
|
90
90
|
return "error";
|
|
91
91
|
return "muted";
|
|
92
92
|
}
|
|
93
93
|
export function installCwd(ctx, sources) {
|
|
94
94
|
ctx.ui.setWidget("tw-cwd", (tui, theme) => {
|
|
95
|
-
const unsub = sources.
|
|
95
|
+
const unsub = sources.onChange(() => tui.requestRender());
|
|
96
96
|
return {
|
|
97
97
|
dispose: unsub,
|
|
98
98
|
invalidate() { },
|
package/dist/footer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { keyHint } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import * as chrome from "./chrome.js";
|
|
4
|
+
import { splitRow } from "./cwd.js";
|
|
4
5
|
import { formatTrackerLines } from "./tracker.js";
|
|
5
6
|
import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js";
|
|
6
7
|
import { createLiveWatch } from "./live-watch.js";
|
|
@@ -45,12 +46,16 @@ export function installFooter(ctx, sources, contributions) {
|
|
|
45
46
|
const update = () => { syncAnimation(); redraw(); };
|
|
46
47
|
const unsub = footerData.onBranchChange(redraw);
|
|
47
48
|
const unsubscribeActivity = contributions?.subscribe(update);
|
|
49
|
+
const unsubscribePrs = sources.onPrsChange?.(redraw);
|
|
50
|
+
const unsubscribeBackground = sources.onBackgroundChange?.(redraw);
|
|
48
51
|
stopMount = () => {
|
|
49
52
|
if (disposed)
|
|
50
53
|
return;
|
|
51
54
|
disposed = true;
|
|
52
55
|
unsub();
|
|
53
56
|
unsubscribeActivity?.();
|
|
57
|
+
unsubscribePrs?.();
|
|
58
|
+
unsubscribeBackground?.();
|
|
54
59
|
live.stop();
|
|
55
60
|
};
|
|
56
61
|
update();
|
|
@@ -65,9 +70,16 @@ export function installFooter(ctx, sources, contributions) {
|
|
|
65
70
|
keyHint("app.thinking.cycle", "thinking"),
|
|
66
71
|
keyHint("app.tools.expand", "expand tools"),
|
|
67
72
|
].filter(Boolean));
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
// Left: hints (idle only) then the background count (always). Right: PR chips whenever
|
|
74
|
+
// there are any. The chips are dropped before the left side when the line is too narrow.
|
|
75
|
+
const background = sources.background?.();
|
|
76
|
+
const left = [hints.length > 0 ? theme.fg("dim", hints.join(" · ")) : "", background ? theme.fg("warning", background) : ""]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join(theme.fg("dim", " · "));
|
|
79
|
+
const prs = sources.prs?.(theme);
|
|
80
|
+
if (left || prs) {
|
|
81
|
+
const line = prs ? splitRow(left, [prs], inner) : truncateToWidth(left, inner, "");
|
|
82
|
+
out.push(`${" ".repeat(margin())}${line}`);
|
|
71
83
|
}
|
|
72
84
|
const activityWidth = Math.max(0, width - margin() * 2);
|
|
73
85
|
const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme);
|
package/dist/herdr.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createListeners } from "./listeners.js";
|
|
1
2
|
function parseJson(stdout) {
|
|
2
3
|
return JSON.parse(stdout);
|
|
3
4
|
}
|
|
@@ -42,3 +43,80 @@ export function listSiblings(agents, tabs, selfPane, selfTab) {
|
|
|
42
43
|
return undefined;
|
|
43
44
|
return parts;
|
|
44
45
|
}
|
|
46
|
+
const HERDR_FAST_MS = 2000;
|
|
47
|
+
const HERDR_SLOW_MS = 10000;
|
|
48
|
+
const HERDR_EXEC_TIMEOUT_MS = 3000;
|
|
49
|
+
/**
|
|
50
|
+
* Polls `herdr agent list` + `herdr tab list`: every 2 s while siblings are visible, every 10 s
|
|
51
|
+
* otherwise or after a failure. Probes once immediately.
|
|
52
|
+
*/
|
|
53
|
+
export function createHerdrSiblings(pi, env = process.env) {
|
|
54
|
+
let alive = true;
|
|
55
|
+
let siblings;
|
|
56
|
+
let timer;
|
|
57
|
+
let period = 0;
|
|
58
|
+
let inFlight;
|
|
59
|
+
const listeners = createListeners();
|
|
60
|
+
const set = (next) => {
|
|
61
|
+
const changed = JSON.stringify(next) !== JSON.stringify(siblings);
|
|
62
|
+
siblings = next;
|
|
63
|
+
if (changed)
|
|
64
|
+
listeners.notify();
|
|
65
|
+
};
|
|
66
|
+
const schedule = (periodMs) => {
|
|
67
|
+
if (!alive || period === periodMs)
|
|
68
|
+
return;
|
|
69
|
+
if (timer)
|
|
70
|
+
clearInterval(timer);
|
|
71
|
+
period = periodMs;
|
|
72
|
+
timer = setInterval(() => { void probe(); }, periodMs);
|
|
73
|
+
};
|
|
74
|
+
const probe = async () => {
|
|
75
|
+
if (!alive || inFlight)
|
|
76
|
+
return;
|
|
77
|
+
const ac = new AbortController();
|
|
78
|
+
inFlight = ac;
|
|
79
|
+
try {
|
|
80
|
+
const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal });
|
|
81
|
+
if (!alive)
|
|
82
|
+
return;
|
|
83
|
+
const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal });
|
|
84
|
+
if (!alive)
|
|
85
|
+
return;
|
|
86
|
+
if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
|
|
87
|
+
set(undefined);
|
|
88
|
+
schedule(HERDR_SLOW_MS);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
set(listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), env.HERDR_PANE_ID, env.HERDR_TAB_ID));
|
|
92
|
+
schedule(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
if (!alive)
|
|
96
|
+
return;
|
|
97
|
+
set(undefined);
|
|
98
|
+
schedule(HERDR_SLOW_MS);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
if (inFlight === ac)
|
|
102
|
+
inFlight = undefined;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
void probe();
|
|
106
|
+
return {
|
|
107
|
+
get: () => siblings,
|
|
108
|
+
onChange: listeners.add,
|
|
109
|
+
dispose() {
|
|
110
|
+
if (!alive)
|
|
111
|
+
return;
|
|
112
|
+
alive = false;
|
|
113
|
+
if (timer)
|
|
114
|
+
clearInterval(timer);
|
|
115
|
+
timer = undefined;
|
|
116
|
+
inFlight?.abort();
|
|
117
|
+
inFlight = undefined;
|
|
118
|
+
siblings = undefined;
|
|
119
|
+
listeners.clear();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|