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/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 { listSiblings, parseAgentList, parseTabList, type Sibling } from "./herdr.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"
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 and
35
- * Herdr pollers, and the header/footer/status-line chrome. Disposal stops every timer and makes
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: Sibling[] | undefined
62
- let herdrTimer: ReturnType<typeof setInterval> | undefined
63
- let herdrPeriod = 0
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, { gitState: gitState.get, siblings: () => siblings, onGitChange: gitState.onChange })
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
- const scheduleHerdr = (periodMs: number) => {
99
- if (!alive || herdrPeriod === periodMs) return
100
- if (herdrTimer) clearInterval(herdrTimer)
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
- if (herdrTimer) clearInterval(herdrTimer)
150
- herdrTimer = undefined
151
- herdrInFlight?.abort()
152
- herdrInFlight = undefined
176
+ background.dispose()
177
+ prs.dispose()
178
+ siblings?.dispose()
153
179
  siblings = undefined
154
180
  gitState.dispose()
155
181
  stopFooter()
@@ -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
+ }