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/edit-diff.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { diffWords } from "diff"
|
|
3
|
+
import type { ToolResultLike } from "./tool-rows.js"
|
|
4
|
+
|
|
5
|
+
/** The edit tool's `details.diff`, when present and non-empty. */
|
|
6
|
+
export function editDiffText(result: ToolResultLike | undefined): string | undefined {
|
|
7
|
+
const details = result?.details as { diff?: unknown } | undefined
|
|
8
|
+
return typeof details?.diff === "string" && details.diff.trim() ? details.diff : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type DiffLine = { prefix: "+" | "-" | " "; lineNum: string; content: string }
|
|
12
|
+
|
|
13
|
+
/** pi's edit diff lines: "+123 content", "-123 content", " 123 context"; anything else (e.g. "...") is a separator. */
|
|
14
|
+
function parseDiffLine(line: string): DiffLine | undefined {
|
|
15
|
+
const match = /^([+\- ])(\s*\d*) (.*)$/.exec(line)
|
|
16
|
+
if (!match) return undefined
|
|
17
|
+
return { prefix: match[1] as DiffLine["prefix"], lineNum: match[2]!, content: match[3]!.replace(/\t/g, " ") }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Word-level diff of a 1:1 replacement; changed tokens are inverted, leading indentation is left alone, as in pi. */
|
|
21
|
+
function highlightPair(removed: string, added: string, theme: Theme): { removed: string; added: string } {
|
|
22
|
+
let out = { removed: "", added: "" }
|
|
23
|
+
let firstRemoved = true
|
|
24
|
+
let firstAdded = true
|
|
25
|
+
for (const part of diffWords(removed, added)) {
|
|
26
|
+
if (part.removed || part.added) {
|
|
27
|
+
const key = part.removed ? "removed" : "added"
|
|
28
|
+
let value = part.value
|
|
29
|
+
if (part.removed ? firstRemoved : firstAdded) {
|
|
30
|
+
const ws = /^(\s*)/.exec(value)?.[1] ?? ""
|
|
31
|
+
value = value.slice(ws.length)
|
|
32
|
+
out = { ...out, [key]: out[key] + ws }
|
|
33
|
+
if (part.removed) firstRemoved = false
|
|
34
|
+
else firstAdded = false
|
|
35
|
+
}
|
|
36
|
+
if (value) out = { ...out, [key]: out[key] + theme.inverse(value) }
|
|
37
|
+
} else {
|
|
38
|
+
out = { removed: out.removed + part.value, added: out.added + part.value }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return out
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Colours pi's edit diff like pi does: theme diff colours per line, inverse on changed tokens of single-line replacements. */
|
|
45
|
+
export function colorDiffLines(lines: string[], theme: Theme): string[] {
|
|
46
|
+
const out: string[] = []
|
|
47
|
+
for (let i = 0; i < lines.length; ) {
|
|
48
|
+
const parsed = parseDiffLine(lines[i]!)
|
|
49
|
+
if (!parsed) {
|
|
50
|
+
out.push(theme.fg("toolDiffContext", lines[i]!))
|
|
51
|
+
i++
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (parsed.prefix === " ") {
|
|
55
|
+
out.push(theme.fg("toolDiffContext", ` ${parsed.lineNum} ${parsed.content}`))
|
|
56
|
+
i++
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const removed: DiffLine[] = []
|
|
60
|
+
const added: DiffLine[] = []
|
|
61
|
+
for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "-"; p = parseDiffLine(lines[++i] ?? "")) removed.push(p)
|
|
62
|
+
for (let p = parseDiffLine(lines[i] ?? ""); p?.prefix === "+"; p = parseDiffLine(lines[++i] ?? "")) added.push(p)
|
|
63
|
+
if (removed.length === 1 && added.length === 1) {
|
|
64
|
+
const pair = highlightPair(removed[0]!.content, added[0]!.content, theme)
|
|
65
|
+
out.push(theme.fg("toolDiffRemoved", `-${removed[0]!.lineNum} ${pair.removed}`))
|
|
66
|
+
out.push(theme.fg("toolDiffAdded", `+${added[0]!.lineNum} ${pair.added}`))
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
for (const line of removed) out.push(theme.fg("toolDiffRemoved", `-${line.lineNum} ${line.content}`))
|
|
70
|
+
for (const line of added) out.push(theme.fg("toolDiffAdded", `+${line.lineNum} ${line.content}`))
|
|
71
|
+
}
|
|
72
|
+
return out
|
|
73
|
+
}
|
|
@@ -5,8 +5,11 @@ export const FOOTER_EVENTS = {
|
|
|
5
5
|
ready: "pi-minimal:footer:v1:ready",
|
|
6
6
|
unavailable: "pi-minimal:footer:v1:unavailable",
|
|
7
7
|
} as const
|
|
8
|
+
/** Slot names on the wire. `summary` is the legacy name of the plan slot and stays accepted. */
|
|
9
|
+
export const FOOTER_SLOTS = ["activity", "plan", "summary"] as const
|
|
10
|
+
export type FooterSlot = typeof FOOTER_SLOTS[number]
|
|
8
11
|
export type RegisterRequest = {
|
|
9
|
-
version: 1; sessionId: string; owner: string; slot:
|
|
12
|
+
version: 1; sessionId: string; owner: string; slot: FooterSlot
|
|
10
13
|
result?: RegistrationResult | { ok: false; code: "not-tui" }
|
|
11
14
|
}
|
|
12
15
|
type Events = {
|
|
@@ -22,11 +25,11 @@ export function bindFooterContributionEvents(events: Events, registry: FooterCon
|
|
|
22
25
|
const request = value as Partial<RegisterRequest>
|
|
23
26
|
// A different host may have answered first. Never overwrite its result.
|
|
24
27
|
if (request.result !== undefined) return
|
|
25
|
-
if (request.version !== 1 || (request.slot
|
|
28
|
+
if (request.version !== 1 || !FOOTER_SLOTS.includes(request.slot as FooterSlot)) request.result = { ok: false, code: "invalid" }
|
|
26
29
|
else if (mode !== "tui") request.result = { ok: false, code: "not-tui" }
|
|
27
|
-
else request.result = request.slot === "
|
|
30
|
+
else request.result = request.slot === "activity" ? registry.register(request) : registry.registerPlan(request)
|
|
28
31
|
})
|
|
29
|
-
if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [
|
|
32
|
+
if (mode === "tui") events.emit(FOOTER_EVENTS.ready, { version: 1, sessionId, slots: [...FOOTER_SLOTS] })
|
|
30
33
|
let disposed = false
|
|
31
34
|
return () => {
|
|
32
35
|
if (disposed) return
|
|
@@ -77,7 +77,8 @@ function normalizeSnapshot(input: unknown): ActivitySnapshot | undefined {
|
|
|
77
77
|
|
|
78
78
|
function createFooterSlot<T extends { revision: number }>(normalize: (input: unknown) => T | undefined, initialSessionId: string | null) {
|
|
79
79
|
let sessionId = initialSessionId
|
|
80
|
-
|
|
80
|
+
// The registrant's `owner` string is a display identity; this symbol is the actual authority to publish.
|
|
81
|
+
let ownerToken: symbol | undefined
|
|
81
82
|
let snapshot: T | undefined
|
|
82
83
|
const observers = new Set<() => void>()
|
|
83
84
|
const notify = () => {
|
|
@@ -89,17 +90,17 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
89
90
|
read: () => snapshot,
|
|
90
91
|
subscribe(observer: () => void) { observers.add(observer); return () => { observers.delete(observer) } },
|
|
91
92
|
reset(nextSessionId: string | null) {
|
|
92
|
-
sessionId = nextSessionId;
|
|
93
|
+
sessionId = nextSessionId; ownerToken = undefined; snapshot = undefined; notify()
|
|
93
94
|
},
|
|
94
95
|
register(input: unknown): RegistrationResult {
|
|
95
96
|
if (!record(input) || !identity(input.owner) || typeof input.sessionId !== "string" || !input.sessionId) return { ok: false, code: "invalid" }
|
|
96
97
|
if (sessionId === null || input.sessionId !== sessionId) return { ok: false, code: "wrong-session" }
|
|
97
|
-
if (
|
|
98
|
+
if (ownerToken) return { ok: false, code: "occupied" }
|
|
98
99
|
const token = Symbol(input.owner)
|
|
99
|
-
|
|
100
|
+
ownerToken = token
|
|
100
101
|
return { ok: true, registration: {
|
|
101
102
|
publish(input: unknown) {
|
|
102
|
-
if (
|
|
103
|
+
if (ownerToken !== token) return { ok: false, code: "disposed" }
|
|
103
104
|
const next = normalize(input)
|
|
104
105
|
if (!next) return { ok: false, code: "invalid" }
|
|
105
106
|
if (snapshot && next.revision <= snapshot.revision) return { ok: false, code: "stale" }
|
|
@@ -107,8 +108,8 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
107
108
|
return { ok: true }
|
|
108
109
|
},
|
|
109
110
|
dispose() {
|
|
110
|
-
if (
|
|
111
|
-
|
|
111
|
+
if (ownerToken !== token) return
|
|
112
|
+
ownerToken = undefined; snapshot = undefined; notify()
|
|
112
113
|
},
|
|
113
114
|
} }
|
|
114
115
|
},
|
|
@@ -116,17 +117,18 @@ function createFooterSlot<T extends { revision: number }>(normalize: (input: unk
|
|
|
116
117
|
}
|
|
117
118
|
export function createFooterContributions(initialSessionId: string | null = null) {
|
|
118
119
|
const activity = createFooterSlot(normalizeSnapshot, initialSessionId)
|
|
119
|
-
|
|
120
|
+
// The plan slot; "summary" is its legacy wire name.
|
|
121
|
+
const plan = createFooterSlot(normalizePlanSnapshot, initialSessionId)
|
|
120
122
|
return {
|
|
121
123
|
read: activity.read,
|
|
122
|
-
readPlan:
|
|
124
|
+
readPlan: plan.read,
|
|
123
125
|
register: activity.register,
|
|
124
|
-
registerPlan:
|
|
126
|
+
registerPlan: plan.register,
|
|
125
127
|
subscribe(observer: () => void) {
|
|
126
|
-
const offActivity = activity.subscribe(observer),
|
|
127
|
-
return () => { offActivity();
|
|
128
|
+
const offActivity = activity.subscribe(observer), offPlan = plan.subscribe(observer)
|
|
129
|
+
return () => { offActivity(); offPlan() }
|
|
128
130
|
},
|
|
129
|
-
reset(sessionId: string | null) { activity.reset(sessionId);
|
|
131
|
+
reset(sessionId: string | null) { activity.reset(sessionId); plan.reset(sessionId) },
|
|
130
132
|
}
|
|
131
133
|
}
|
|
132
134
|
export type FooterContributions = ReturnType<typeof createFooterContributions>
|
package/src/footer.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
|
2
2
|
import { keyHint } from "@earendil-works/pi-coding-agent"
|
|
3
3
|
import { truncateToWidth } from "@earendil-works/pi-tui"
|
|
4
4
|
import * as chrome from "./chrome.js"
|
|
5
|
+
import { splitRow } from "./cwd.js"
|
|
5
6
|
import { formatTrackerLines } from "./tracker.js"
|
|
6
7
|
import type { FooterContributions } from "./footer-contributions.js"
|
|
7
8
|
import { activityNeedsAnimation, renderFooterActivity } from "./footer-activity.js"
|
|
@@ -19,6 +20,14 @@ export type FooterSources = {
|
|
|
19
20
|
editorText: () => string
|
|
20
21
|
/** Context usage percentage, when known. */
|
|
21
22
|
contextPct: () => number | undefined
|
|
23
|
+
/** Open PR chips (`#1675 #1670 +3`), themed and linked, shown right-aligned on the hints line. */
|
|
24
|
+
prs?: (theme: { fg(color: string, text: string): string }) => string | undefined
|
|
25
|
+
/** Fires when the PR chips may have changed. */
|
|
26
|
+
onPrsChange?: (listener: () => void) => () => void
|
|
27
|
+
/** `⚙ 2 bg` while bash-tool processes run in the background; shown after the hints. */
|
|
28
|
+
background?: () => string | undefined
|
|
29
|
+
/** Fires when the background count may have changed. */
|
|
30
|
+
onBackgroundChange?: (listener: () => void) => () => void
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
/**
|
|
@@ -52,11 +61,15 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
|
|
|
52
61
|
const update = () => { syncAnimation(); redraw() }
|
|
53
62
|
const unsub = footerData.onBranchChange(redraw)
|
|
54
63
|
const unsubscribeActivity = contributions?.subscribe(update)
|
|
64
|
+
const unsubscribePrs = sources.onPrsChange?.(redraw)
|
|
65
|
+
const unsubscribeBackground = sources.onBackgroundChange?.(redraw)
|
|
55
66
|
stopMount = () => {
|
|
56
67
|
if (disposed) return
|
|
57
68
|
disposed = true
|
|
58
69
|
unsub()
|
|
59
70
|
unsubscribeActivity?.()
|
|
71
|
+
unsubscribePrs?.()
|
|
72
|
+
unsubscribeBackground?.()
|
|
60
73
|
live.stop()
|
|
61
74
|
}
|
|
62
75
|
update()
|
|
@@ -71,9 +84,16 @@ export function installFooter(ctx: ExtensionContext, sources: FooterSources, con
|
|
|
71
84
|
keyHint("app.thinking.cycle", "thinking"),
|
|
72
85
|
keyHint("app.tools.expand", "expand tools"),
|
|
73
86
|
].filter(Boolean))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
// Left: hints (idle only) then the background count (always). Right: PR chips whenever
|
|
88
|
+
// there are any. The chips are dropped before the left side when the line is too narrow.
|
|
89
|
+
const background = sources.background?.()
|
|
90
|
+
const left = [hints.length > 0 ? theme.fg("dim", hints.join(" · ")) : "", background ? theme.fg("warning", background) : ""]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(theme.fg("dim", " · "))
|
|
93
|
+
const prs = sources.prs?.(theme)
|
|
94
|
+
if (left || prs) {
|
|
95
|
+
const line = prs ? splitRow(left, [prs], inner) : truncateToWidth(left, inner, "")
|
|
96
|
+
out.push(`${" ".repeat(margin())}${line}`)
|
|
77
97
|
}
|
|
78
98
|
const activityWidth = Math.max(0, width - margin() * 2)
|
|
79
99
|
const plan = renderFooterPlan(contributions?.readPlan(), activityWidth, theme)
|
package/src/git-state.ts
CHANGED
|
@@ -30,30 +30,45 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
|
|
|
30
30
|
let state: GitState | undefined
|
|
31
31
|
let lastRun = 0
|
|
32
32
|
let inFlight: Promise<void> | undefined
|
|
33
|
+
let disposed = false
|
|
33
34
|
const listeners = new Set<() => void>()
|
|
34
35
|
|
|
36
|
+
const notify = () => {
|
|
37
|
+
for (const listener of listeners) {
|
|
38
|
+
try {
|
|
39
|
+
listener()
|
|
40
|
+
} catch {
|
|
41
|
+
// One listener's failure must not starve the others.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
const refresh = (force = false): Promise<void> => {
|
|
47
|
+
if (disposed) return Promise.resolve()
|
|
36
48
|
const now = Date.now()
|
|
37
49
|
if (inFlight) return inFlight
|
|
38
50
|
if (!force && now - lastRun < minIntervalMs) return Promise.resolve()
|
|
39
51
|
lastRun = now
|
|
52
|
+
// Read the directory once so both commands describe the same tree even if cwd changes mid-refresh.
|
|
53
|
+
const dir = cwd()
|
|
40
54
|
inFlight = (async () => {
|
|
55
|
+
let next: GitState | undefined
|
|
41
56
|
try {
|
|
42
|
-
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd:
|
|
43
|
-
if (branchRun.code
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
const branchRun = await pi.exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, timeout: 2000 })
|
|
58
|
+
if (branchRun.code === 0) {
|
|
59
|
+
const branch = parseBranch(branchRun.stdout)
|
|
60
|
+
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
|
|
61
|
+
next = { branch, dirty: statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined }
|
|
46
62
|
}
|
|
47
|
-
const branch = parseBranch(branchRun.stdout)
|
|
48
|
-
const statusRun = await pi.exec("git", ["status", "--porcelain"], { cwd: cwd(), timeout: 3000 })
|
|
49
|
-
const dirty = statusRun.code === 0 ? parsePorcelain(statusRun.stdout) : undefined
|
|
50
|
-
state = { branch, dirty }
|
|
51
63
|
} catch {
|
|
52
|
-
|
|
64
|
+
next = undefined
|
|
53
65
|
} finally {
|
|
54
66
|
inFlight = undefined
|
|
55
|
-
for (const listener of listeners) listener()
|
|
56
67
|
}
|
|
68
|
+
// A result that lands after dispose belongs to a session that no longer exists.
|
|
69
|
+
if (disposed) return
|
|
70
|
+
state = next
|
|
71
|
+
notify()
|
|
57
72
|
})()
|
|
58
73
|
return inFlight
|
|
59
74
|
}
|
|
@@ -65,6 +80,10 @@ export function createGitState(pi: ExtensionAPI, cwd: () => string, minIntervalM
|
|
|
65
80
|
listeners.add(listener)
|
|
66
81
|
return () => listeners.delete(listener)
|
|
67
82
|
},
|
|
83
|
+
dispose() {
|
|
84
|
+
disposed = true
|
|
85
|
+
listeners.clear()
|
|
86
|
+
},
|
|
68
87
|
}
|
|
69
88
|
}
|
|
70
89
|
|
package/src/herdr.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
2
|
+
import { createListeners } from "./listeners.js"
|
|
3
|
+
import type { Sibling, SiblingSource } from "./siblings.js"
|
|
4
|
+
|
|
5
|
+
export type { Sibling } from "./siblings.js"
|
|
6
|
+
|
|
1
7
|
export type HerdrAgent = {
|
|
2
8
|
agent: string
|
|
3
9
|
agent_status: string
|
|
@@ -40,11 +46,6 @@ export function parseTabList(stdout: string): HerdrTab[] {
|
|
|
40
46
|
}))
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
export type Sibling = {
|
|
44
|
-
name: string
|
|
45
|
-
status: string
|
|
46
|
-
}
|
|
47
|
-
|
|
48
49
|
export function listSiblings(
|
|
49
50
|
agents: HerdrAgent[],
|
|
50
51
|
tabs: HerdrTab[],
|
|
@@ -64,3 +65,75 @@ export function listSiblings(
|
|
|
64
65
|
if (parts.length === 0) return undefined
|
|
65
66
|
return parts
|
|
66
67
|
}
|
|
68
|
+
|
|
69
|
+
const HERDR_FAST_MS = 2000
|
|
70
|
+
const HERDR_SLOW_MS = 10000
|
|
71
|
+
const HERDR_EXEC_TIMEOUT_MS = 3000
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Polls `herdr agent list` + `herdr tab list`: every 2 s while siblings are visible, every 10 s
|
|
75
|
+
* otherwise or after a failure. Probes once immediately.
|
|
76
|
+
*/
|
|
77
|
+
export function createHerdrSiblings(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env): SiblingSource {
|
|
78
|
+
let alive = true
|
|
79
|
+
let siblings: Sibling[] | undefined
|
|
80
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
81
|
+
let period = 0
|
|
82
|
+
let inFlight: AbortController | undefined
|
|
83
|
+
const listeners = createListeners()
|
|
84
|
+
|
|
85
|
+
const set = (next: Sibling[] | undefined) => {
|
|
86
|
+
const changed = JSON.stringify(next) !== JSON.stringify(siblings)
|
|
87
|
+
siblings = next
|
|
88
|
+
if (changed) listeners.notify()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const schedule = (periodMs: number) => {
|
|
92
|
+
if (!alive || period === periodMs) return
|
|
93
|
+
if (timer) clearInterval(timer)
|
|
94
|
+
period = periodMs
|
|
95
|
+
timer = setInterval(() => { void probe() }, periodMs)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const probe = async () => {
|
|
99
|
+
if (!alive || inFlight) return
|
|
100
|
+
const ac = new AbortController()
|
|
101
|
+
inFlight = ac
|
|
102
|
+
try {
|
|
103
|
+
const agentRun = await pi.exec("herdr", ["agent", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
|
|
104
|
+
if (!alive) return
|
|
105
|
+
const tabRun = await pi.exec("herdr", ["tab", "list"], { timeout: HERDR_EXEC_TIMEOUT_MS, signal: ac.signal })
|
|
106
|
+
if (!alive) return
|
|
107
|
+
if (agentRun.code !== 0 || tabRun.code !== 0 || agentRun.killed || tabRun.killed) {
|
|
108
|
+
set(undefined)
|
|
109
|
+
schedule(HERDR_SLOW_MS)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
set(listSiblings(parseAgentList(agentRun.stdout), parseTabList(tabRun.stdout), env.HERDR_PANE_ID, env.HERDR_TAB_ID))
|
|
113
|
+
schedule(siblings ? HERDR_FAST_MS : HERDR_SLOW_MS)
|
|
114
|
+
} catch {
|
|
115
|
+
if (!alive) return
|
|
116
|
+
set(undefined)
|
|
117
|
+
schedule(HERDR_SLOW_MS)
|
|
118
|
+
} finally {
|
|
119
|
+
if (inFlight === ac) inFlight = undefined
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
void probe()
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
get: () => siblings,
|
|
127
|
+
onChange: listeners.add,
|
|
128
|
+
dispose() {
|
|
129
|
+
if (!alive) return
|
|
130
|
+
alive = false
|
|
131
|
+
if (timer) clearInterval(timer)
|
|
132
|
+
timer = undefined
|
|
133
|
+
inFlight?.abort()
|
|
134
|
+
inFlight = undefined
|
|
135
|
+
siblings = undefined
|
|
136
|
+
listeners.clear()
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|