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/session.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { getAgentDir, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+ import { setChromeMargin } from "./chrome.js"
3
+ import { loadPiMinimalConfig } from "./config.js"
4
+ import { installCwd } from "./cwd.js"
5
+ import { installFooter } from "./footer.js"
6
+ import { bindFooterContributionEvents } from "./footer-contribution-events.js"
7
+ import { createFooterContributions } from "./footer-contributions.js"
8
+ import { createGitState } from "./git-state.js"
9
+ import { installHeader } from "./header.js"
10
+ import { createBackgroundTracker, type BackgroundTracker } from "./background.js"
11
+ import { backgroundSegment } from "./background-overlay.js"
12
+ import { createPrSource, findPrUrls, prSegment, type PrSource } from "./prs.js"
13
+ import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
14
+ import type { RexSpawn } from "./rex.js"
15
+ import { createSiblingSource, type SiblingSource } from "./siblings.js"
16
+ import { setSessionCwd } from "./tool-rows.js"
17
+ import { setEditDiffMode } from "./tools.js"
18
+ import { hideGauntletWidgets } from "./tracker.js"
19
+
20
+ /** Custom session entry remembering a PR this session created, so a resumed session shows it. */
21
+ export const PR_ENTRY_TYPE = "pi-minimal.pr"
22
+
23
+ function readSessionPrs(ctx: ExtensionContext): string[] {
24
+ const urls: string[] = []
25
+ try {
26
+ for (const entry of ctx.sessionManager.getBranch()) {
27
+ if (entry.type !== "custom" || entry.customType !== PR_ENTRY_TYPE) continue
28
+ const url = (entry.data as { url?: unknown } | undefined)?.url
29
+ if (typeof url === "string") urls.push(url)
30
+ }
31
+ } catch {
32
+ // No branch available (tests, odd hosts): start empty.
33
+ }
34
+ return urls
35
+ }
36
+
37
+ export type SessionDeps = {
38
+ /** Whether an agent run is in progress; owned by the extension because agent events are not session-scoped. */
39
+ busy: () => boolean
40
+ /** Test seam for the Rex event stream; defaults to node:child_process.spawn. */
41
+ spawn?: RexSpawn
42
+ }
43
+
44
+ export type Session = {
45
+ readonly id: string
46
+ /** Background processes left behind by bash tool calls; undefined outside TUI mode. */
47
+ readonly background?: BackgroundTracker
48
+ readonly backgroundShortcut?: string | null
49
+ /** Open PRs for the repo in cwd plus ones this session created; undefined outside TUI mode. */
50
+ readonly prs?: PrSource
51
+ /** Records PR URLs found in text (e.g. `gh pr create` output) as this session's PRs. */
52
+ notePrUrls(text: string): void
53
+ /** Re-asserts chrome after an event that may have let another extension draw over it. */
54
+ pin(reason: ReclaimReason): void
55
+ refreshGit(force?: boolean): void
56
+ dispose(): void
57
+ }
58
+
59
+ /**
60
+ * Everything pi-minimal owns for one session: config, cwd, the contribution registry, the git
61
+ * poller, the sibling source (Herdr or Rex), and the header/footer/status-line chrome. Disposal stops every timer and makes
62
+ * late async results inert, so two sessions in one process never share or overwrite each other's
63
+ * state. Non-TUI sessions get a session too, but it only binds the contribution protocol (which
64
+ * replies `not-tui`) and installs no chrome.
65
+ */
66
+ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: SessionDeps): Session {
67
+ const id = ctx.sessionManager.getSessionId()
68
+ let alive = true
69
+ const contributions = createFooterContributions()
70
+ const stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, id, ctx.mode)
71
+ if (ctx.mode !== "tui") {
72
+ return { id, pin() {}, refreshGit() {}, notePrUrls() {}, dispose() { if (!alive) return; alive = false; stopContributionEvents() } }
73
+ }
74
+
75
+ const config = loadPiMinimalConfig({ agentDir: getAgentDir(), cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })
76
+ setSessionCwd(ctx.cwd)
77
+ setEditDiffMode(config.editDiff)
78
+ try {
79
+ const settings = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() })
80
+ setChromeMargin(settings.getOutputPad())
81
+ } catch {
82
+ // Keep the current margin when settings are unavailable.
83
+ }
84
+
85
+ const gitState = createGitState(pi, () => ctx.cwd)
86
+ let stopFooter = () => {}
87
+ let siblings: SiblingSource | undefined
88
+ const background = createBackgroundTracker({ pi })
89
+ const prs = createPrSource({ pi, cwd: () => ctx.cwd, sessionPrs: readSessionPrs(ctx) })
90
+ const settleTimers: ReturnType<typeof setTimeout>[] = []
91
+
92
+ const contextPct = (): number | undefined => {
93
+ const usage = ctx.getContextUsage?.()
94
+ const window = usage?.contextWindow || ctx.model?.contextWindow
95
+ if (typeof usage?.tokens !== "number" || !window) return undefined
96
+ return Math.max(0, Math.min(100, Math.round((usage.tokens / window) * 100)))
97
+ }
98
+
99
+ const installChromeFooter = () => {
100
+ stopFooter()
101
+ stopFooter = installFooter(ctx, {
102
+ busy: deps.busy,
103
+ editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
104
+ contextPct,
105
+ prs: (theme) => {
106
+ const snap = prs.get()
107
+ return prSegment(snap.prs, theme, 4, snap.repo ? `${snap.repo.host}/${snap.repo.nameWithOwner}` : undefined)
108
+ },
109
+ onPrsChange: prs.onChange,
110
+ background: () => backgroundSegment(background.get()),
111
+ onBackgroundChange: background.onChange,
112
+ }, contributions)
113
+ }
114
+
115
+ const installStatusLine = () => {
116
+ installCwd(ctx, {
117
+ gitState: gitState.get,
118
+ siblings: () => siblings?.get(),
119
+ onChange(listener) {
120
+ const offGit = gitState.onChange(listener)
121
+ const offSiblings = siblings?.onChange(listener) ?? (() => {})
122
+ return () => { offGit(); offSiblings() }
123
+ },
124
+ })
125
+ }
126
+
127
+ const pin = (reason: ReclaimReason) => {
128
+ if (!alive) return
129
+ applyReclaim(reason, ctx.ui, {
130
+ widgets: () => { if (config.hideGauntletWidgets) hideGauntletWidgets(ctx) },
131
+ chrome: () => { installHeader(ctx); installChromeFooter() },
132
+ })
133
+ if (!reclaimPolicy(reason).reinstallChrome) return
134
+ ctx.ui.setWidget("tw-cwd", undefined)
135
+ installStatusLine()
136
+ }
137
+
138
+ // The source is chosen from the environment (HERDR_ENV, else REX_SESSION/REX_BLOCK); start it
139
+ // before the status line so its onChange subscription is live.
140
+ siblings = createSiblingSource({ pi, spawn: deps.spawn })
141
+
142
+ // Install once, then re-assert shortly after: other extensions' session_start handlers may draw after ours.
143
+ installHeader(ctx)
144
+ installChromeFooter()
145
+ installStatusLine()
146
+ void gitState.refresh(true)
147
+ pin("session_start")
148
+ ctx.ui.setHiddenThinkingLabel("◆ Thought")
149
+ ctx.ui.setWorkingIndicator()
150
+ for (const delay of [0, 50]) settleTimers.push(setTimeout(() => pin("session_settle"), delay))
151
+
152
+ return {
153
+ id,
154
+ background,
155
+ backgroundShortcut: config.backgroundShortcut,
156
+ prs,
157
+ notePrUrls(text) {
158
+ if (!alive) return
159
+ const known = new Set(prs.sessionPrs())
160
+ for (const url of findPrUrls(text)) {
161
+ if (known.has(url)) continue
162
+ prs.addSessionPr(url)
163
+ try {
164
+ pi.appendEntry?.(PR_ENTRY_TYPE, { url })
165
+ } catch {
166
+ // A session that cannot accept entries just forgets the PR on restart.
167
+ }
168
+ }
169
+ },
170
+ pin,
171
+ refreshGit(force) { if (alive) { void gitState.refresh(force); void prs.refresh() } },
172
+ dispose() {
173
+ if (!alive) return
174
+ alive = false
175
+ for (const timer of settleTimers.splice(0)) clearTimeout(timer)
176
+ background.dispose()
177
+ prs.dispose()
178
+ siblings?.dispose()
179
+ siblings = undefined
180
+ gitState.dispose()
181
+ stopFooter()
182
+ stopContributionEvents()
183
+ },
184
+ }
185
+ }
@@ -0,0 +1,38 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { createHerdrSiblings } from "./herdr.js"
3
+ import { createRexSiblings, type RexSpawn } from "./rex.js"
4
+
5
+ /** One other agent in the same workspace, as shown on the status line. */
6
+ export type Sibling = {
7
+ name: string
8
+ /** `idle | working | blocked | done | error`, or whatever the host reports; colour falls back to muted. */
9
+ status: string
10
+ }
11
+
12
+ /**
13
+ * Where sibling status comes from. `get()` is synchronous; `onChange` fires when the list may have
14
+ * changed so the status line can re-render; `dispose()` stops timers and child processes and makes
15
+ * late results inert.
16
+ */
17
+ export type SiblingSource = {
18
+ get(): Sibling[] | undefined
19
+ onChange(listener: () => void): () => void
20
+ dispose(): void
21
+ }
22
+
23
+ export type SiblingSourceDeps = {
24
+ pi: ExtensionAPI
25
+ env?: NodeJS.ProcessEnv
26
+ spawn?: RexSpawn
27
+ }
28
+
29
+ /**
30
+ * Picks the host the session runs in. Herdr wins when both are present (Rex inside a Herdr pane):
31
+ * Herdr knows every agent it launched, Rex only the programs that report status.
32
+ */
33
+ export function createSiblingSource(deps: SiblingSourceDeps): SiblingSource | undefined {
34
+ const env = deps.env ?? process.env
35
+ if (env.HERDR_ENV === "1") return createHerdrSiblings(deps.pi, env)
36
+ if (env.REX_SESSION && env.REX_BLOCK) return createRexSiblings({ pi: deps.pi, env, spawn: deps.spawn })
37
+ return undefined
38
+ }