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.
@@ -0,0 +1,198 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
4
+ import {
5
+ applyPoll,
6
+ createBackgroundState,
7
+ createBackgroundTracker,
8
+ findOrphanGroups,
9
+ parseEtime,
10
+ parsePs,
11
+ PS_ARGS,
12
+ snapshot,
13
+ } from "../src/background.js"
14
+ import { backgroundRows, backgroundSegment, renderBackgroundLines } from "../src/background-overlay.js"
15
+
16
+ // `ps -ax -o pid=,ppid=,pgid=,etime=,command=` as macOS prints it (right-aligned numbers).
17
+ const PI = 76737
18
+ const SHELL = 81443
19
+ const psBefore = `
20
+ ${PI} 78317 ${PI} 04:11 pi
21
+ 81000 1 81000 01-02:03:04 /usr/libexec/trustd --agent
22
+ `
23
+ const psDuring = `${psBefore}
24
+ ${SHELL} ${PI} ${SHELL} 00:00 /bin/bash -c (./watch.sh &); sleep 0.3
25
+ `
26
+ const psAfter = `${psBefore}
27
+ 81446 1 ${SHELL} 00:01 /bin/bash ./watch.sh
28
+ 81450 1 ${SHELL} 00:01 node server.js
29
+ 81460 1 81460 00:00 /usr/libexec/other-daemon
30
+ `
31
+
32
+ test.describe("ps parsing", () => {
33
+ test("parseEtime handles every format", () => {
34
+ assert.equal(parseEtime("05"), 5)
35
+ assert.equal(parseEtime("04:11"), 251)
36
+ assert.equal(parseEtime("01:02:03"), 3723)
37
+ assert.equal(parseEtime("01-02:03:04"), 86400 + 7384)
38
+ assert.equal(parseEtime("garbage"), 0)
39
+ })
40
+
41
+ test("parsePs reads pid, ppid, pgid, etime and the full command", () => {
42
+ const entries = parsePs(psAfter)
43
+ assert.deepEqual(entries.get(81446), { pid: 81446, ppid: 1, pgid: SHELL, elapsedSec: 1, command: "/bin/bash ./watch.sh" })
44
+ assert.equal(entries.get(81000)?.elapsedSec, 86400 + 7384)
45
+ assert.equal(entries.size, 5)
46
+ })
47
+ })
48
+
49
+ test.describe("findOrphanGroups", () => {
50
+ test("finds the dead tool shell's group and ignores unrelated orphans and pre-existing groups", () => {
51
+ const groups = findOrphanGroups(parsePs(psBefore), parsePs(psAfter))
52
+ assert.deepEqual([...groups], [SHELL])
53
+ })
54
+
55
+ test("a group whose leader is still alive is a foreground call, not background", () => {
56
+ const groups = findOrphanGroups(parsePs(psBefore), parsePs(`${psDuring}\n 81446 1 ${SHELL} 00:01 node server.js`))
57
+ assert.equal(groups.size, 0)
58
+ })
59
+
60
+ test("a group that existed before the call belongs to someone else", () => {
61
+ const before = parsePs(`${psBefore}\n 81446 1 ${SHELL} 00:10 node server.js`)
62
+ assert.equal(findOrphanGroups(before, parsePs(psAfter)).size, 0)
63
+ })
64
+ })
65
+
66
+ test.describe("applyPoll", () => {
67
+ test("tracks members, marks them ended when gone, forgets them after the TTL and drops empty groups", () => {
68
+ const state = createBackgroundState()
69
+ state.groups.add(SHELL)
70
+ let now = 100_000
71
+ assert.equal(applyPoll(state, parsePs(psAfter), now), true)
72
+ let snap = snapshot(state)
73
+ assert.deepEqual(snap.running.map((p) => [p.pid, p.command, p.startedAt]), [
74
+ [81446, "/bin/bash ./watch.sh", now - 1000],
75
+ [81450, "node server.js", now - 1000],
76
+ ])
77
+ assert.equal(snap.ended.length, 0)
78
+
79
+ // Nothing changed: no notification.
80
+ assert.equal(applyPoll(state, parsePs(psAfter), now + 2000), false)
81
+
82
+ // server.js exits.
83
+ now += 4000
84
+ const onlyWatch = psAfter.split("\n").filter((line) => !line.includes("server.js")).join("\n")
85
+ assert.equal(applyPoll(state, parsePs(onlyWatch), now), true)
86
+ snap = snapshot(state)
87
+ assert.deepEqual(snap.running.map((p) => p.pid), [81446])
88
+ assert.deepEqual(snap.ended.map((p) => [p.pid, p.endedAt]), [[81450, now]])
89
+
90
+ // Everything exits; the ended entries linger for a minute, then the group is dropped.
91
+ now += 2000
92
+ assert.equal(applyPoll(state, parsePs(psBefore), now), true)
93
+ assert.equal(snapshot(state).running.length, 0)
94
+ assert.equal(snapshot(state).ended.length, 2)
95
+ assert.equal(state.groups.has(SHELL), true)
96
+ now += 61_000
97
+ assert.equal(applyPoll(state, parsePs(psBefore), now), true)
98
+ assert.equal(snapshot(state).ended.length, 0)
99
+ assert.equal(state.groups.size, 0)
100
+ })
101
+ })
102
+
103
+ test.describe("overlay rendering", () => {
104
+ const theme = { fg: (_c: string, t: string) => t }
105
+
106
+ test("segment shows the running count only", () => {
107
+ assert.equal(backgroundSegment({ running: [], ended: [] }), undefined)
108
+ const proc = { pid: 1, pgid: 1, command: "x", startedAt: 0 }
109
+ assert.equal(backgroundSegment({ running: [proc, proc], ended: [proc] }), "⚙ 2 bg")
110
+ })
111
+
112
+ test("rows carry status and duration; lines fit the width and mark the selection", () => {
113
+ const now = 1_000_000
114
+ const rows = backgroundRows(
115
+ {
116
+ running: [{ pid: 10, pgid: 1, command: "npm run dev", startedAt: now - 252_000 }],
117
+ ended: [{ pid: 11, pgid: 1, command: "tail -f app.log", startedAt: now - 40_000, endedAt: now - 8000 }],
118
+ },
119
+ now,
120
+ )
121
+ assert.deepEqual(rows, [
122
+ { pid: 10, command: "npm run dev", status: "running", durationMs: 252_000 },
123
+ { pid: 11, command: "tail -f app.log", status: "ended", durationMs: 32_000 },
124
+ ])
125
+ const lines = renderBackgroundLines(rows, 1, 60, theme, "ctrl+b")
126
+ assert.equal(lines.length, 4)
127
+ assert.match(lines[1] ?? "", /^ {2}npm run dev\s+running\s+4m 12s$/)
128
+ assert.match(lines[2] ?? "", /^› tail -f app.log\s+ended\s+ran 32s$/)
129
+ assert.ok(lines.every((line) => line.length <= 60))
130
+ assert.match(lines[3] ?? "", /ctrl\+b/)
131
+ })
132
+
133
+ test("empty list says so", () => {
134
+ const lines = renderBackgroundLines([], 0, 40, theme)
135
+ assert.match(lines[1] ?? "", /Nothing running/)
136
+ })
137
+ })
138
+
139
+ test.describe("createBackgroundTracker", () => {
140
+ const tick = () => new Promise((resolve) => setImmediate(resolve))
141
+ const settle = async () => { for (let i = 0; i < 6; i++) await tick() }
142
+
143
+ function fakePi(listings: string[]) {
144
+ const execCalls: string[][] = []
145
+ const pi = {
146
+ async exec(command: string, args: string[]) {
147
+ execCalls.push([command, ...args])
148
+ const stdout = listings.length > 1 ? (listings.shift() as string) : (listings[0] ?? "")
149
+ return { stdout, stderr: "", code: 0, killed: false }
150
+ },
151
+ } as unknown as ExtensionAPI
152
+ return { pi, execCalls }
153
+ }
154
+
155
+ test("a bash call that leaves processes behind is tracked; other tools are ignored", async () => {
156
+ const { pi, execCalls } = fakePi([psBefore, psAfter, psAfter])
157
+ const killed: Array<[number, string]> = []
158
+ let now = 500_000
159
+ const tracker = createBackgroundTracker({ pi, now: () => now, killProcess: (pid, signal) => { killed.push([pid, signal]) } })
160
+ let changes = 0
161
+ tracker.onChange(() => { changes += 1 })
162
+
163
+ tracker.toolStart("c1", "read")
164
+ tracker.toolEnd("c1", "read")
165
+ await settle()
166
+ assert.equal(execCalls.length, 0)
167
+
168
+ tracker.toolStart("c2", "bash")
169
+ await settle()
170
+ tracker.toolEnd("c2", "bash")
171
+ await settle()
172
+ assert.deepEqual(execCalls.slice(0, 2), [["ps", ...PS_ARGS], ["ps", ...PS_ARGS]])
173
+ assert.equal(changes, 1)
174
+ assert.deepEqual(tracker.get().running.map((p) => p.pid), [81446, 81450])
175
+
176
+ assert.equal(tracker.kill(81450), true)
177
+ assert.equal(tracker.kill(81446, { group: true, signal: "SIGKILL" }), true)
178
+ assert.equal(tracker.kill(99999), false)
179
+ assert.deepEqual(killed, [[81450, "SIGTERM"], [-SHELL, "SIGKILL"]])
180
+
181
+ now += 3000
182
+ tracker.dispose()
183
+ assert.deepEqual(tracker.get().running.map((p) => p.pid), [81446, 81450], "get() still answers after dispose")
184
+ })
185
+
186
+ test("a clean bash call tracks nothing and starts no poller", async () => {
187
+ const { pi, execCalls } = fakePi([psBefore, psBefore])
188
+ const tracker = createBackgroundTracker({ pi })
189
+ tracker.toolStart("c1", "bash")
190
+ tracker.toolEnd("c1", "bash")
191
+ await settle()
192
+ assert.equal(execCalls.length, 2)
193
+ assert.equal(tracker.get().running.length, 0)
194
+ await new Promise((resolve) => setTimeout(resolve, 30))
195
+ assert.equal(execCalls.length, 2, "no poll scheduled")
196
+ tracker.dispose()
197
+ })
198
+ })
@@ -30,7 +30,7 @@ test("defaults hideGauntletWidgets to false", (t) => {
30
30
  const { agentDir, cwd } = setup(t)
31
31
  const error = t.mock.method(console, "error")
32
32
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
33
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
33
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
34
34
  assert.equal(error.mock.callCount(), 0)
35
35
  })
36
36
 
@@ -39,7 +39,7 @@ test("global false overrides default", (t) => {
39
39
  writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
40
40
  const error = t.mock.method(console, "error")
41
41
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
42
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
42
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
43
43
  assert.equal(error.mock.callCount(), 0)
44
44
  })
45
45
 
@@ -49,7 +49,7 @@ test("trusted project true overrides global false", (t) => {
49
49
  writeProject(cwd, JSON.stringify({ hideGauntletWidgets: true, editDiff: "expanded" }))
50
50
  const error = t.mock.method(console, "error")
51
51
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
52
- assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
52
+ assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
53
53
  assert.equal(error.mock.callCount(), 0)
54
54
  })
55
55
 
@@ -59,7 +59,7 @@ test("untrusted project false does not override global true", (t) => {
59
59
  writeProject(cwd, JSON.stringify({ hideGauntletWidgets: false, editDiff: "expanded" }))
60
60
  const error = t.mock.method(console, "error")
61
61
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
62
- assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded" })
62
+ assert.deepEqual(config, { hideGauntletWidgets: true, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
63
63
  assert.equal(error.mock.callCount(), 0)
64
64
  })
65
65
 
@@ -69,7 +69,7 @@ test("omitted keys do not override", (t) => {
69
69
  writeProject(cwd, JSON.stringify({}))
70
70
  const error = t.mock.method(console, "error")
71
71
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
72
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
72
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
73
73
  assert.equal(error.mock.callCount(), 0)
74
74
  })
75
75
 
@@ -79,7 +79,7 @@ test("unknown keys do not override", (t) => {
79
79
  writeProject(cwd, JSON.stringify({ extra: true }))
80
80
  const error = t.mock.method(console, "error")
81
81
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
82
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
82
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
83
83
  assert.equal(error.mock.callCount(), 0)
84
84
  })
85
85
 
@@ -89,7 +89,7 @@ test("invalid JSON calls console.error and leaves the lower-precedence value unc
89
89
  writeProject(cwd, "{")
90
90
  const error = t.mock.method(console, "error")
91
91
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
92
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
92
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
93
93
  assert.ok(error.mock.callCount() >= 1)
94
94
  })
95
95
 
@@ -99,7 +99,7 @@ test("non-object root calls console.error and leaves the lower-precedence value
99
99
  writeProject(cwd, JSON.stringify(["hideGauntletWidgets", false]))
100
100
  const error = t.mock.method(console, "error")
101
101
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
102
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
102
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
103
103
  assert.ok(error.mock.callCount() >= 1)
104
104
  })
105
105
 
@@ -109,7 +109,7 @@ test("non-boolean hideGauntletWidgets calls console.error and leaves the lower-p
109
109
  writeProject(cwd, JSON.stringify({ hideGauntletWidgets: "yes" }))
110
110
  const error = t.mock.method(console, "error")
111
111
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
112
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
112
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded", backgroundShortcut: "ctrl+b" })
113
113
  assert.ok(error.mock.callCount() >= 1)
114
114
  })
115
115
 
@@ -129,7 +129,20 @@ test("an invalid editDiff value is reported and does not override", (t) => {
129
129
  writeProject(cwd, JSON.stringify({ editDiff: "sometimes" }))
130
130
  const error = t.mock.method(console, "error")
131
131
  const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
132
- assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "always" })
132
+ assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "always", backgroundShortcut: "ctrl+b" })
133
133
  assert.equal(error.mock.callCount(), 1)
134
134
  assert.match(String(error.mock.calls[0]!.arguments[0]), /editDiff must be one of "always", "never", "expanded"/)
135
135
  })
136
+
137
+ test("backgroundShortcut accepts a key name or null and rejects other values", (t) => {
138
+ const { agentDir, cwd } = setup(t)
139
+ writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: "ctrl+g" }))
140
+ assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, "ctrl+g")
141
+ writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: null }))
142
+ assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, null)
143
+ const errors: string[] = []
144
+ t.mock.method(console, "error", (message: string) => { errors.push(message) })
145
+ writeGlobal(agentDir, JSON.stringify({ backgroundShortcut: 7 }))
146
+ assert.equal(loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false }).backgroundShortcut, "ctrl+b")
147
+ assert.match(errors.join("\n"), /backgroundShortcut/)
148
+ })
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict"
2
2
  import test from "node:test"
3
3
  import { initTheme, type ExtensionContext } from "@earendil-works/pi-coding-agent"
4
+ import { visibleWidth } from "@earendil-works/pi-tui"
4
5
  import { installFooter } from "../src/footer.js"
5
6
  import { createFooterContributions, type ActivitySnapshot } from "../src/footer-contributions.js"
6
7
 
@@ -23,6 +24,8 @@ const data = (revision: number, motion: "spinner" | "static" = "spinner"): Activ
23
24
  revision, rows: [{ key: "1", agent: "worker", state: "running", text: "Last: tests" }], omitted: 0, motion, format: "icons",
24
25
  })
25
26
  const sources = { busy: () => false, editorText: () => "", contextPct: () => 10 }
27
+ // eslint-disable-next-line no-control-regex
28
+ const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
26
29
 
27
30
  test("no contributor preserves existing footer; activity is last even while typing/busy", () => {
28
31
  const f = fixture(), registry = createFooterContributions("S1")
@@ -127,3 +130,61 @@ test("legacy task summary precedes activity", () => {
127
130
  assert.ok(lines.at(-1)?.includes("worker"))
128
131
  dispose()
129
132
  })
133
+
134
+ test("background count follows the hints and survives hiding them", () => {
135
+ const f = fixture()
136
+ let bg: string | undefined = "⚙ 2 bg"
137
+ let listener: (() => void) | undefined
138
+ const bgSources = {
139
+ ...sources,
140
+ background: () => bg,
141
+ onBackgroundChange: (l: () => void) => { listener = l; return () => { listener = undefined } },
142
+ prs: () => "#24",
143
+ }
144
+ const stop = installFooter(f.ctx, bgSources)
145
+ const component = f.mount()
146
+ assert.match(stripAnsi(component.render(80)[0] ?? ""), /^ enter send.*expand tools {2}· {2}⚙ 2 bg\s+#24$/)
147
+ stop(); component.dispose?.()
148
+ const typingStop = installFooter(f.ctx, { ...bgSources, busy: () => true })
149
+ const typing = f.mount()
150
+ assert.match(typing.render(80)[0] ?? "", /^ ⚙ 2 bg\s+#24$/)
151
+ const before = f.renders
152
+ bg = undefined
153
+ listener?.()
154
+ assert.equal(f.renders, before + 1)
155
+ assert.deepEqual(typing.render(80).map((line) => line.trim()), ["#24"])
156
+ typingStop(); typing.dispose?.()
157
+ assert.equal(listener, undefined)
158
+ })
159
+
160
+ test("PR chips sit right-aligned on the hints line, stay when hints hide, and re-render on change", () => {
161
+ const f = fixture()
162
+ let chips: string | undefined = "#23 #22"
163
+ let listener: (() => void) | undefined
164
+ const prsSources = {
165
+ ...sources,
166
+ prs: () => chips,
167
+ onPrsChange: (l: () => void) => { listener = l; return () => { listener = undefined } },
168
+ }
169
+ const stop = installFooter(f.ctx, prsSources)
170
+ const component = f.mount()
171
+ const idle = component.render(80)
172
+ assert.equal(idle.length, 1)
173
+ assert.match(idle[0] ?? "", /^ \S.*send.*#23 #22$/)
174
+ assert.equal(visibleWidth(idle[0] ?? ""), 1 + 78, "chips are flush right within the margin")
175
+
176
+ // Typing hides the hints; the chips keep the line.
177
+ stop(); component.dispose?.()
178
+ const typingStop = installFooter(f.ctx, { ...prsSources, editorText: () => "draft" })
179
+ const typing = f.mount()
180
+ assert.deepEqual(typing.render(80).map((line) => line.trim()), ["#23 #22"])
181
+
182
+ // A change notification triggers a render; no chips, no line.
183
+ const before = f.renders
184
+ chips = undefined
185
+ listener?.()
186
+ assert.equal(f.renders, before + 1)
187
+ assert.deepEqual(typing.render(80), [])
188
+ typingStop(); typing.dispose?.()
189
+ assert.equal(listener, undefined, "unsubscribed on dispose")
190
+ })
@@ -17,11 +17,17 @@ type SessionMode = "tui" | "rpc" | "json" | "print"
17
17
 
18
18
  const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
19
19
  const originalHerdrEnv = process.env.HERDR_ENV
20
+ const originalRexSession = process.env.REX_SESSION
21
+ const originalRexBlock = process.env.REX_BLOCK
20
22
  const originalAgentDir = process.env[AGENT_DIR_ENV]
21
23
 
22
24
  function restoreEnv() {
23
25
  if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV
24
26
  else process.env.HERDR_ENV = originalHerdrEnv
27
+ if (originalRexSession === undefined) delete process.env.REX_SESSION
28
+ else process.env.REX_SESSION = originalRexSession
29
+ if (originalRexBlock === undefined) delete process.env.REX_BLOCK
30
+ else process.env.REX_BLOCK = originalRexBlock
25
31
  if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
26
32
  else process.env[AGENT_DIR_ENV] = originalAgentDir
27
33
  }
@@ -90,6 +96,9 @@ function createFake(options: {
90
96
  if (command === "herdr" && args[0] === "agent") {
91
97
  return { stdout: JSON.stringify({ result: { agents: [] } }), stderr: "", code: 0, killed: false }
92
98
  }
99
+ if (command === "gh") {
100
+ return { stdout: "", stderr: "not a github repo", code: 1, killed: false }
101
+ }
93
102
  if (command === "herdr" && args[0] === "tab") {
94
103
  return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: "", code: 0, killed: false }
95
104
  }
@@ -98,6 +107,12 @@ function createFake(options: {
98
107
  registerTool(...args: unknown[]) {
99
108
  record(registerCalls, "registerTool", args)
100
109
  },
110
+ registerCommand(...args: unknown[]) {
111
+ record(registerCalls, "registerCommand", args)
112
+ },
113
+ registerShortcut(...args: unknown[]) {
114
+ record(registerCalls, "registerShortcut", args)
115
+ },
101
116
  registerToolRenderer(...args: unknown[]) {
102
117
  record(registerCalls, "registerToolRenderer", args)
103
118
  },
@@ -136,6 +151,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
136
151
  const { agentDir, cwd } = setupDirs(t)
137
152
  process.env[AGENT_DIR_ENV] = agentDir
138
153
  delete process.env.HERDR_ENV
154
+ delete process.env.REX_SESSION
155
+ delete process.env.REX_BLOCK
139
156
  t.after(restoreEnv)
140
157
 
141
158
  const hideThinking = t.mock.method(SettingsManager.prototype, "setHideThinkingBlock")
@@ -172,6 +189,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
172
189
  const { agentDir, cwd } = setupDirs(t)
173
190
  process.env[AGENT_DIR_ENV] = agentDir
174
191
  delete process.env.HERDR_ENV
192
+ delete process.env.REX_SESSION
193
+ delete process.env.REX_BLOCK
175
194
  t.after(restoreEnv)
176
195
 
177
196
  const { pi, allCalls, emit } = createFake({ cwd })
@@ -197,7 +216,7 @@ test.describe("session chrome", { concurrency: 1 }, () => {
197
216
  assert.ok(firstHeader < firstReclaim, "header before reclaim")
198
217
  assert.ok(firstFooter < firstReclaim, "footer before reclaim")
199
218
  assert.ok(firstCwd < firstReclaim, "cwd before reclaim")
200
- assert.equal(allCalls.some((call) => call.method === "registerTool"), false, "built-in tools are not re-registered")
219
+ assert.deepEqual(allCalls.filter((call) => call.method === "registerTool").map((call) => (call.args[0] as { name: string }).name), ["list_open_prs"], "only pi-minimal's own tool is registered")
201
220
  })
202
221
 
203
222
  test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
@@ -205,6 +224,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
205
224
  writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
206
225
  process.env[AGENT_DIR_ENV] = agentDir
207
226
  delete process.env.HERDR_ENV
227
+ delete process.env.REX_SESSION
228
+ delete process.env.REX_BLOCK
208
229
  t.after(restoreEnv)
209
230
 
210
231
  const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
@@ -225,6 +246,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
225
246
  writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
226
247
  process.env[AGENT_DIR_ENV] = agentDir
227
248
  delete process.env.HERDR_ENV
249
+ delete process.env.REX_SESSION
250
+ delete process.env.REX_BLOCK
228
251
  t.after(restoreEnv)
229
252
 
230
253
  const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
@@ -243,6 +266,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
243
266
  const { agentDir, cwd } = setupDirs(t)
244
267
  process.env[AGENT_DIR_ENV] = agentDir
245
268
  delete process.env.HERDR_ENV
269
+ delete process.env.REX_SESSION
270
+ delete process.env.REX_BLOCK
246
271
  t.after(restoreEnv)
247
272
 
248
273
  const { pi, execCalls, emit } = createFake({ cwd })
@@ -261,6 +286,46 @@ test.describe("session chrome", { concurrency: 1 }, () => {
261
286
  const { agentDir, cwd } = setupDirs(t)
262
287
  process.env[AGENT_DIR_ENV] = agentDir
263
288
  process.env.HERDR_ENV = "1"
289
+ delete process.env.REX_SESSION
290
+ delete process.env.REX_BLOCK
291
+ t.after(restoreEnv)
292
+
293
+ const { pi, execCalls, emit } = createFake({ cwd })
294
+ install(pi)
295
+ t.after(() => emit("session_shutdown"))
296
+ await emit("session_start")
297
+
298
+ assert.equal(herdrProbes(execCalls).length, 1)
299
+ assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
300
+ })
301
+
302
+ test("inside Rex (REX_SESSION + REX_BLOCK) it reads the Rex session instead of herdr", async (t) => {
303
+ const { agentDir, cwd } = setupDirs(t)
304
+ process.env[AGENT_DIR_ENV] = agentDir
305
+ delete process.env.HERDR_ENV
306
+ process.env.REX_SESSION = "session:test"
307
+ process.env.REX_BLOCK = "block:self"
308
+ t.after(restoreEnv)
309
+
310
+ const { pi, execCalls, emit } = createFake({ cwd })
311
+ install(pi)
312
+ t.after(() => emit("session_shutdown"))
313
+ await emit("session_start")
314
+ await new Promise((resolve) => setTimeout(resolve, 20))
315
+
316
+ assert.equal(herdrProbes(execCalls).length, 0)
317
+ assert.deepEqual(
318
+ execCalls.filter((call) => call.args[0] === "rex").map((call) => call.args[1]),
319
+ [["window", "ls", "-s", "session:test", "--json"]],
320
+ )
321
+ })
322
+
323
+ test("Herdr wins when both Herdr and Rex are present", async (t) => {
324
+ const { agentDir, cwd } = setupDirs(t)
325
+ process.env[AGENT_DIR_ENV] = agentDir
326
+ process.env.HERDR_ENV = "1"
327
+ process.env.REX_SESSION = "session:test"
328
+ process.env.REX_BLOCK = "block:self"
264
329
  t.after(restoreEnv)
265
330
 
266
331
  const { pi, execCalls, emit } = createFake({ cwd })
@@ -269,12 +334,97 @@ test.describe("session chrome", { concurrency: 1 }, () => {
269
334
  await emit("session_start")
270
335
 
271
336
  assert.equal(herdrProbes(execCalls).length, 1)
337
+ assert.equal(execCalls.filter((call) => call.args[0] === "rex").length, 0)
338
+ })
339
+
340
+ test("registers /bg and the background shortcut once, honouring backgroundShortcut", async (t) => {
341
+ const { agentDir, cwd } = setupDirs(t)
342
+ writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ backgroundShortcut: "ctrl+g" }))
343
+ process.env[AGENT_DIR_ENV] = agentDir
344
+ delete process.env.HERDR_ENV
345
+ delete process.env.REX_SESSION
346
+ delete process.env.REX_BLOCK
347
+ t.after(restoreEnv)
348
+
349
+ const { pi, registerCalls, emit } = createFake({ cwd })
350
+ install(pi)
351
+ t.after(() => emit("session_shutdown"))
352
+ assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "bg"))
353
+ assert.equal(registerCalls.filter((call) => call.method === "registerShortcut").length, 0, "shortcut waits for session config")
354
+ await emit("session_start")
355
+ await emit("session_start")
356
+ const shortcuts = registerCalls.filter((call) => call.method === "registerShortcut")
357
+ assert.deepEqual(shortcuts.map((call) => call.args[0]), ["ctrl+g"])
358
+ })
359
+
360
+ test("a TUI session asks gh for the repo once and registers /prs", async (t) => {
361
+ const { agentDir, cwd } = setupDirs(t)
362
+ process.env[AGENT_DIR_ENV] = agentDir
363
+ delete process.env.HERDR_ENV
364
+ delete process.env.REX_SESSION
365
+ delete process.env.REX_BLOCK
366
+ t.after(restoreEnv)
367
+
368
+ const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
369
+ install(pi)
370
+ t.after(() => emit("session_shutdown"))
371
+ assert.ok(registerCalls.some((call) => call.method === "registerCommand" && call.args[0] === "prs"))
372
+ await emit("session_start")
373
+ await new Promise((resolve) => setTimeout(resolve, 20))
374
+ assert.deepEqual(execCalls.filter((call) => call.args[0] === "gh").map((call) => call.args[1]), [["repo", "view", "--json", "nameWithOwner,url"]])
375
+ })
376
+
377
+ test("a bash call running gh pr create records the PR URL as a session entry", async (t) => {
378
+ const { agentDir, cwd } = setupDirs(t)
379
+ process.env[AGENT_DIR_ENV] = agentDir
380
+ delete process.env.HERDR_ENV
381
+ delete process.env.REX_SESSION
382
+ delete process.env.REX_BLOCK
383
+ t.after(restoreEnv)
384
+
385
+ const { pi, execCalls, registerCalls, emit } = createFake({ cwd })
386
+ install(pi)
387
+ t.after(() => emit("session_shutdown"))
388
+ await emit("session_start")
389
+ await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash", args: { command: "gh pr create --fill" } })
390
+ await emit("tool_execution_end", {
391
+ toolCallId: "b1", toolName: "bash", isError: false,
392
+ result: { content: [{ type: "text", text: "Creating pull request…\nhttps://github.com/raviy06/pi-minimal/pull/24\n" }] },
393
+ })
394
+ await new Promise((resolve) => setTimeout(resolve, 20))
395
+ const entries = registerCalls.filter((call) => call.method === "appendEntry")
396
+ assert.deepEqual(entries.map((call) => call.args), [["pi-minimal.pr", { url: "https://github.com/raviy06/pi-minimal/pull/24" }]])
397
+ assert.ok(execCalls.some((call) => call.args[0] === "gh" && (call.args[1] as string[])[1] === "view"), "the PR is fetched by URL")
398
+ })
399
+
400
+ test("bash tool calls are inspected with ps; other tools are not", async (t) => {
401
+ const { agentDir, cwd } = setupDirs(t)
402
+ process.env[AGENT_DIR_ENV] = agentDir
403
+ delete process.env.HERDR_ENV
404
+ delete process.env.REX_SESSION
405
+ delete process.env.REX_BLOCK
406
+ t.after(restoreEnv)
407
+
408
+ const { pi, execCalls, emit } = createFake({ cwd })
409
+ install(pi)
410
+ t.after(() => emit("session_shutdown"))
411
+ await emit("session_start")
412
+ await emit("tool_execution_start", { toolCallId: "r1", toolName: "read" })
413
+ await emit("tool_execution_end", { toolCallId: "r1", toolName: "read" })
414
+ await new Promise((resolve) => setTimeout(resolve, 10))
415
+ assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 0)
416
+ await emit("tool_execution_start", { toolCallId: "b1", toolName: "bash" })
417
+ await emit("tool_execution_end", { toolCallId: "b1", toolName: "bash" })
418
+ await new Promise((resolve) => setTimeout(resolve, 10))
419
+ assert.equal(execCalls.filter((call) => call.args[0] === "ps").length, 2)
272
420
  })
273
421
 
274
422
  test("non-TUI modes install no TUI components", async (t) => {
275
423
  const { agentDir, cwd } = setupDirs(t)
276
424
  process.env[AGENT_DIR_ENV] = agentDir
277
425
  delete process.env.HERDR_ENV
426
+ delete process.env.REX_SESSION
427
+ delete process.env.REX_BLOCK
278
428
  t.after(restoreEnv)
279
429
 
280
430
  const { pi, uiCalls, execCalls, emit } = createFake({ cwd, mode: "print" })
@@ -307,6 +457,8 @@ test("footer registration survives chrome reinstalls and becomes inert on shutdo
307
457
  const { agentDir, cwd } = setupDirs(t)
308
458
  process.env[AGENT_DIR_ENV] = agentDir
309
459
  delete process.env.HERDR_ENV
460
+ delete process.env.REX_SESSION
461
+ delete process.env.REX_BLOCK
310
462
  t.after(restoreEnv)
311
463
  const { pi, emit, events, uiCalls } = createFake({ cwd })
312
464
  install(pi)
@@ -332,6 +484,8 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
332
484
  const { agentDir, cwd } = setupDirs(t)
333
485
  process.env[AGENT_DIR_ENV] = agentDir
334
486
  delete process.env.HERDR_ENV
487
+ delete process.env.REX_SESSION
488
+ delete process.env.REX_BLOCK
335
489
  t.after(restoreEnv)
336
490
  const { pi, registerCalls, emit } = createFake({ cwd })
337
491
  install(pi)
@@ -354,6 +508,8 @@ test("a second session_start disposes the first session's chrome and the stale o
354
508
  const { agentDir, cwd } = setupDirs(t)
355
509
  process.env[AGENT_DIR_ENV] = agentDir
356
510
  delete process.env.HERDR_ENV
511
+ delete process.env.REX_SESSION
512
+ delete process.env.REX_BLOCK
357
513
  t.after(restoreEnv)
358
514
  const { pi, emit, events, uiCalls } = createFake({ cwd })
359
515
  install(pi)