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.
- package/CHANGELOG.md +81 -1
- package/CONTEXT.md +107 -0
- package/README.md +9 -6
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +10 -1
- package/dist/cwd.js +2 -2
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/footer.js +15 -3
- package/dist/git-state.js +31 -11
- package/dist/herdr.js +78 -0
- package/dist/index.js +79 -202
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/reclaim.js +0 -3
- package/dist/rex.js +289 -0
- package/dist/session.js +168 -0
- package/dist/siblings.js +14 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +5 -3
- package/release-notes.md +33 -12
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +10 -1
- package/src/cwd.ts +5 -4
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/footer.ts +23 -3
- package/src/git-state.ts +29 -10
- package/src/herdr.ts +78 -5
- package/src/index.ts +81 -195
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/reclaim.ts +0 -4
- package/src/rex.ts +304 -0
- package/src/session.ts +185 -0
- package/src/siblings.ts +38 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +24 -11
- package/test/footer-composition.test.ts +61 -0
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +202 -4
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /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
|
+
}
|