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/prs.ts ADDED
@@ -0,0 +1,348 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { hyperlink } from "@earendil-works/pi-tui"
3
+ import { createListeners } from "./listeners.js"
4
+
5
+ /**
6
+ * Open pull requests for the repository the session is in, via the `gh` CLI: the ones I authored
7
+ * and the ones waiting on my review. Shown on the status line as clickable `#1675` chips (OSC 8
8
+ * links), coloured by kind, and listed by `/prs`.
9
+ *
10
+ * `gh` resolves the host from the repo's remote, so GitHub.com and GitHub Enterprise both work;
11
+ * the viewer login is looked up per host because it differs between them.
12
+ */
13
+
14
+ export type PrKind = "mine" | "review"
15
+
16
+ export type PullRequest = {
17
+ number: number
18
+ title: string
19
+ url: string
20
+ /** `host/owner/repo`, the form `gh -R` accepts for any host. */
21
+ repo: string
22
+ author: string
23
+ isDraft: boolean
24
+ /** Why it is on my list. A PR I authored that also requests my review counts as `mine`. */
25
+ kind: PrKind
26
+ /** GitHub's review decision: APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED or empty. */
27
+ reviewDecision: string
28
+ /** Any status context or check run failed. */
29
+ ciFailing: boolean
30
+ updatedAt: string
31
+ }
32
+
33
+ export type PrRepo = {
34
+ /** `owner/repo` */
35
+ nameWithOwner: string
36
+ /** e.g. `github.com` */
37
+ host: string
38
+ url: string
39
+ }
40
+
41
+ export type PrSnapshot = {
42
+ repo?: PrRepo
43
+ /** The cwd repo's PRs first, then PRs created in this session elsewhere (deduped by URL). */
44
+ prs: PullRequest[]
45
+ /** Last `gh` error, when the list could not be refreshed. */
46
+ error?: string
47
+ fetchedAt?: number
48
+ }
49
+
50
+ export const PR_LIST_FIELDS = "number,title,url,isDraft,author,reviewRequests,reviewDecision,statusCheckRollup,updatedAt"
51
+
52
+ /** A GitHub PR URL on any host: `https://host/owner/repo/pull/123`. */
53
+ export const PR_URL_RE = /https?:\/\/([a-z0-9.-]+)\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\b/gi
54
+
55
+ /** Finds PR URLs in text, e.g. the output of `gh pr create`. */
56
+ export function findPrUrls(text: string): string[] {
57
+ const urls = new Set<string>()
58
+ for (const match of text.matchAll(PR_URL_RE)) urls.add(`https://${match[1]}/${match[2]}/${match[3]}/pull/${match[4]}`)
59
+ return [...urls]
60
+ }
61
+
62
+ /** Repo (`host/owner/repo`) a PR URL belongs to. */
63
+ export function prUrlRepo(url: string): string | undefined {
64
+ const match = new RegExp(PR_URL_RE.source, "i").exec(url)
65
+ return match ? `${match[1]}/${match[2]}/${match[3]}` : undefined
66
+ }
67
+
68
+ /** Commands whose output announces a PR this session created. */
69
+ export function isPrCreateCommand(command: string): boolean {
70
+ return /\bgh\s+pr\s+create\b|\bgt\s+submit\b/.test(command)
71
+ }
72
+
73
+ /** Parses `gh repo view --json nameWithOwner,url`. */
74
+ export function parseRepoView(stdout: string): PrRepo {
75
+ const data = JSON.parse(stdout) as { nameWithOwner?: unknown; url?: unknown }
76
+ if (typeof data.nameWithOwner !== "string" || typeof data.url !== "string") throw new Error("invalid gh repo view")
77
+ return { nameWithOwner: data.nameWithOwner, host: new URL(data.url).host, url: data.url }
78
+ }
79
+
80
+ type RawCheck = { state?: string | null; conclusion?: string | null; status?: string | null }
81
+ type RawPr = {
82
+ number?: unknown
83
+ title?: unknown
84
+ url?: unknown
85
+ isDraft?: unknown
86
+ author?: { login?: unknown } | null
87
+ reviewRequests?: Array<{ __typename?: string; login?: unknown; slug?: unknown; name?: unknown }> | null
88
+ reviewDecision?: unknown
89
+ statusCheckRollup?: RawCheck[] | null
90
+ updatedAt?: unknown
91
+ }
92
+
93
+ const FAILED_STATES = new Set(["FAILURE", "ERROR"])
94
+ const FAILED_CONCLUSIONS = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"])
95
+
96
+ export function ciFailing(checks: RawCheck[] | null | undefined): boolean {
97
+ return (checks ?? []).some(
98
+ (check) => FAILED_STATES.has(check.state ?? "") || FAILED_CONCLUSIONS.has(check.conclusion ?? ""),
99
+ )
100
+ }
101
+
102
+ function toPullRequest(raw: RawPr, repo: string, kind: PrKind): PullRequest | undefined {
103
+ if (typeof raw.number !== "number" || typeof raw.url !== "string") return undefined
104
+ return {
105
+ number: raw.number,
106
+ title: typeof raw.title === "string" ? raw.title : "",
107
+ url: raw.url,
108
+ repo,
109
+ author: typeof raw.author?.login === "string" ? raw.author.login : "",
110
+ isDraft: raw.isDraft === true,
111
+ kind,
112
+ reviewDecision: typeof raw.reviewDecision === "string" ? raw.reviewDecision : "",
113
+ ciFailing: ciFailing(raw.statusCheckRollup),
114
+ updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : "",
115
+ }
116
+ }
117
+
118
+ /** Parses `gh pr view URL --json …` for a PR this session created (always `mine`). */
119
+ export function parsePrView(stdout: string, url: string): PullRequest | undefined {
120
+ const raw = JSON.parse(stdout) as RawPr & { state?: unknown }
121
+ if (raw.state !== undefined && raw.state !== "OPEN") return undefined
122
+ const repo = prUrlRepo(url)
123
+ return repo ? toPullRequest(raw, repo, "mine") : undefined
124
+ }
125
+
126
+ /**
127
+ * Parses `gh pr list --json …` for `involves:@me` into my PRs: authored by `login`, or requesting
128
+ * my review (directly; team requests are included when `teams` lists my team slugs). Others I'm
129
+ * merely mentioned on are dropped.
130
+ */
131
+ export function parsePrList(stdout: string, repo: PrRepo, login: string, teams: readonly string[] = []): PullRequest[] {
132
+ const data = JSON.parse(stdout) as RawPr[]
133
+ if (!Array.isArray(data)) throw new Error("invalid gh pr list")
134
+ const teamSet = new Set(teams.map((team) => team.toLowerCase()))
135
+ const prs: PullRequest[] = []
136
+ for (const raw of data) {
137
+ const author = typeof raw.author?.login === "string" ? raw.author.login : ""
138
+ const requested = (raw.reviewRequests ?? []).some((request) => {
139
+ if (typeof request.login === "string") return request.login.toLowerCase() === login.toLowerCase()
140
+ const slug = typeof request.slug === "string" ? request.slug : typeof request.name === "string" ? request.name : ""
141
+ return teamSet.has(slug.toLowerCase())
142
+ })
143
+ const mine = author.toLowerCase() === login.toLowerCase()
144
+ if (!mine && !requested) continue
145
+ const pr = toPullRequest(raw, `${repo.host}/${repo.nameWithOwner}`, mine ? "mine" : "review")
146
+ if (pr) prs.push(pr)
147
+ }
148
+ // Review requests first (they need me), then mine; newest first within each.
149
+ prs.sort((a, b) => (a.kind === b.kind ? (a.updatedAt < b.updatedAt ? 1 : -1) : a.kind === "review" ? -1 : 1))
150
+ return prs
151
+ }
152
+
153
+ export type PrColor = "accent" | "dim" | "warning" | "error"
154
+
155
+ /** Chip colour: trouble (failing CI, changes requested) wins, then review requests, then drafts. */
156
+ export function prColor(pr: PullRequest): PrColor {
157
+ if (pr.ciFailing || pr.reviewDecision === "CHANGES_REQUESTED") return "error"
158
+ if (pr.kind === "review") return "warning"
159
+ if (pr.isDraft) return "dim"
160
+ return "accent"
161
+ }
162
+
163
+ export const PR_LEGEND = "#n mine · dim draft · amber needs my review · red failing CI / changes requested"
164
+
165
+ type Theme = { fg(color: string, text: string): string }
166
+
167
+ /** `#1675` inside the cwd repo; `repo#1675` for a PR elsewhere (created in this session) or when cwd is no repo. */
168
+ export function prChipLabel(pr: PullRequest, cwdRepo?: string): string {
169
+ if (cwdRepo && pr.repo === cwdRepo) return `#${pr.number}`
170
+ return `${pr.repo.split("/").pop()}#${pr.number}`
171
+ }
172
+
173
+ /** Footer segment: `#1675 #1670 +3` with each number an OSC 8 link; undefined when empty. */
174
+ export function prSegment(prs: PullRequest[], theme: Theme, max = 4, cwdRepo?: string): string | undefined {
175
+ if (prs.length === 0) return undefined
176
+ const shown = prs.slice(0, max)
177
+ const chips = shown.map((pr) => theme.fg(prColor(pr), hyperlink(prChipLabel(pr, cwdRepo), pr.url)))
178
+ const rest = prs.length - shown.length
179
+ if (rest > 0) chips.push(theme.fg("dim", `+${rest}`))
180
+ return chips.join(" ")
181
+ }
182
+
183
+ export function describePr(pr: PullRequest, cwdRepo?: string): string {
184
+ const flags = [
185
+ pr.kind === "review" ? "review requested" : pr.isDraft ? "draft" : "mine",
186
+ pr.ciFailing ? "CI failing" : undefined,
187
+ pr.reviewDecision === "CHANGES_REQUESTED" ? "changes requested" : pr.reviewDecision === "APPROVED" ? "approved" : undefined,
188
+ ].filter(Boolean)
189
+ return `${prChipLabel(pr, cwdRepo)} ${pr.title} · ${flags.join(" · ")}${pr.kind === "review" ? ` · @${pr.author}` : ""}`
190
+ }
191
+
192
+ export type PrSource = {
193
+ get(): PrSnapshot
194
+ onChange(listener: () => void): () => void
195
+ /** Re-fetch now (throttled unless `force`). */
196
+ refresh(force?: boolean): Promise<void>
197
+ /** Remember a PR this session created (any repo); it is shown until it is no longer open. */
198
+ addSessionPr(url: string): void
199
+ /** URLs remembered via addSessionPr, for persistence. */
200
+ sessionPrs(): string[]
201
+ dispose(): void
202
+ }
203
+
204
+ export type PrSourceDeps = {
205
+ pi: ExtensionAPI
206
+ cwd: () => string
207
+ /** PR URLs restored from a previous run of this session. */
208
+ sessionPrs?: readonly string[]
209
+ now?: () => number
210
+ /** Poll period; 3 min by default. */
211
+ pollMs?: number
212
+ /** Minimum gap between refreshes triggered by events. */
213
+ minIntervalMs?: number
214
+ }
215
+
216
+ const GH_TIMEOUT_MS = 15_000
217
+ const DEFAULT_POLL_MS = 3 * 60_000
218
+ const DEFAULT_MIN_INTERVAL_MS = 60_000
219
+
220
+ export function createPrSource(deps: PrSourceDeps): PrSource {
221
+ const now = deps.now ?? Date.now
222
+ const pollMs = deps.pollMs ?? DEFAULT_POLL_MS
223
+ const minIntervalMs = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
224
+ const listeners = createListeners()
225
+ let alive = true
226
+ let state: PrSnapshot = { prs: [] }
227
+ let inFlight: Promise<void> | undefined
228
+ let timer: ReturnType<typeof setTimeout> | undefined
229
+ let lastRun = 0
230
+ const logins = new Map<string, string>()
231
+ const session = new Set<string>(deps.sessionPrs ?? [])
232
+
233
+ // Only what is displayed counts as a change; a refresh that confirms the same list is silent.
234
+ const visible = (snap: PrSnapshot) => JSON.stringify([snap.repo, snap.prs, snap.error])
235
+ const set = (next: PrSnapshot) => {
236
+ const changed = visible(next) !== visible(state)
237
+ state = next
238
+ if (changed) listeners.notify()
239
+ }
240
+
241
+ const gh = (args: string[]) => deps.pi.exec("gh", args, { cwd: deps.cwd(), timeout: GH_TIMEOUT_MS })
242
+
243
+ const viewerLogin = async (host: string): Promise<string | undefined> => {
244
+ const cached = logins.get(host)
245
+ if (cached) return cached
246
+ const run = await gh(["api", "--hostname", host, "user", "--jq", ".login"])
247
+ if (run.code !== 0) return undefined
248
+ const login = run.stdout.trim()
249
+ if (login) logins.set(host, login)
250
+ return login || undefined
251
+ }
252
+
253
+ /** PRs this session created, wherever they live; a merged or closed one is forgotten. */
254
+ const fetchSessionPrs = async (exclude: Set<string>): Promise<PullRequest[]> => {
255
+ const extra = await Promise.all(
256
+ [...session].filter((url) => !exclude.has(url)).map(async (url) => {
257
+ const view = await gh(["pr", "view", url, "--json", `${PR_LIST_FIELDS},state`])
258
+ if (view.code !== 0) return undefined
259
+ try {
260
+ const pr = parsePrView(view.stdout, url)
261
+ if (!pr) session.delete(url)
262
+ return pr
263
+ } catch {
264
+ return undefined
265
+ }
266
+ }),
267
+ )
268
+ return extra.filter((pr): pr is PullRequest => pr !== undefined)
269
+ }
270
+
271
+ const fetchOnce = async (): Promise<void> => {
272
+ let repo = state.repo
273
+ let repoError: string | undefined
274
+ try {
275
+ if (!repo) {
276
+ const view = await gh(["repo", "view", "--json", "nameWithOwner,url"])
277
+ if (!alive) return
278
+ if (view.code === 0) repo = parseRepoView(view.stdout)
279
+ else repoError = view.stderr.trim() || "gh repo view failed"
280
+ }
281
+ let prs: PullRequest[] = []
282
+ let error = repoError
283
+ if (repo) {
284
+ const login = await viewerLogin(repo.host)
285
+ if (!alive) return
286
+ if (!login) {
287
+ error = `gh is not authenticated for ${repo.host}`
288
+ } else {
289
+ const list = await gh([
290
+ "pr", "list", "-R", `${repo.host}/${repo.nameWithOwner}`, "--state", "open", "--limit", "100",
291
+ "--search", "involves:@me", "--json", PR_LIST_FIELDS,
292
+ ])
293
+ if (!alive) return
294
+ if (list.code !== 0) error = list.stderr.trim() || "gh pr list failed"
295
+ else prs = parsePrList(list.stdout, repo, login)
296
+ }
297
+ }
298
+ // Outside a GitHub repo, or when the list failed, the session's own PRs still show.
299
+ prs.push(...(await fetchSessionPrs(new Set(prs.map((pr) => pr.url)))))
300
+ if (!alive) return
301
+ set({ repo, prs: error && prs.length === 0 && state.prs.length > 0 ? state.prs : prs, error, fetchedAt: now() })
302
+ } catch (error) {
303
+ if (!alive) return
304
+ set({ repo, prs: state.prs, error: error instanceof Error ? error.message : String(error), fetchedAt: state.fetchedAt })
305
+ }
306
+ }
307
+
308
+ const schedule = () => {
309
+ if (!alive) return
310
+ if (timer) clearTimeout(timer)
311
+ timer = setTimeout(() => { void refresh(true) }, pollMs)
312
+ timer.unref?.()
313
+ }
314
+
315
+ const refresh = (force = false): Promise<void> => {
316
+ if (!alive) return Promise.resolve()
317
+ if (inFlight) return inFlight
318
+ if (!force && now() - lastRun < minIntervalMs) return Promise.resolve()
319
+ lastRun = now()
320
+ inFlight = fetchOnce().finally(() => {
321
+ inFlight = undefined
322
+ schedule()
323
+ })
324
+ return inFlight
325
+ }
326
+
327
+ void refresh(true)
328
+
329
+ return {
330
+ get: () => state,
331
+ onChange: listeners.add,
332
+ refresh,
333
+ addSessionPr(url) {
334
+ const found = findPrUrls(url)[0]
335
+ if (!found || session.has(found)) return
336
+ session.add(found)
337
+ void refresh(true)
338
+ },
339
+ sessionPrs: () => [...session],
340
+ dispose() {
341
+ if (!alive) return
342
+ alive = false
343
+ if (timer) clearTimeout(timer)
344
+ timer = undefined
345
+ listeners.clear()
346
+ },
347
+ }
348
+ }
package/src/reclaim.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  export type ReclaimReason =
2
2
  | "session_start"
3
3
  | "session_settle"
4
- | "interval"
5
4
  | "turn_end"
6
5
  | "tool_execution_end"
7
6
 
@@ -14,9 +13,6 @@ export function reclaimPolicy(reason: ReclaimReason): ReclaimPolicy {
14
13
  if (reason === "session_start") {
15
14
  return { resetEditor: true, reinstallChrome: true }
16
15
  }
17
- if (reason === "interval") {
18
- return { resetEditor: false, reinstallChrome: false }
19
- }
20
16
  // session_settle / turn_end / tool_execution_end: restore chrome, keep editor
21
17
  return { resetEditor: false, reinstallChrome: true }
22
18
  }
package/src/rex.ts ADDED
@@ -0,0 +1,304 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
2
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process"
3
+ import { createInterface } from "node:readline"
4
+ import { createListeners } from "./listeners.js"
5
+ import type { Sibling, SiblingSource } from "./siblings.js"
6
+
7
+ /**
8
+ * Rex siblings: other panes in the same Rex session whose program reports status with the
9
+ * Program Status Protocol (OSC 7501), e.g. another pi with the rex-agent-state extension, or
10
+ * claude-code / terraform. The pi in this pane is excluded by `REX_BLOCK`.
11
+ *
12
+ * Rex pushes status changes over `rex events -s SESSION --json`, so this source keeps one child
13
+ * process instead of polling every pane; it only shells out again to seed on start and to refresh
14
+ * tab labels / the block→tab map when the layout changes.
15
+ */
16
+
17
+ export type RexStatusRecord = {
18
+ state: string
19
+ app?: string
20
+ msg?: string
21
+ }
22
+
23
+ export type RexLayout = {
24
+ /** window_id → tab label */
25
+ tabs: Map<string, string>
26
+ /** block_id → window_id, terminal blocks only */
27
+ blocks: Map<string, string>
28
+ }
29
+
30
+ export type RexState = {
31
+ layout: RexLayout
32
+ /** block_id → root status record */
33
+ status: Map<string, RexStatusRecord>
34
+ }
35
+
36
+ export function createRexState(): RexState {
37
+ return { layout: { tabs: new Map(), blocks: new Map() }, status: new Map() }
38
+ }
39
+
40
+ const TERMINAL_CREATOR = "com.superlogical.terminal"
41
+
42
+ /** Parses `rex window ls -s SESSION --json`. */
43
+ export function parseWindowList(stdout: string): RexLayout {
44
+ const data = JSON.parse(stdout) as {
45
+ windows?: Array<{
46
+ window_id?: unknown
47
+ label?: unknown
48
+ layers?: Array<{ blocks?: Array<{ block_id?: unknown; creator_name?: unknown }> }>
49
+ }>
50
+ }
51
+ if (!Array.isArray(data.windows)) throw new Error("invalid rex window list")
52
+ const layout: RexLayout = { tabs: new Map(), blocks: new Map() }
53
+ for (const window of data.windows) {
54
+ const windowId = typeof window.window_id === "string" ? window.window_id : undefined
55
+ if (!windowId) continue
56
+ layout.tabs.set(windowId, typeof window.label === "string" ? window.label : "")
57
+ for (const layer of window.layers ?? []) {
58
+ for (const block of layer.blocks ?? []) {
59
+ if (typeof block.block_id !== "string") continue
60
+ if (block.creator_name !== undefined && block.creator_name !== TERMINAL_CREATOR) continue
61
+ layout.blocks.set(block.block_id, windowId)
62
+ }
63
+ }
64
+ }
65
+ return layout
66
+ }
67
+
68
+ /** Parses `rex block call -b BLOCK program_status`; returns the root record, if any. */
69
+ export function parseProgramStatus(stdout: string): RexStatusRecord | undefined {
70
+ const data = JSON.parse(stdout) as { records?: Array<Record<string, unknown>> }
71
+ if (!Array.isArray(data.records)) throw new Error("invalid rex program status")
72
+ const root = data.records.find((record) => record.id === undefined || record.id === "")
73
+ return root ? toRecord(root) : undefined
74
+ }
75
+
76
+ function toRecord(raw: Record<string, unknown>): RexStatusRecord | undefined {
77
+ if (typeof raw.state !== "string") return undefined
78
+ const record: RexStatusRecord = { state: raw.state }
79
+ const app = typeof raw.effective_app === "string" ? raw.effective_app : typeof raw.app === "string" ? raw.app : undefined
80
+ if (app) record.app = app
81
+ if (typeof raw.msg === "string") record.msg = raw.msg
82
+ return record
83
+ }
84
+
85
+ export type RexEventEffect = "status" | "layout" | undefined
86
+
87
+ /**
88
+ * Applies one line of `rex events --json` to the state. Returns what changed: `status` (a pane's
89
+ * record) or `layout` (the caller should re-read the window list). Unknown events are ignored.
90
+ */
91
+ export function applyRexEvent(state: RexState, line: string): RexEventEffect {
92
+ // Shape: {"type":"session_event","payload":{"session_id":…,"event":{"type":…,"payload":{…}}}}
93
+ let outer: { type?: unknown; payload?: { event?: { type?: unknown; payload?: Record<string, unknown> } } }
94
+ try {
95
+ outer = JSON.parse(line)
96
+ } catch {
97
+ return undefined
98
+ }
99
+ if (outer.type !== "session_event") return undefined
100
+ const event = outer.payload?.event
101
+ const payload = event?.payload ?? {}
102
+ switch (event?.type) {
103
+ case "block_event": {
104
+ const blockId = payload.block_id
105
+ if (typeof blockId !== "string") return undefined
106
+ const inner = (payload.payload ?? {}) as Record<string, unknown>
107
+ if (payload.name === "program_status_changed") {
108
+ if (inner.id !== "" && inner.id !== undefined) return undefined
109
+ const record = toRecord((inner.record ?? {}) as Record<string, unknown>)
110
+ if (!record) return undefined
111
+ state.status.set(blockId, record)
112
+ return "status"
113
+ }
114
+ if (payload.name === "program_status_removed") {
115
+ const ids = Array.isArray(inner.ids) ? inner.ids : []
116
+ if (!ids.includes("")) return undefined
117
+ return state.status.delete(blockId) ? "status" : undefined
118
+ }
119
+ return undefined
120
+ }
121
+ case "block_closed": {
122
+ const blockId = payload.block_id
123
+ if (typeof blockId !== "string") return undefined
124
+ state.status.delete(blockId)
125
+ state.layout.blocks.delete(blockId)
126
+ return "layout"
127
+ }
128
+ case "block_created":
129
+ case "window_label_changed":
130
+ case "window_created":
131
+ case "window_closed":
132
+ case "session_label_changed":
133
+ return "layout"
134
+ default:
135
+ return undefined
136
+ }
137
+ }
138
+
139
+ /** A Rex auto-label such as `Window 395a96cb` says nothing about the work; prefer the program. */
140
+ const AUTO_LABEL = /^Window [0-9a-f]+$/i
141
+
142
+ /**
143
+ * Siblings are named after their tab, like Herdr siblings. A pane in the same tab as this pi
144
+ * would share our tab's name, so it is named after its program instead.
145
+ */
146
+ export function listRexSiblings(state: RexState, selfBlock?: string): Sibling[] | undefined {
147
+ if (!selfBlock) return undefined
148
+ const selfWindow = state.layout.blocks.get(selfBlock)
149
+ const parts: Sibling[] = []
150
+ for (const [blockId, record] of state.status) {
151
+ if (blockId === selfBlock) continue
152
+ const windowId = state.layout.blocks.get(blockId)
153
+ const label = windowId && windowId !== selfWindow ? state.layout.tabs.get(windowId) : undefined
154
+ const name = label && !AUTO_LABEL.test(label) ? label : record.app
155
+ if (!name) continue
156
+ parts.push({ name, status: record.state })
157
+ }
158
+ if (parts.length === 0) return undefined
159
+ return parts
160
+ }
161
+
162
+ export type RexSpawn = (command: string, args: string[]) => Pick<ChildProcess, "stdout" | "stderr" | "kill" | "on" | "once">
163
+
164
+ export type RexSiblingsDeps = {
165
+ pi: ExtensionAPI
166
+ env?: NodeJS.ProcessEnv
167
+ spawn?: RexSpawn
168
+ }
169
+
170
+ const REX_EXEC_TIMEOUT_MS = 3000
171
+ const REX_RESTART_MS = 10000
172
+ const REX_LAYOUT_DEBOUNCE_MS = 150
173
+
174
+ export function createRexSiblings(deps: RexSiblingsDeps): SiblingSource {
175
+ const env = deps.env ?? process.env
176
+ const spawn: RexSpawn = deps.spawn ?? ((command, args) => nodeSpawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }))
177
+ const session = env.REX_SESSION ?? ""
178
+ const selfBlock = env.REX_BLOCK
179
+ let alive = true
180
+ let child: ReturnType<RexSpawn> | undefined
181
+ let restartTimer: ReturnType<typeof setTimeout> | undefined
182
+ let layoutTimer: ReturnType<typeof setTimeout> | undefined
183
+ let layoutInFlight = false
184
+ let layoutDirty = false
185
+ let siblings: Sibling[] | undefined
186
+ const state = createRexState()
187
+ const listeners = createListeners()
188
+
189
+ const recompute = () => {
190
+ if (!alive) return
191
+ const next = listRexSiblings(state, selfBlock)
192
+ const changed = JSON.stringify(next) !== JSON.stringify(siblings)
193
+ siblings = next
194
+ if (changed) listeners.notify()
195
+ }
196
+
197
+ const refreshLayout = async (seedStatus: boolean) => {
198
+ if (!alive) return
199
+ if (layoutInFlight) { layoutDirty = true; return }
200
+ layoutInFlight = true
201
+ try {
202
+ const run = await deps.pi.exec("rex", ["window", "ls", "-s", session, "--json"], { timeout: REX_EXEC_TIMEOUT_MS })
203
+ if (!alive) return
204
+ if (run.code !== 0 || run.killed) return
205
+ const layout = parseWindowList(run.stdout)
206
+ state.layout = layout
207
+ for (const blockId of [...state.status.keys()]) {
208
+ if (!layout.blocks.has(blockId)) state.status.delete(blockId)
209
+ }
210
+ if (seedStatus) {
211
+ await Promise.all(
212
+ [...layout.blocks.keys()]
213
+ .filter((blockId) => blockId !== selfBlock)
214
+ .map(async (blockId) => {
215
+ try {
216
+ const status = await deps.pi.exec("rex", ["block", "call", "-s", session, "-b", blockId, "program_status"], { timeout: REX_EXEC_TIMEOUT_MS })
217
+ if (!alive || status.code !== 0 || status.killed) return
218
+ const record = parseProgramStatus(status.stdout)
219
+ if (record) state.status.set(blockId, record)
220
+ else state.status.delete(blockId)
221
+ } catch {
222
+ // A pane that vanished mid-seed shows up via events later.
223
+ }
224
+ }),
225
+ )
226
+ }
227
+ recompute()
228
+ } catch {
229
+ // Keep the last known layout; the next layout event retries.
230
+ } finally {
231
+ layoutInFlight = false
232
+ if (layoutDirty && alive) {
233
+ layoutDirty = false
234
+ void refreshLayout(false)
235
+ }
236
+ }
237
+ }
238
+
239
+ const scheduleLayoutRefresh = () => {
240
+ if (!alive || layoutTimer) return
241
+ layoutTimer = setTimeout(() => {
242
+ layoutTimer = undefined
243
+ void refreshLayout(false)
244
+ }, REX_LAYOUT_DEBOUNCE_MS)
245
+ }
246
+
247
+ const start = () => {
248
+ if (!alive) return
249
+ restartTimer = undefined
250
+ let proc: ReturnType<RexSpawn>
251
+ try {
252
+ proc = spawn("rex", ["events", "-s", session, "--json"])
253
+ } catch {
254
+ scheduleRestart()
255
+ return
256
+ }
257
+ child = proc
258
+ if (proc.stdout) {
259
+ const lines = createInterface({ input: proc.stdout })
260
+ lines.on("line", (line) => {
261
+ if (!alive || child !== proc) return
262
+ const effect = applyRexEvent(state, line)
263
+ if (effect === "status") recompute()
264
+ else if (effect === "layout") { recompute(); scheduleLayoutRefresh() }
265
+ })
266
+ }
267
+ proc.on("error", () => { if (child === proc) onExit() })
268
+ proc.on("exit", () => { if (child === proc) onExit() })
269
+ void refreshLayout(true)
270
+ }
271
+
272
+ const onExit = () => {
273
+ child = undefined
274
+ if (!alive) return
275
+ state.status.clear()
276
+ recompute()
277
+ scheduleRestart()
278
+ }
279
+
280
+ const scheduleRestart = () => {
281
+ if (!alive || restartTimer) return
282
+ restartTimer = setTimeout(start, REX_RESTART_MS)
283
+ }
284
+
285
+ start()
286
+
287
+ return {
288
+ get: () => siblings,
289
+ onChange: listeners.add,
290
+ dispose() {
291
+ if (!alive) return
292
+ alive = false
293
+ if (restartTimer) clearTimeout(restartTimer)
294
+ if (layoutTimer) clearTimeout(layoutTimer)
295
+ restartTimer = undefined
296
+ layoutTimer = undefined
297
+ const proc = child
298
+ child = undefined
299
+ try { proc?.kill() } catch { /* already gone */ }
300
+ siblings = undefined
301
+ listeners.clear()
302
+ },
303
+ }
304
+ }