pi-minimal 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -1
- package/CONTEXT.md +27 -3
- package/README.md +6 -3
- package/dist/background-overlay.js +95 -0
- package/dist/background.js +206 -0
- package/dist/config.js +8 -0
- package/dist/cwd.js +2 -2
- package/dist/footer.js +15 -3
- package/dist/herdr.js +78 -0
- package/dist/index.js +62 -1
- package/dist/listeners.js +23 -0
- package/dist/prs-command.js +76 -0
- package/dist/prs.js +270 -0
- package/dist/rex.js +289 -0
- package/dist/session.js +72 -59
- package/dist/siblings.js +14 -0
- package/package.json +5 -3
- package/release-notes.md +29 -42
- package/src/background-overlay.ts +103 -0
- package/src/background.ts +250 -0
- package/src/config.ts +8 -0
- package/src/cwd.ts +5 -4
- package/src/footer.ts +23 -3
- package/src/herdr.ts +78 -5
- package/src/index.ts +63 -2
- package/src/listeners.ts +22 -0
- package/src/prs-command.ts +81 -0
- package/src/prs.ts +348 -0
- package/src/rex.ts +304 -0
- package/src/session.ts +76 -50
- package/src/siblings.ts +38 -0
- package/test/background.test.ts +198 -0
- package/test/config.test.ts +23 -10
- package/test/footer-composition.test.ts +61 -0
- package/test/index.test.ts +157 -1
- package/test/prs.test.ts +272 -0
- package/test/rex.test.ts +249 -0
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
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -7,32 +7,58 @@ import { bindFooterContributionEvents } from "./footer-contribution-events.js"
|
|
|
7
7
|
import { createFooterContributions } from "./footer-contributions.js"
|
|
8
8
|
import { createGitState } from "./git-state.js"
|
|
9
9
|
import { installHeader } from "./header.js"
|
|
10
|
-
import {
|
|
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"
|
|
11
13
|
import { applyReclaim, reclaimPolicy, type ReclaimReason } from "./reclaim.js"
|
|
14
|
+
import type { RexSpawn } from "./rex.js"
|
|
15
|
+
import { createSiblingSource, type SiblingSource } from "./siblings.js"
|
|
12
16
|
import { setSessionCwd } from "./tool-rows.js"
|
|
13
17
|
import { setEditDiffMode } from "./tools.js"
|
|
14
18
|
import { hideGauntletWidgets } from "./tracker.js"
|
|
15
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
|
+
|
|
16
37
|
export type SessionDeps = {
|
|
17
38
|
/** Whether an agent run is in progress; owned by the extension because agent events are not session-scoped. */
|
|
18
39
|
busy: () => boolean
|
|
40
|
+
/** Test seam for the Rex event stream; defaults to node:child_process.spawn. */
|
|
41
|
+
spawn?: RexSpawn
|
|
19
42
|
}
|
|
20
43
|
|
|
21
44
|
export type Session = {
|
|
22
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
|
|
23
53
|
/** Re-asserts chrome after an event that may have let another extension draw over it. */
|
|
24
54
|
pin(reason: ReclaimReason): void
|
|
25
55
|
refreshGit(force?: boolean): void
|
|
26
56
|
dispose(): void
|
|
27
57
|
}
|
|
28
58
|
|
|
29
|
-
const HERDR_FAST_MS = 2000
|
|
30
|
-
const HERDR_SLOW_MS = 10000
|
|
31
|
-
const HERDR_EXEC_TIMEOUT_MS = 3000
|
|
32
|
-
|
|
33
59
|
/**
|
|
34
|
-
* Everything pi-minimal owns for one session: config, cwd, the contribution registry, the git
|
|
35
|
-
* Herdr
|
|
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
|
|
36
62
|
* late async results inert, so two sessions in one process never share or overwrite each other's
|
|
37
63
|
* state. Non-TUI sessions get a session too, but it only binds the contribution protocol (which
|
|
38
64
|
* replies `not-tui`) and installs no chrome.
|
|
@@ -43,7 +69,7 @@ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ses
|
|
|
43
69
|
const contributions = createFooterContributions()
|
|
44
70
|
const stopContributionEvents = bindFooterContributionEvents(pi.events, contributions, id, ctx.mode)
|
|
45
71
|
if (ctx.mode !== "tui") {
|
|
46
|
-
return { id, pin() {}, refreshGit() {}, dispose() { if (!alive) return; alive = false; stopContributionEvents() } }
|
|
72
|
+
return { id, pin() {}, refreshGit() {}, notePrUrls() {}, dispose() { if (!alive) return; alive = false; stopContributionEvents() } }
|
|
47
73
|
}
|
|
48
74
|
|
|
49
75
|
const config = loadPiMinimalConfig({ agentDir: getAgentDir(), cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })
|
|
@@ -58,10 +84,9 @@ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ses
|
|
|
58
84
|
|
|
59
85
|
const gitState = createGitState(pi, () => ctx.cwd)
|
|
60
86
|
let stopFooter = () => {}
|
|
61
|
-
let siblings:
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
let herdrInFlight: AbortController | undefined
|
|
87
|
+
let siblings: SiblingSource | undefined
|
|
88
|
+
const background = createBackgroundTracker({ pi })
|
|
89
|
+
const prs = createPrSource({ pi, cwd: () => ctx.cwd, sessionPrs: readSessionPrs(ctx) })
|
|
65
90
|
const settleTimers: ReturnType<typeof setTimeout>[] = []
|
|
66
91
|
|
|
67
92
|
const contextPct = (): number | undefined => {
|
|
@@ -77,11 +102,26 @@ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ses
|
|
|
77
102
|
busy: deps.busy,
|
|
78
103
|
editorText: () => (typeof ctx.ui.getEditorText === "function" ? ctx.ui.getEditorText() : ""),
|
|
79
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,
|
|
80
112
|
}, contributions)
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
const installStatusLine = () => {
|
|
84
|
-
installCwd(ctx, {
|
|
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
|
+
})
|
|
85
125
|
}
|
|
86
126
|
|
|
87
127
|
const pin = (reason: ReclaimReason) => {
|
|
@@ -95,37 +135,9 @@ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ses
|
|
|
95
135
|
installStatusLine()
|
|
96
136
|
}
|
|
97
137
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
herdrPeriod = periodMs
|
|
102
|
-
herdrTimer = setInterval(() => { void probeHerdr() }, periodMs)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const probeHerdr = async () => {
|
|
106
|
-
if (!alive || herdrInFlight) return
|
|
107
|
-
const ac = new AbortController()
|
|
108
|
-
herdrInFlight = ac
|
|
109
|
-
try {
|
|
110
|
-
const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
|
|
111
|
-
if (!alive) return
|
|
112
|
-
const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
|
|
113
|
-
if (!alive) return
|
|
114
|
-
if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
|
|
115
|
-
siblings = undefined
|
|
116
|
-
scheduleHerdr(HERDR_SLOW_MS)
|
|
117
|
-
return
|
|
118
|
-
}
|
|
119
|
-
siblings = listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), process.env.HERDR_PANE_ID, process.env.HERDR_TAB_ID)
|
|
120
|
-
scheduleHerdr(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS)
|
|
121
|
-
} catch {
|
|
122
|
-
if (!alive) return
|
|
123
|
-
siblings = undefined
|
|
124
|
-
scheduleHerdr(HERDR_SLOW_MS)
|
|
125
|
-
} finally {
|
|
126
|
-
if (herdrInFlight === ac) herdrInFlight = undefined
|
|
127
|
-
}
|
|
128
|
-
}
|
|
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 })
|
|
129
141
|
|
|
130
142
|
// Install once, then re-assert shortly after: other extensions' session_start handlers may draw after ours.
|
|
131
143
|
installHeader(ctx)
|
|
@@ -136,20 +148,34 @@ export function createSession(pi: ExtensionAPI, ctx: ExtensionContext, deps: Ses
|
|
|
136
148
|
ctx.ui.setHiddenThinkingLabel("◆ Thought")
|
|
137
149
|
ctx.ui.setWorkingIndicator()
|
|
138
150
|
for (const delay of [0, 50]) settleTimers.push(setTimeout(() => pin("session_settle"), delay))
|
|
139
|
-
if (process.env.HERDR_ENV === "1") void probeHerdr()
|
|
140
151
|
|
|
141
152
|
return {
|
|
142
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
|
+
},
|
|
143
170
|
pin,
|
|
144
|
-
refreshGit(force) { if (alive) void gitState.refresh(force) },
|
|
171
|
+
refreshGit(force) { if (alive) { void gitState.refresh(force); void prs.refresh() } },
|
|
145
172
|
dispose() {
|
|
146
173
|
if (!alive) return
|
|
147
174
|
alive = false
|
|
148
175
|
for (const timer of settleTimers.splice(0)) clearTimeout(timer)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
herdrInFlight = undefined
|
|
176
|
+
background.dispose()
|
|
177
|
+
prs.dispose()
|
|
178
|
+
siblings?.dispose()
|
|
153
179
|
siblings = undefined
|
|
154
180
|
gitState.dispose()
|
|
155
181
|
stopFooter()
|
package/src/siblings.ts
ADDED
|
@@ -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
|
+
}
|