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.
@@ -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,12 +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.
16
18
  hideGauntletWidgets: false,
17
19
  editDiff: "expanded",
20
+ backgroundShortcut: "ctrl+b",
18
21
  }
19
22
 
20
23
  export function loadPiMinimalConfig(options: {
@@ -58,6 +61,11 @@ function applyLayer(config: PiMinimalConfig, path: string): void {
58
61
  if (typeof value === "boolean") config.hideGauntletWidgets = value
59
62
  else console.error(`pi-minimal: hideGauntletWidgets must be a boolean in ${path}`)
60
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
+ }
61
69
  if (Object.hasOwn(record, "editDiff")) {
62
70
  const value = record.editDiff
63
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() {},
package/src/footer.ts CHANGED
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
2
2
  import { keyHint } from "@earendil-works/pi-coding-agent"
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui"
4
4
  import * as chrome from "./chrome.js"
5
+ import { splitRow } from "./cwd.js"
5
6
  import { formatTrackerLines } from "./tracker.js"
6
7
  import type { FooterContributions } from "./footer-contributions.js"
7
8
  import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js"
@@ -19,6 +20,14 @@ export type FooterSources = {
19
20
  editorText: () => string
20
21
  /** Context usage percentage, when known. */
21
22
  contextPct: () => number | undefined
23
+ /** Open PR chips (`#1675 #1670 +3`), themed and linked, shown right-aligned on the hints line. */
24
+ prs?: (theme: { fg(color: string, text: string): string }) => string | undefined
25
+ /** Fires when the PR chips may have changed. */
26
+ onPrsChange?: (listener: () => void) => () => void
27
+ /** `⚙ 2 bg` while bash-tool processes run in the background; shown after the hints. */
28
+ background?: () => string | undefined
29
+ /** Fires when the background count may have changed. */
30
+ onBackgroundChange?: (listener: () => void) => () => void
22
31
  }
23
32
 
24
33
  /**
@@ -52,11 +61,15 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
52
61
  const update = () => { syncAnimation(); redraw() }
53
62
  const unsub = footerData.onBranchChange(redraw)
54
63
  const unsubscribeActivity = contributions?.subscribe(update)
64
+ const unsubscribePrs = sources.onPrsChange?.(redraw)
65
+ const unsubscribeBackground = sources.onBackgroundChange?.(redraw)
55
66
  stopMount = () => {
56
67
  if (disposed) return
57
68
  disposed = true
58
69
  unsub()
59
70
  unsubscribeActivity?.()
71
+ unsubscribePrs?.()
72
+ unsubscribeBackground?.()
60
73
  live.stop()
61
74
  }
62
75
  update()
@@ -71,9 +84,16 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
71
84
  keyHint("app.thinking.cycle", "thinking"),
72
85
  keyHint("app.tools.expand", "expand tools"),
73
86
  ].filter(Boolean))
74
- if (hints.length > 0) {
75
- const line = theme.fg("dim", hints.join(" · "))
76
- out.push(`${" ".repeat(margin())}${truncateToWidth(line, inner, "")}`)
87
+ // Left: hints (idle only) then the background count (always). Right: PR chips whenever
88
+ // there are any. The chips are dropped before the left side when the line is too narrow.
89
+ const background = sources.background?.()
90
+ const left = [hints.length > 0 ? theme.fg("dim", hints.join(" · ")) : "", background ? theme.fg("warning", background) : ""]
91
+ .filter(Boolean)
92
+ .join(theme.fg("dim", " · "))
93
+ const prs = sources.prs?.(theme)
94
+ if (left || prs) {
95
+ const line = prs ? splitRow(left, [prs], inner) : truncateToWidth(left, inner, "")
96
+ out.push(`${" ".repeat(margin())}${line}`)
77
97
  }
78
98
  const activityWidth = Math.max(0, width - margin() * 2)
79
99
  const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme)
package/src/herdr.ts CHANGED
@@ -1,3 +1,9 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { createListeners } from "./listeners.js"
3
+ import type { Sibling, SiblingSource } from "./siblings.js"
4
+
5
+ export type { Sibling } from "./siblings.js"
6
+
1
7
  export type HerdrAgent = {
2
8
  agent: string
3
9
  agent_status: string
@@ -40,11 +46,6 @@ export function parseTabList(stdout: string): HerdrTab[] {
40
46
  }))
41
47
  }
42
48
 
43
- export type Sibling = {
44
- name: string
45
- status: string
46
- }
47
-
48
49
  export function listSiblings(
49
50
  agents: HerdrAgent[],
50
51
  tabs: HerdrTab[],
@@ -64,3 +65,75 @@ export function listSiblings(
64
65
  if (parts.length === 0) return undefined
65
66
  return parts
66
67
  }
68
+
69
+ const HERDR_FAST_MS = 2000
70
+ const HERDR_SLOW_MS = 10000
71
+ const HERDR_EXEC_TIMEOUT_MS = 3000
72
+
73
+ /**
74
+ * Polls `herdr agent list` + `herdr tab list`: every 2 s while siblings are visible, every 10 s
75
+ * otherwise or after a failure. Probes once immediately.
76
+ */
77
+ export function createHerdrSiblings(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env): SiblingSource {
78
+ let alive = true
79
+ let siblings: Sibling[] | undefined
80
+ let timer: ReturnType<typeof setInterval> | undefined
81
+ let period = 0
82
+ let inFlight: AbortController | undefined
83
+ const listeners = createListeners()
84
+
85
+ const set = (next: Sibling[] | undefined) => {
86
+ const changed = JSON.stringify(next) !== JSON.stringify(siblings)
87
+ siblings = next
88
+ if (changed) listeners.notify()
89
+ }
90
+
91
+ const schedule = (periodMs: number) => {
92
+ if (!alive || period === periodMs) return
93
+ if (timer) clearInterval(timer)
94
+ period = periodMs
95
+ timer = setInterval(() => { void probe() }, periodMs)
96
+ }
97
+
98
+ const probe = async () => {
99
+ if (!alive || inFlight) return
100
+ const ac = new AbortController()
101
+ inFlight = ac
102
+ try {
103
+ const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
104
+ if (!alive) return
105
+ const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
106
+ if (!alive) return
107
+ if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
108
+ set(undefined)
109
+ schedule(HERDR_SLOW_MS)
110
+ return
111
+ }
112
+ set(listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), env.HERDR_PANE_ID, env.HERDR_TAB_ID))
113
+ schedule(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS)
114
+ } catch {
115
+ if (!alive) return
116
+ set(undefined)
117
+ schedule(HERDR_SLOW_MS)
118
+ } finally {
119
+ if (inFlight === ac) inFlight = undefined
120
+ }
121
+ }
122
+
123
+ void probe()
124
+
125
+ return {
126
+ get: () => siblings,
127
+ onChange: listeners.add,
128
+ dispose() {
129
+ if (!alive) return
130
+ alive = false
131
+ if (timer) clearInterval(timer)
132
+ timer = undefined
133
+ inFlight?.abort()
134
+ inFlight = undefined
135
+ siblings = undefined
136
+ listeners.clear()
137
+ },
138
+ }
139
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
1
+ import { Type } from "@earendil-works/pi-ai"
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
3
+ import { showBackgroundOverlay } from "./background-overlay.js"
2
4
  import { installCodeBox } from "./code-box.js"
5
+ import { isPrCreateCommand } from "./prs.js"
6
+ import { parsePrFilter, prsToolResult, runPrsCommand, type PrFilter } from "./prs-command.js"
3
7
  import { createSession, type Session } from "./session.js"
4
8
  import { installToolRenderers, stopLiveWatch } from "./tools.js"
5
9
  import { installTranscript } from "./transcript.js"
@@ -21,9 +25,61 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
21
25
  let active: Session | undefined
22
26
  let busy = false
23
27
 
28
+ // Background processes: `/bg` and a shortcut (default ctrl+b, see `backgroundShortcut`). The
29
+ // shortcut is registered once per key at load, since pi has no unregister; it reads the active
30
+ // session's config when pressed.
31
+ const openBackground = async (ctx: ExtensionContext) => {
32
+ if (!active?.background) return
33
+ await showBackgroundOverlay(ctx, active.background, active.backgroundShortcut ?? undefined)
34
+ }
35
+ pi.registerCommand("bg", { description: "Show processes left running by bash tool calls", handler: (_args, ctx) => openBackground(ctx) })
36
+ // Open PRs for the repo in cwd: `/prs [mine|review|all]` and the `list_open_prs` tool.
37
+ pi.registerCommand("prs", {
38
+ description: "Open PRs in this repo that I authored or that need my review",
39
+ handler: (args, ctx) => runPrsCommand(args, ctx, pi, active?.prs),
40
+ })
41
+ pi.registerTool({
42
+ name: "list_open_prs",
43
+ label: "List Open PRs",
44
+ description: "List open pull requests in the current repository that the user authored or has been asked to review (via the gh CLI).",
45
+ parameters: Type.Object({
46
+ filter: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("mine"), Type.Literal("review")], { description: "all (default), mine, or review (waiting on my review)" })),
47
+ }),
48
+ async execute(_toolCallId, params) {
49
+ await active?.prs?.refresh()
50
+ const result = prsToolResult(active?.prs, (params.filter as PrFilter | undefined) ?? parsePrFilter(""))
51
+ const text = result.prs.length === 0
52
+ ? `No open PRs${result.repo ? ` in ${result.repo}` : ""}${result.error ? ` (${result.error})` : ""}.`
53
+ : result.prs.map((pr) => `#${pr.number} [${pr.kind}${pr.isDraft ? ", draft" : ""}${pr.ciFailing ? ", CI failing" : ""}] ${pr.title} — ${pr.url}`).join("\n")
54
+ return { content: [{ type: "text", text }], details: result }
55
+ },
56
+ })
57
+ const registeredShortcuts = new Set<string>()
58
+ const ensureShortcut = () => {
59
+ const key = active?.backgroundShortcut
60
+ if (!key || registeredShortcuts.has(key) || typeof pi.registerShortcut !== "function") return
61
+ registeredShortcuts.add(key)
62
+ pi.registerShortcut(key as Parameters<ExtensionAPI["registerShortcut"]>[0], {
63
+ description: "Show background processes",
64
+ handler: (ctx) => (active?.backgroundShortcut === key ? openBackground(ctx) : undefined),
65
+ })
66
+ }
67
+
24
68
  pi.on("session_start", (_event, ctx) => {
25
69
  active?.dispose()
26
70
  active = createSession(pi, ctx, { busy: () => busy })
71
+ ensureShortcut()
72
+ })
73
+
74
+ // Commands that create a PR: remember the URL they print so the chips follow the session
75
+ // even when cwd is another repo (worktrees) or no repo at all.
76
+ const prCreateCalls = new Map<string, true>()
77
+ pi.on("tool_execution_start", (event) => {
78
+ active?.background?.toolStart(event.toolCallId, event.toolName)
79
+ const command = (event.args as { command?: unknown } | undefined)?.command
80
+ if ((event.toolName === "bash" || event.toolName === "powershell") && typeof command === "string" && isPrCreateCommand(command)) {
81
+ prCreateCalls.set(event.toolCallId, true)
82
+ }
27
83
  })
28
84
 
29
85
  pi.on("agent_start", () => {
@@ -37,7 +93,12 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
37
93
  active?.pin("turn_end")
38
94
  active?.refreshGit()
39
95
  })
40
- pi.on("tool_execution_end", () => {
96
+ pi.on("tool_execution_end", (event) => {
97
+ active?.background?.toolEnd(event.toolCallId, event.toolName)
98
+ if (prCreateCalls.delete(event.toolCallId) && !event.isError) {
99
+ const content = (event.result as { content?: Array<{ type?: string; text?: string }> } | undefined)?.content ?? []
100
+ active?.notePrUrls(content.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : "")).join("\n"))
101
+ }
41
102
  active?.pin("tool_execution_end")
42
103
  active?.refreshGit()
43
104
  })
@@ -0,0 +1,22 @@
1
+ /** A tiny change-notification set shared by the git and sibling sources. */
2
+ export function createListeners() {
3
+ const listeners = new Set<() => void>()
4
+ return {
5
+ notify() {
6
+ for (const listener of listeners) {
7
+ try {
8
+ listener()
9
+ } catch {
10
+ // One listener's failure must not starve the others.
11
+ }
12
+ }
13
+ },
14
+ add(listener: () => void) {
15
+ listeners.add(listener)
16
+ return () => listeners.delete(listener)
17
+ },
18
+ clear() {
19
+ listeners.clear()
20
+ },
21
+ }
22
+ }