pi-minimal 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/CONTEXT.md +107 -0
  3. package/README.md +9 -6
  4. package/dist/background-overlay.js +95 -0
  5. package/dist/background.js +206 -0
  6. package/dist/config.js +10 -1
  7. package/dist/cwd.js +2 -2
  8. package/dist/edit-diff.js +74 -0
  9. package/dist/footer-contribution-events.js +5 -3
  10. package/dist/footer-contributions.js +15 -13
  11. package/dist/footer.js +15 -3
  12. package/dist/git-state.js +31 -11
  13. package/dist/herdr.js +78 -0
  14. package/dist/index.js +79 -202
  15. package/dist/listeners.js +23 -0
  16. package/dist/prs-command.js +76 -0
  17. package/dist/prs.js +270 -0
  18. package/dist/reclaim.js +0 -3
  19. package/dist/rex.js +289 -0
  20. package/dist/session.js +168 -0
  21. package/dist/siblings.js +14 -0
  22. package/dist/tools.js +213 -374
  23. package/dist/tracker.js +2 -2
  24. package/dist/working-status.js +2 -1
  25. package/doc/archive/README.md +3 -0
  26. package/doc/footer-contributions.md +7 -7
  27. package/package.json +5 -3
  28. package/release-notes.md +33 -12
  29. package/src/background-overlay.ts +103 -0
  30. package/src/background.ts +250 -0
  31. package/src/config.ts +10 -1
  32. package/src/cwd.ts +5 -4
  33. package/src/edit-diff.ts +73 -0
  34. package/src/footer-contribution-events.ts +7 -4
  35. package/src/footer-contributions.ts +15 -13
  36. package/src/footer.ts +23 -3
  37. package/src/git-state.ts +29 -10
  38. package/src/herdr.ts +78 -5
  39. package/src/index.ts +81 -195
  40. package/src/listeners.ts +22 -0
  41. package/src/prs-command.ts +81 -0
  42. package/src/prs.ts +348 -0
  43. package/src/reclaim.ts +0 -4
  44. package/src/rex.ts +304 -0
  45. package/src/session.ts +185 -0
  46. package/src/siblings.ts +38 -0
  47. package/src/tools.ts +261 -389
  48. package/src/tracker.ts +11 -5
  49. package/src/working-status.ts +2 -1
  50. package/test/background.test.ts +198 -0
  51. package/test/config.test.ts +24 -11
  52. package/test/footer-composition.test.ts +61 -0
  53. package/test/footer-contribution-events.test.ts +15 -0
  54. package/test/git-state.test.ts +35 -0
  55. package/test/index.test.ts +202 -4
  56. package/test/prs.test.ts +272 -0
  57. package/test/rex.test.ts +249 -0
  58. package/test/tools.test.ts +36 -3
  59. package/test/working-status.test.ts +53 -0
  60. /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
  61. /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/src/index.ts CHANGED
@@ -1,199 +1,84 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"
2
- import { loadPiMinimalConfig } from "./config.js"
3
- import { installHeader } from "./header.js"
4
- import { installFooter } from "./footer.js"
5
- import { createFooterContributions } from "./footer-contributions.js"
6
- import { bindFooterContributionEvents } from "./footer-contribution-events.js"
7
- import { installCwd } from "./cwd.js"
8
- import { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.js"
9
- import { hideGauntletWidgets } from "./tracker.js"
10
- import { installToolRenderers, setEditDiffMode, stopLiveWatch } from "./tools.js"
11
- import { setSessionCwd } from "./tool-rows.js"
12
- import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
13
- import { installWorkingStatus } from "./working-status.js"
14
- import { installTurnRule } from "./turn-rule.js"
15
- import { installTranscript } from "./transcript.js"
16
- import { createGitState } from "./git-state.js"
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"
17
4
  import { installCodeBox } from "./code-box.js"
18
- import { setChromeMargin } from "./chrome.js"
19
- import { SettingsManager } from "@earendil-works/pi-coding-agent"
5
+ import { isPrCreateCommand } from "./prs.js"
6
+ import { parsePrFilter, prsToolResult, runPrsCommand, type PrFilter } from "./prs-command.js"
7
+ import { createSession, type Session } from "./session.js"
8
+ import { installToolRenderers, stopLiveWatch } from "./tools.js"
9
+ import { installTranscript } from "./transcript.js"
10
+ import { installTurnRule } from "./turn-rule.js"
11
+ import { installWorkingStatus } from "./working-status.js"
20
12
 
13
+ /**
14
+ * Process-wide hooks are installed once at load (renderers, transformers, the code-box patch);
15
+ * everything else lives on a `Session` created at session_start and disposed at session_shutdown
16
+ * or when the next session starts.
17
+ */
21
18
  export default function piMinimalExtension(pi: ExtensionAPI): void {
22
19
  const stopWorkingStatus = installWorkingStatus(pi)
23
20
  const stopTurnRule = installTurnRule(pi)
24
21
  installToolRenderers(pi)
25
22
  installTranscript(pi)
26
23
  installCodeBox()
27
- const contributions = createFooterContributions()
28
- let stopFooter = () => {}
29
- let stopContributionEvents = () => {}
30
- let sessionActive = false
31
- let fastTimer: ReturnType<typeof setInterval> | undefined
32
- let slowTimer: ReturnType<typeof setInterval> | undefined
33
- let stealTimer: ReturnType<typeof setInterval> | undefined
34
- let inFlight: AbortController | undefined
35
- let siblings: Sibling[] | undefined
36
- let generation = 0
37
- let hideWidgets = true
38
- let busy = false
39
- let currentCwd = process.cwd()
40
- const gitState = createGitState(pi, () => currentCwd)
41
- const stopTimers = () => {
42
- if (fastTimer) clearInterval(fastTimer)
43
- if (slowTimer) clearInterval(slowTimer)
44
- if (stealTimer) clearInterval(stealTimer)
45
- fastTimer = undefined
46
- slowTimer = undefined
47
- stealTimer = undefined
48
- inFlight?.abort()
49
- inFlight = undefined
50
- }
51
-
52
- const scheduleFast = (ctx: ExtensionContext) => {
53
- if (fastTimer) return
54
- if (slowTimer) clearInterval(slowTimer)
55
- slowTimer = undefined
56
- fastTimer = setInterval(() => {
57
- void probe(ctx)
58
- }, 2000)
59
- }
60
-
61
- const scheduleSlow = (ctx: ExtensionContext) => {
62
- if (slowTimer) return
63
- if (fastTimer) clearInterval(fastTimer)
64
- fastTimer = undefined
65
- slowTimer = setInterval(() => {
66
- void probe(ctx)
67
- }, 10000)
68
- }
69
-
70
- const probe = async (ctx: ExtensionContext) => {
71
- if (process.env.HERDR_ENV !== "1") return
72
- if (inFlight) return
73
- const ac = new AbortController()
74
- const gen = generation
75
- inFlight = ac
76
- try {
77
- const agentRun = await pi.exec("herdr", ["agent", "list"], {
78
- timeout: 3000,
79
- signal: ac.signal,
80
- })
81
- if (generation !== gen) return
82
- const tabRun = await pi.exec("herdr", ["tab", "list"], {
83
- timeout: 3000,
84
- signal: ac.signal,
85
- })
86
- if (generation !== gen) return
87
- if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
88
- siblings = undefined
89
- scheduleSlow(ctx)
90
- return
91
- }
92
- siblings = listSiblings(
93
- parseAgentList(agentRun.stdout),
94
- parseTabList(tabRun.stdout),
95
- process.env.HERDR_PANE_ID,
96
- process.env.HERDR_TAB_ID,
97
- )
98
- if (siblings) scheduleFast(ctx)
99
- else scheduleSlow(ctx)
100
- } catch {
101
- if (generation !== gen) return
102
- siblings = undefined
103
- scheduleSlow(ctx)
104
- } finally {
105
- if (inFlight === ac) inFlight = undefined
106
- }
107
- }
108
24
 
109
- const hideForeignChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
110
- if (ctx.mode !== "tui") return
111
- applyReclaim(
112
- reason,
113
- ctx.ui,
114
- {
115
- widgets: () => {
116
- if (hideWidgets) hideGauntletWidgets(ctx)
117
- },
118
- chrome: () => {
119
- installHeader(ctx)
120
- installChromeFooter(ctx)
121
- },
122
- },
123
- )
124
- }
125
-
126
- const contextPct = (ctx: ExtensionContext): number | undefined => {
127
- const usage = ctx.getContextUsage?.()
128
- const window = usage?.contextWindow || ctx.model?.contextWindow
129
- if (typeof usage?.tokens !== "number" || !window) return undefined
130
- return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)))
131
- }
25
+ let active: Session | undefined
26
+ let busy = false
132
27
 
133
- const installChromeFooter = (ctx: ExtensionContext) => {
134
- stopFooter()
135
- stopFooter = installFooter(ctx, {
136
- busy: () => busy,
137
- editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
138
- contextPct: () => contextPct(ctx),
139
- }, contributions)
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)
140
34
  }
141
-
142
- const installStatusLine = (ctx: ExtensionContext) => {
143
- installCwd(ctx, {
144
- gitState: gitState.get,
145
- siblings: () => siblings,
146
- onGitChange: gitState.onChange,
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),
147
65
  })
148
66
  }
149
67
 
150
- const pinChrome = (ctx: ExtensionContext, reason: ReclaimReason) => {
151
- hideForeignChrome(ctx, reason)
152
- if (ctx.mode !== "tui" || !reclaimPolicy(reason).reinstallChrome) return
153
- ctx.ui.setWidget("tw-cwd", undefined)
154
- installStatusLine(ctx)
155
- }
68
+ pi.on("session_start", (_event, ctx) => {
69
+ active?.dispose()
70
+ active = createSession(pi, ctx, { busy: () => busy })
71
+ ensureShortcut()
72
+ })
156
73
 
157
- pi.on("session_start", async (_event, ctx) => {
158
- stopFooter()
159
- stopContributionEvents()
160
- sessionActive = true
161
- generation += 1
162
- stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, ctx.sessionManager.getSessionId(), ctx.mode)
163
- if (ctx.mode !== "tui") return
164
- const config = loadPiMinimalConfig({
165
- agentDir: getAgentDir(),
166
- cwd: ctx.cwd,
167
- projectTrusted: ctx.isProjectTrusted(),
168
- })
169
- hideWidgets = config.hideGauntletWidgets
170
- setEditDiffMode(config.editDiff)
171
- currentCwd = ctx.cwd
172
- setSessionCwd(ctx.cwd)
173
- try {
174
- const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
175
- setChromeMargin(settings.getOutputPad())
176
- } catch {
177
- // Keep the default margin when settings are unavailable.
178
- }
179
- installHeader(ctx)
180
- installChromeFooter(ctx)
181
- installStatusLine(ctx)
182
- void gitState.refresh(true)
183
- pinChrome(ctx, "session_start")
184
- ctx.ui.setHiddenThinkingLabel("◆ Thought")
185
- ctx.ui.setWorkingIndicator()
186
- const sessionGeneration = generation
187
- const settle = () => {
188
- if (sessionActive && generation === sessionGeneration) pinChrome(ctx, "session_settle")
189
- }
190
- setTimeout(settle, 0)
191
- setTimeout(settle, 50)
192
- if (!stealTimer) {
193
- stealTimer = setInterval(() => hideForeignChrome(ctx, "interval"), 200)
194
- }
195
- if (process.env.HERDR_ENV === "1") {
196
- await probe(ctx)
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)
197
82
  }
198
83
  })
199
84
 
@@ -202,27 +87,28 @@ export default function piMinimalExtension(pi: ExtensionAPI): void {
202
87
  })
203
88
  pi.on("agent_end", () => {
204
89
  busy = false
205
- void gitState.refresh()
90
+ active?.refreshGit()
206
91
  })
207
- pi.on("turn_end", (_event, ctx) => {
208
- pinChrome(ctx, "turn_end")
209
- void gitState.refresh()
92
+ pi.on("turn_end", () => {
93
+ active?.pin("turn_end")
94
+ active?.refreshGit()
210
95
  })
211
- pi.on("tool_execution_end", (_event, ctx) => {
212
- pinChrome(ctx, "tool_execution_end")
213
- void gitState.refresh()
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
+ }
102
+ active?.pin("tool_execution_end")
103
+ active?.refreshGit()
214
104
  })
215
105
 
216
- pi.on("session_shutdown", async () => {
217
- sessionActive = false
218
- stopFooter()
219
- stopContributionEvents()
106
+ pi.on("session_shutdown", () => {
107
+ active?.dispose()
108
+ active = undefined
220
109
  busy = false
221
- stopTimers()
222
110
  stopWorkingStatus()
223
111
  stopTurnRule()
224
112
  stopLiveWatch()
225
- generation += 1
226
- siblings = undefined
227
113
  })
228
114
  }
@@ -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
+ }
@@ -0,0 +1,81 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { describePr, PR_LEGEND, type PrSource, type PullRequest } from "./prs.js"
3
+
4
+ /**
5
+ * `/prs [mine|review|all]` lists the current repo's open PRs that involve me and offers actions on
6
+ * the chosen one. The `list_open_prs` tool gives the model the same list.
7
+ */
8
+
9
+ export type PrFilter = "all" | "mine" | "review"
10
+
11
+ export function parsePrFilter(args: string): PrFilter {
12
+ const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ""
13
+ if (["mine", "author", "authored", "my"].includes(word)) return "mine"
14
+ if (["review", "reviews", "review-requested", "requested"].includes(word)) return "review"
15
+ return "all"
16
+ }
17
+
18
+ export function filterPrs(prs: PullRequest[], filter: PrFilter): PullRequest[] {
19
+ if (filter === "all") return prs
20
+ return prs.filter((pr) => pr.kind === filter)
21
+ }
22
+
23
+ const ACTIONS = ["Open in browser", "Paste URL into editor", "Checkout branch", "Cancel"] as const
24
+
25
+ export async function runPrsCommand(args: string, ctx: ExtensionContext, pi: ExtensionAPI, source: PrSource | undefined): Promise<void> {
26
+ if (!source) {
27
+ ctx.ui.notify("PRs are only tracked in interactive sessions.", "info")
28
+ return
29
+ }
30
+ await source.refresh(true)
31
+ const snap = source.get()
32
+ if (!snap.repo && snap.prs.length === 0) {
33
+ ctx.ui.notify(snap.error ? `No GitHub repo here: ${snap.error}` : "No GitHub repo here.", "warning")
34
+ return
35
+ }
36
+ const filter = parsePrFilter(args)
37
+ const prs = filterPrs(snap.prs, filter)
38
+ const title = `${snap.repo?.nameWithOwner ?? "this session"}: ${prs.length} open PR${prs.length === 1 ? "" : "s"}${filter === "all" ? "" : ` (${filter})`}${snap.error ? ` · stale: ${snap.error}` : ""}`
39
+ if (prs.length === 0) {
40
+ ctx.ui.notify(title, "info")
41
+ return
42
+ }
43
+ if (ctx.mode !== "tui") {
44
+ ctx.ui.notify(`${title}\n${prs.map((pr) => `${describePr(pr, snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined)}\n ${pr.url}`).join("\n")}`, "info")
45
+ return
46
+ }
47
+ const cwdRepo = snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined
48
+ const labels = prs.map((pr) => describePr(pr, cwdRepo))
49
+ const chosen = await ctx.ui.select(`${title}\n${PR_LEGEND}`, labels)
50
+ if (chosen === undefined) return
51
+ const pr = prs[labels.indexOf(chosen)]
52
+ if (!pr) return
53
+ const action = await ctx.ui.select(`#${pr.number} ${pr.title}`, [...ACTIONS])
54
+ if (action === "Open in browser") {
55
+ const open = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"
56
+ const run = await pi.exec(open, [pr.url])
57
+ if (run.code !== 0) ctx.ui.notify(`Could not open browser: ${run.stderr.trim()}`, "error")
58
+ } else if (action === "Paste URL into editor") {
59
+ ctx.ui.pasteToEditor(pr.url)
60
+ } else if (action === "Checkout branch") {
61
+ const run = await pi.exec("gh", ["pr", "checkout", String(pr.number), "-R", pr.repo], { cwd: ctx.cwd, timeout: 60_000 })
62
+ ctx.ui.notify(run.code === 0 ? `Checked out #${pr.number}` : `Checkout failed: ${run.stderr.trim()}`, run.code === 0 ? "info" : "error")
63
+ }
64
+ }
65
+
66
+ export function prsToolResult(source: PrSource | undefined, filter: PrFilter) {
67
+ const snap = source?.get()
68
+ const prs = filterPrs(snap?.prs ?? [], filter).map((pr) => ({
69
+ number: pr.number,
70
+ title: pr.title,
71
+ url: pr.url,
72
+ repo: pr.repo,
73
+ author: pr.author,
74
+ isDraft: pr.isDraft,
75
+ kind: pr.kind,
76
+ reviewDecision: pr.reviewDecision,
77
+ ciFailing: pr.ciFailing,
78
+ updatedAt: pr.updatedAt,
79
+ }))
80
+ return { repo: snap?.repo?.nameWithOwner, host: snap?.repo?.host, fetchedAt: snap?.fetchedAt, error: snap?.error, prs }
81
+ }