pi-minimal 0.1.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,214 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
2
+
3
+ export type WorkingPhase = "idle" | "thinking" | "tool" | "writing"
4
+
5
+ export type WorkingSnapshot = {
6
+ phase: WorkingPhase
7
+ startedAt: number
8
+ now: number
9
+ thought?: string
10
+ tool?: string
11
+ toolCount?: number
12
+ }
13
+
14
+ type ToolArgs = {
15
+ command?: unknown
16
+ path?: unknown
17
+ file_path?: unknown
18
+ target_directory?: unknown
19
+ pattern?: unknown
20
+ }
21
+
22
+ type ContentPart = {
23
+ type?: string
24
+ thinking?: string
25
+ text?: string
26
+ }
27
+
28
+ const DETAIL_MAX = 120
29
+
30
+ export function formatElapsed(ms: number): string {
31
+ const total = Math.max(0, Math.floor(ms / 1000))
32
+ const hours = Math.floor(total / 3600)
33
+ const minutes = Math.floor((total % 3600) / 60)
34
+ const seconds = total % 60
35
+ if (hours > 0) return `${hours}h ${minutes}m`
36
+ if (minutes > 0) return `${minutes}m ${seconds}s`
37
+ return `${seconds}s`
38
+ }
39
+
40
+ export function clipDetail(text: string, max = DETAIL_MAX): string {
41
+ const one = text.replace(/\s+/g, " ").trim()
42
+ if (one.length <= max) return one
43
+ if (max <= 1) return "…"
44
+ return `${one.slice(0, max - 1).trimEnd()}…`
45
+ }
46
+
47
+ export function lastThoughtLine(message: { content?: ContentPart[] } | undefined): string | undefined {
48
+ if (!Array.isArray(message?.content)) return undefined
49
+ let thinking = ""
50
+ for (const part of message.content) {
51
+ if (part?.type === "thinking" && typeof part.thinking === "string") {
52
+ thinking = part.thinking
53
+ }
54
+ }
55
+ const lines = thinking
56
+ .split("\n")
57
+ .map((line) => line.replace(/^[#>*\-\d.\s]+/, "").trim())
58
+ .filter((line) => line.length > 2)
59
+ if (lines.length === 0) return undefined
60
+ return clipDetail(lines.slice(-2).join(" · "))
61
+ }
62
+
63
+ export function summarizeTool(name: string, args: ToolArgs | undefined): string {
64
+ const path =
65
+ (typeof args?.path === "string" && args.path) ||
66
+ (typeof args?.file_path === "string" && args.file_path) ||
67
+ (typeof args?.target_directory === "string" && args.target_directory) ||
68
+ ""
69
+ const pattern = typeof args?.pattern === "string" ? args.pattern : ""
70
+ const command = typeof args?.command === "string" ? args.command.split("\n")[0]?.trim() ?? "" : ""
71
+ const base = path.split("/").filter(Boolean).slice(-2).join("/") || path
72
+
73
+ switch (name) {
74
+ case "bash":
75
+ return command ? `Running ${clipDetail(command, 56)}` : "Running"
76
+ case "read":
77
+ return base ? `Reading ${base}` : "Reading"
78
+ case "edit":
79
+ return base ? `Editing ${base}` : "Editing"
80
+ case "write":
81
+ return base ? `Writing ${base}` : "Writing"
82
+ case "grep":
83
+ return pattern ? `Grep ${clipDetail(pattern, 40)}` : "Grep"
84
+ case "find":
85
+ return pattern ? `Finding ${clipDetail(pattern, 40)}` : "Finding"
86
+ case "ls":
87
+ return base ? `Listing ${base}` : "Listing"
88
+ default:
89
+ return name || "Working"
90
+ }
91
+ }
92
+
93
+ export function formatWorkingMessage(snap: WorkingSnapshot): string {
94
+ const elapsed = formatElapsed(snap.now - snap.startedAt)
95
+ if (snap.phase === "tool" && snap.tool) {
96
+ const extra = snap.toolCount && snap.toolCount > 1 ? ` · ${snap.toolCount} tools` : ""
97
+ return `${snap.tool}${extra} · ${elapsed}`
98
+ }
99
+ if (snap.phase === "writing") return `Writing · ${elapsed}`
100
+ if (snap.thought) return `Thinking · ${snap.thought} · ${elapsed}`
101
+ return `Thinking · ${elapsed}`
102
+ }
103
+
104
+ export function installWorkingStatus(pi: ExtensionAPI): () => void {
105
+ let ctx: ExtensionContext | undefined
106
+ let startedAt = 0
107
+ let phase: WorkingPhase = "idle"
108
+ let thought: string | undefined
109
+ let lastMessage = ""
110
+ let timer: ReturnType<typeof setInterval> | undefined
111
+ const tools = new Map<string, string>()
112
+
113
+ const stop = () => {
114
+ if (timer) clearInterval(timer)
115
+ timer = undefined
116
+ startedAt = 0
117
+ phase = "idle"
118
+ thought = undefined
119
+ lastMessage = ""
120
+ tools.clear()
121
+ if (ctx?.mode === "tui") ctx.ui.setWorkingMessage()
122
+ }
123
+
124
+ const paint = (now = Date.now()) => {
125
+ if (!ctx || ctx.mode !== "tui" || phase === "idle") return
126
+ const next = formatWorkingMessage({
127
+ phase,
128
+ startedAt,
129
+ now,
130
+ thought,
131
+ tool: [...tools.values()].at(-1),
132
+ toolCount: tools.size,
133
+ })
134
+ if (next === lastMessage) return
135
+ lastMessage = next
136
+ ctx.ui.setWorkingMessage(next)
137
+ }
138
+
139
+ const startClock = () => {
140
+ if (!startedAt) startedAt = Date.now()
141
+ if (!timer) timer = setInterval(() => paint(), 1000)
142
+ }
143
+
144
+ pi.on("session_start", (_event, next) => {
145
+ if (next.mode !== "tui") return
146
+ ctx = next
147
+ })
148
+
149
+ pi.on("agent_start", () => {
150
+ if (!ctx || ctx.mode !== "tui") return
151
+ if (phase === "idle") {
152
+ tools.clear()
153
+ thought = undefined
154
+ lastMessage = ""
155
+ startedAt = Date.now()
156
+ }
157
+ if (tools.size === 0) phase = "thinking"
158
+ startClock()
159
+ paint()
160
+ })
161
+
162
+ pi.on("turn_start", () => {
163
+ if (phase === "idle") return
164
+ if (tools.size === 0) phase = "thinking"
165
+ startClock()
166
+ paint()
167
+ })
168
+
169
+ pi.on("message_update", (event) => {
170
+ if (phase === "idle") return
171
+ const ev = event as {
172
+ message?: { role?: string; content?: ContentPart[] }
173
+ assistantMessageEvent?: { type?: string }
174
+ }
175
+ if (ev.message?.role && ev.message.role !== "assistant") return
176
+ const kind = ev.assistantMessageEvent?.type
177
+ const nextThought = lastThoughtLine(ev.message)
178
+ if (nextThought) thought = nextThought
179
+ if (kind === "text_start" || kind === "text_delta" || kind === "text_end") {
180
+ if (tools.size === 0) phase = "writing"
181
+ } else if (tools.size === 0) {
182
+ phase = "thinking"
183
+ }
184
+ paint()
185
+ })
186
+
187
+ pi.on("tool_execution_start", (event) => {
188
+ if (phase === "idle") return
189
+ const ev = event as { toolCallId?: string; toolName?: string; args?: ToolArgs }
190
+ const id = ev.toolCallId || `${ev.toolName}-${tools.size}`
191
+ tools.set(id, summarizeTool(ev.toolName || "tool", ev.args))
192
+ phase = "tool"
193
+ paint()
194
+ })
195
+
196
+ pi.on("tool_execution_end", (event) => {
197
+ if (phase === "idle") return
198
+ const ev = event as { toolCallId?: string }
199
+ if (ev.toolCallId) tools.delete(ev.toolCallId)
200
+ phase = tools.size > 0 ? "tool" : "thinking"
201
+ paint()
202
+ })
203
+
204
+ pi.on("agent_settled", () => {
205
+ stop()
206
+ })
207
+
208
+ pi.on("session_shutdown", () => {
209
+ ctx = undefined
210
+ stop()
211
+ })
212
+
213
+ return stop
214
+ }
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { activityBullet, rowIsRunning } from "../src/activity.js"
4
+
5
+ test("rowIsRunning is false when isPartial is false", () => {
6
+ assert.equal(rowIsRunning({ isPartial: false }), false)
7
+ })
8
+
9
+ test("rowIsRunning is true when isPartial is true", () => {
10
+ assert.equal(rowIsRunning({ isPartial: true }), true)
11
+ })
12
+
13
+ test("activityBullet(false, 0) returns ◆", () => {
14
+ assert.equal(activityBullet(false, 0), "◆")
15
+ })
16
+
17
+ test("activityBullet(false, 1) returns ◆", () => {
18
+ assert.equal(activityBullet(false, 1), "◆")
19
+ })
20
+
21
+ test("activityBullet(true, 0) returns ◆", () => {
22
+ assert.equal(activityBullet(true, 0), "◆")
23
+ })
24
+
25
+ test("activityBullet(true, 1) returns ◇", () => {
26
+ assert.equal(activityBullet(true, 1), "◇")
27
+ })
@@ -0,0 +1,114 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import test, { type TestContext } from "node:test"
6
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent"
7
+ import { loadPiMinimalConfig } from "../src/config.js"
8
+
9
+ function setup(t: TestContext) {
10
+ const root = mkdtempSync(join(tmpdir(), "pi-minimal-config-"))
11
+ t.after(() => rmSync(root, { recursive: true, force: true }))
12
+ const agentDir = join(root, "agent")
13
+ const cwd = join(root, "project")
14
+ mkdirSync(agentDir)
15
+ mkdirSync(cwd)
16
+ return { agentDir, cwd }
17
+ }
18
+
19
+ function writeGlobal(agentDir: string, contents: string) {
20
+ writeFileSync(join(agentDir, "pi-minimal.json"), contents)
21
+ }
22
+
23
+ function writeProject(cwd: string, contents: string) {
24
+ const dir = join(cwd, CONFIG_DIR_NAME)
25
+ mkdirSync(dir, { recursive: true })
26
+ writeFileSync(join(dir, "pi-minimal.json"), contents)
27
+ }
28
+
29
+ test("defaults hideGauntletWidgets to true", (t) => {
30
+ const { agentDir, cwd } = setup(t)
31
+ const error = t.mock.method(console, "error")
32
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
33
+ assert.deepEqual(config, { hideGauntletWidgets: true })
34
+ assert.equal(error.mock.callCount(), 0)
35
+ })
36
+
37
+ test("global false overrides default", (t) => {
38
+ const { agentDir, cwd } = setup(t)
39
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
40
+ const error = t.mock.method(console, "error")
41
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
42
+ assert.deepEqual(config, { hideGauntletWidgets: false })
43
+ assert.equal(error.mock.callCount(), 0)
44
+ })
45
+
46
+ test("trusted project true overrides global false", (t) => {
47
+ const { agentDir, cwd } = setup(t)
48
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
49
+ writeProject(cwd, JSON.stringify({ hideGauntletWidgets: true }))
50
+ const error = t.mock.method(console, "error")
51
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
52
+ assert.deepEqual(config, { hideGauntletWidgets: true })
53
+ assert.equal(error.mock.callCount(), 0)
54
+ })
55
+
56
+ test("untrusted project false does not override global true", (t) => {
57
+ const { agentDir, cwd } = setup(t)
58
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: true }))
59
+ writeProject(cwd, JSON.stringify({ hideGauntletWidgets: false }))
60
+ const error = t.mock.method(console, "error")
61
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: false })
62
+ assert.deepEqual(config, { hideGauntletWidgets: true })
63
+ assert.equal(error.mock.callCount(), 0)
64
+ })
65
+
66
+ test("omitted keys do not override", (t) => {
67
+ const { agentDir, cwd } = setup(t)
68
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
69
+ writeProject(cwd, JSON.stringify({}))
70
+ const error = t.mock.method(console, "error")
71
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
72
+ assert.deepEqual(config, { hideGauntletWidgets: false })
73
+ assert.equal(error.mock.callCount(), 0)
74
+ })
75
+
76
+ test("unknown keys do not override", (t) => {
77
+ const { agentDir, cwd } = setup(t)
78
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
79
+ writeProject(cwd, JSON.stringify({ extra: true }))
80
+ const error = t.mock.method(console, "error")
81
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
82
+ assert.deepEqual(config, { hideGauntletWidgets: false })
83
+ assert.equal(error.mock.callCount(), 0)
84
+ })
85
+
86
+ test("invalid JSON calls console.error and leaves the lower-precedence value unchanged", (t) => {
87
+ const { agentDir, cwd } = setup(t)
88
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
89
+ writeProject(cwd, "{")
90
+ const error = t.mock.method(console, "error")
91
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
92
+ assert.deepEqual(config, { hideGauntletWidgets: false })
93
+ assert.ok(error.mock.callCount() >= 1)
94
+ })
95
+
96
+ test("non-object root calls console.error and leaves the lower-precedence value unchanged", (t) => {
97
+ const { agentDir, cwd } = setup(t)
98
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
99
+ writeProject(cwd, JSON.stringify(["hideGauntletWidgets", false]))
100
+ const error = t.mock.method(console, "error")
101
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
102
+ assert.deepEqual(config, { hideGauntletWidgets: false })
103
+ assert.ok(error.mock.callCount() >= 1)
104
+ })
105
+
106
+ test("non-boolean hideGauntletWidgets calls console.error and leaves the lower-precedence value unchanged", (t) => {
107
+ const { agentDir, cwd } = setup(t)
108
+ writeGlobal(agentDir, JSON.stringify({ hideGauntletWidgets: false }))
109
+ writeProject(cwd, JSON.stringify({ hideGauntletWidgets: "yes" }))
110
+ const error = t.mock.method(console, "error")
111
+ const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
112
+ assert.deepEqual(config, { hideGauntletWidgets: false })
113
+ assert.ok(error.mock.callCount() >= 1)
114
+ })
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import test, { type TestContext } from "node:test"
6
+ import {
7
+ SettingsManager,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent"
11
+ import install from "../src/index.js"
12
+
13
+ type Call = { method: string; args: unknown[] }
14
+ type SessionMode = "tui" | "rpc" | "json" | "print"
15
+
16
+ const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
17
+ const originalHerdrEnv = process.env.HERDR_ENV
18
+ const originalAgentDir = process.env[AGENT_DIR_ENV]
19
+
20
+ function restoreEnv() {
21
+ if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV
22
+ else process.env.HERDR_ENV = originalHerdrEnv
23
+ if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
24
+ else process.env[AGENT_DIR_ENV] = originalAgentDir
25
+ }
26
+
27
+ function setupDirs(t: TestContext) {
28
+ const root = mkdtempSync(join(tmpdir(), "pi-minimal-session-"))
29
+ t.after(() => rmSync(root, { recursive: true, force: true }))
30
+ const agentDir = join(root, "agent")
31
+ const cwd = join(root, "project")
32
+ mkdirSync(agentDir)
33
+ mkdirSync(cwd)
34
+ return { agentDir, cwd }
35
+ }
36
+
37
+ function createFake(options: {
38
+ mode?: SessionMode
39
+ cwd: string
40
+ projectTrusted?: boolean
41
+ }) {
42
+ const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => unknown>>()
43
+ const uiCalls: Call[] = []
44
+ const execCalls: Call[] = []
45
+ const registerCalls: Call[] = []
46
+ const allCalls: Call[] = []
47
+
48
+ const record = (target: Call[], method: string, args: unknown[]) => {
49
+ const call = { method, args }
50
+ target.push(call)
51
+ allCalls.push(call)
52
+ }
53
+
54
+ const ui = {
55
+ setHeader: (...args: unknown[]) => record(uiCalls, "setHeader", args),
56
+ setFooter: (...args: unknown[]) => record(uiCalls, "setFooter", args),
57
+ setWidget: (...args: unknown[]) => record(uiCalls, "setWidget", args),
58
+ setHiddenThinkingLabel: (...args: unknown[]) => record(uiCalls, "setHiddenThinkingLabel", args),
59
+ setWorkingIndicator: (...args: unknown[]) => record(uiCalls, "setWorkingIndicator", args),
60
+ setWorkingMessage: (...args: unknown[]) => record(uiCalls, "setWorkingMessage", args),
61
+ setEditorComponent: (...args: unknown[]) => record(uiCalls, "setEditorComponent", args),
62
+ setHideThinkingBlock: (...args: unknown[]) => record(uiCalls, "setHideThinkingBlock", args),
63
+ }
64
+
65
+ const ctx = {
66
+ mode: options.mode ?? "tui",
67
+ cwd: options.cwd,
68
+ isProjectTrusted: () => options.projectTrusted ?? false,
69
+ sessionManager: { getBranch: () => [] },
70
+ ui,
71
+ } as unknown as ExtensionContext
72
+
73
+ const pi = {
74
+ on(event: string, handler: (event: unknown, ctx: ExtensionContext) => unknown) {
75
+ const list = handlers.get(event) ?? []
76
+ list.push(handler)
77
+ handlers.set(event, list)
78
+ return () => {}
79
+ },
80
+ async exec(command: string, args: string[]) {
81
+ record(execCalls, "exec", [command, args])
82
+ if (command === "herdr" && args[0] === "agent") {
83
+ return { stdout: JSON.stringify({ result: { agents: [] } }), stderr: "", code: 0, killed: false }
84
+ }
85
+ if (command === "herdr" && args[0] === "tab") {
86
+ return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: "", code: 0, killed: false }
87
+ }
88
+ return { stdout: "", stderr: "", code: 1, killed: false }
89
+ },
90
+ registerTool(...args: unknown[]) {
91
+ record(registerCalls, "registerTool", args)
92
+ },
93
+ } as unknown as ExtensionAPI
94
+
95
+ const emit = async (event: string, payload: unknown = {}) => {
96
+ for (const handler of handlers.get(event) ?? []) {
97
+ await handler(payload, ctx)
98
+ }
99
+ }
100
+
101
+ return { pi, ctx, uiCalls, execCalls, registerCalls, allCalls, emit }
102
+ }
103
+
104
+ function widgetCalls(uiCalls: Call[], key: string): Call[] {
105
+ return uiCalls.filter((call) => call.method === "setWidget" && call.args[0] === key)
106
+ }
107
+
108
+ function herdrProbes(execCalls: Call[]): Call[] {
109
+ return execCalls.filter(
110
+ (call) => call.method === "exec" && call.args[0] === "herdr" && Array.isArray(call.args[1]) && call.args[1][0] === "agent",
111
+ )
112
+ }
113
+
114
+ test.describe("session chrome", { concurrency: 1 }, () => {
115
+ test("TUI session_start installs chrome and hides Gauntlet widgets by default", async (t) => {
116
+ const { agentDir, cwd } = setupDirs(t)
117
+ process.env[AGENT_DIR_ENV] = agentDir
118
+ delete process.env.HERDR_ENV
119
+ t.after(restoreEnv)
120
+
121
+ const hideThinking = t.mock.method(SettingsManager.prototype, "setHideThinkingBlock")
122
+ const { pi, uiCalls, execCalls, allCalls, emit } = createFake({ cwd })
123
+ install(pi)
124
+ t.after(() => emit("session_shutdown"))
125
+ await emit("session_start")
126
+
127
+ assert.ok(
128
+ uiCalls.some((call) => call.method === "setHiddenThinkingLabel" && call.args.length === 1 && call.args[0] === "◆ Thought"),
129
+ )
130
+ assert.ok(uiCalls.some((call) => call.method === "setWorkingIndicator" && call.args.length === 0))
131
+ assert.ok(uiCalls.some((call) => call.method === "setHeader" && typeof call.args[0] === "function"))
132
+ assert.ok(uiCalls.some((call) => call.method === "setFooter" && typeof call.args[0] === "function"))
133
+ assert.ok(
134
+ widgetCalls(uiCalls, "tw-cwd").some(
135
+ (call) => typeof call.args[1] === "function" || (call.args[1] !== undefined && call.args[1] !== null),
136
+ ),
137
+ )
138
+ assert.ok(widgetCalls(uiCalls, "phase_tracker").some((call) => call.args[1] === undefined))
139
+ assert.ok(widgetCalls(uiCalls, "plan_tracker").some((call) => call.args[1] === undefined))
140
+ assert.equal(
141
+ allCalls.some((call) => call.method === "setHideThinkingBlock"),
142
+ false,
143
+ )
144
+ assert.equal(hideThinking.mock.callCount(), 0)
145
+ assert.equal(
146
+ execCalls.filter((call) => call.args[0] === "herdr").length,
147
+ 0,
148
+ )
149
+ })
150
+
151
+ test("TUI session_start installs chrome and wraps tools before the first reclaim", async (t) => {
152
+ const { agentDir, cwd } = setupDirs(t)
153
+ process.env[AGENT_DIR_ENV] = agentDir
154
+ delete process.env.HERDR_ENV
155
+ t.after(restoreEnv)
156
+
157
+ const { pi, allCalls, emit } = createFake({ cwd })
158
+ install(pi)
159
+ t.after(() => emit("session_shutdown"))
160
+ await emit("session_start")
161
+
162
+ const firstHeader = allCalls.findIndex((call) => call.method === "setHeader")
163
+ const firstFooter = allCalls.findIndex((call) => call.method === "setFooter")
164
+ const firstCwd = allCalls.findIndex(
165
+ (call) => call.method === "setWidget" && call.args[0] === "tw-cwd" && typeof call.args[1] === "function",
166
+ )
167
+ const firstWrap = allCalls.findIndex((call) => call.method === "registerTool")
168
+ const firstReclaim = allCalls.findIndex((call) => call.method === "setEditorComponent")
169
+
170
+ assert.ok(firstHeader >= 0, "header installed")
171
+ assert.ok(firstFooter >= 0, "footer installed")
172
+ assert.ok(firstCwd >= 0, "cwd installed")
173
+ assert.ok(firstWrap >= 0, "tools wrapped")
174
+ assert.ok(firstReclaim >= 0, "reclaim ran")
175
+ assert.ok(firstHeader < firstReclaim, "header before reclaim")
176
+ assert.ok(firstFooter < firstReclaim, "footer before reclaim")
177
+ assert.ok(firstCwd < firstReclaim, "cwd before reclaim")
178
+ assert.ok(firstWrap < firstReclaim, "wrap before reclaim")
179
+ })
180
+
181
+ test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
182
+ const { agentDir, cwd } = setupDirs(t)
183
+ writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
184
+ process.env[AGENT_DIR_ENV] = agentDir
185
+ delete process.env.HERDR_ENV
186
+ t.after(restoreEnv)
187
+
188
+ const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
189
+ install(pi)
190
+ t.after(() => emit("session_shutdown"))
191
+ await emit("session_start")
192
+
193
+ assert.equal(widgetCalls(uiCalls, "phase_tracker").length, 0)
194
+ assert.equal(widgetCalls(uiCalls, "plan_tracker").length, 0)
195
+ assert.ok(uiCalls.some((call) => call.method === "setHeader" && typeof call.args[0] === "function"))
196
+ assert.ok(uiCalls.some((call) => call.method === "setFooter" && typeof call.args[0] === "function"))
197
+ assert.ok(widgetCalls(uiCalls, "tw-cwd").length > 0)
198
+ })
199
+
200
+ test("does not execute herdr when HERDR_ENV is unset", async (t) => {
201
+ const { agentDir, cwd } = setupDirs(t)
202
+ process.env[AGENT_DIR_ENV] = agentDir
203
+ delete process.env.HERDR_ENV
204
+ t.after(restoreEnv)
205
+
206
+ const { pi, execCalls, emit } = createFake({ cwd })
207
+ install(pi)
208
+ t.after(() => emit("session_shutdown"))
209
+ await emit("session_start")
210
+
211
+ assert.equal(
212
+ execCalls.filter((call) => call.args[0] === "herdr").length,
213
+ 0,
214
+ )
215
+ assert.equal(herdrProbes(execCalls).length, 0)
216
+ })
217
+
218
+ test("probes herdr once when HERDR_ENV is 1", async (t) => {
219
+ const { agentDir, cwd } = setupDirs(t)
220
+ process.env[AGENT_DIR_ENV] = agentDir
221
+ process.env.HERDR_ENV = "1"
222
+ t.after(restoreEnv)
223
+
224
+ const { pi, execCalls, emit } = createFake({ cwd })
225
+ install(pi)
226
+ t.after(() => emit("session_shutdown"))
227
+ await emit("session_start")
228
+
229
+ assert.equal(herdrProbes(execCalls).length, 1)
230
+ })
231
+
232
+ test("non-TUI modes install no TUI components", async (t) => {
233
+ const { agentDir, cwd } = setupDirs(t)
234
+ process.env[AGENT_DIR_ENV] = agentDir
235
+ delete process.env.HERDR_ENV
236
+ t.after(restoreEnv)
237
+
238
+ const { pi, uiCalls, execCalls, emit } = createFake({ cwd, mode: "print" })
239
+ install(pi)
240
+ t.after(() => emit("session_shutdown"))
241
+ await emit("session_start")
242
+ await emit("agent_start")
243
+ await emit("agent_settled")
244
+
245
+ assert.equal(
246
+ uiCalls.filter((call) => ["setHeader", "setFooter", "setWidget"].includes(call.method)).length,
247
+ 0,
248
+ )
249
+ assert.equal(
250
+ uiCalls.filter((call) => call.method === "setHiddenThinkingLabel" || call.method === "setWorkingIndicator").length,
251
+ 0,
252
+ )
253
+ assert.equal(
254
+ uiCalls.filter((call) => call.method === "setWorkingMessage").length,
255
+ 0,
256
+ )
257
+ assert.equal(
258
+ execCalls.filter((call) => call.args[0] === "herdr").length,
259
+ 0,
260
+ )
261
+ })
262
+ })
@@ -0,0 +1,18 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+ import { readFileSync } from "node:fs"
4
+
5
+ test("package manifest exposes the committed extension", () => {
6
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"))
7
+ assert.equal(pkg.name, "pi-minimal")
8
+ assert.equal(pkg.version, "0.1.0")
9
+ assert.equal(pkg.license, "MIT")
10
+ assert.equal(pkg.type, "module")
11
+ assert.deepEqual(pkg.engines, { node: ">=22.19.0" })
12
+ assert.deepEqual(pkg.keywords, ["pi-package"])
13
+ assert.deepEqual(pkg.pi, { extensions: ["./dist/index.js"] })
14
+ assert.deepEqual(pkg.peerDependencies, {
15
+ "@earendil-works/pi-coding-agent": "*",
16
+ "@earendil-works/pi-tui": "*",
17
+ })
18
+ })
@@ -0,0 +1,48 @@
1
+ import assert from "node:assert/strict"
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import test, { type TestContext } from "node:test"
6
+ import {
7
+ SettingsManager,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent"
11
+ import { wrapBuiltinTools } from "../src/tools.js"
12
+
13
+ const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"
14
+ const originalAgentDir = process.env[AGENT_DIR_ENV]
15
+
16
+ function restoreEnv() {
17
+ if (originalAgentDir === undefined) delete process.env[AGENT_DIR_ENV]
18
+ else process.env[AGENT_DIR_ENV] = originalAgentDir
19
+ }
20
+
21
+ function setupDirs(t: TestContext) {
22
+ const root = mkdtempSync(join(tmpdir(), "pi-minimal-tools-"))
23
+ t.after(() => rmSync(root, { recursive: true, force: true }))
24
+ const agentDir = join(root, "agent")
25
+ const cwd = join(root, "project")
26
+ mkdirSync(agentDir)
27
+ mkdirSync(cwd)
28
+ return { agentDir, cwd }
29
+ }
30
+
31
+ test("untrusted project does not receive projectTrusted true", (t) => {
32
+ const { agentDir, cwd } = setupDirs(t)
33
+ process.env[AGENT_DIR_ENV] = agentDir
34
+ t.after(restoreEnv)
35
+
36
+ const create = t.mock.method(SettingsManager, "create")
37
+ const pi = { registerTool() {} } as unknown as ExtensionAPI
38
+ const ctx = {
39
+ cwd,
40
+ isProjectTrusted: () => false,
41
+ } as unknown as ExtensionContext
42
+
43
+ wrapBuiltinTools(pi, ctx)
44
+
45
+ assert.equal(create.mock.callCount(), 1)
46
+ const options = create.mock.calls[0].arguments[2] as { projectTrusted?: boolean } | undefined
47
+ assert.equal(options?.projectTrusted, false)
48
+ })