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.
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +29 -0
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/activity.js +12 -0
- package/dist/chrome.js +1 -0
- package/dist/config.js +45 -0
- package/dist/cwd.js +103 -0
- package/dist/footer.js +46 -0
- package/dist/header.js +14 -0
- package/dist/herdr.js +44 -0
- package/dist/index.js +155 -0
- package/dist/live-watch.js +52 -0
- package/dist/reclaim.js +19 -0
- package/dist/tool-rows.js +34 -0
- package/dist/tools.js +103 -0
- package/dist/tracker.js +63 -0
- package/dist/working-status.js +191 -0
- package/doc/plans/2026-09-22-pi-minimal-package.md +354 -0
- package/doc/specs/2026-09-22-pi-minimal-package.md +94 -0
- package/eslint.config.js +8 -0
- package/package.json +37 -0
- package/src/activity.ts +16 -0
- package/src/chrome.ts +1 -0
- package/src/config.ts +59 -0
- package/src/cwd.ts +108 -0
- package/src/footer.ts +54 -0
- package/src/header.ts +16 -0
- package/src/herdr.ts +66 -0
- package/src/index.ts +152 -0
- package/src/live-watch.ts +59 -0
- package/src/reclaim.ts +34 -0
- package/src/tool-rows.ts +58 -0
- package/src/tools.ts +161 -0
- package/src/tracker.ts +91 -0
- package/src/working-status.ts +214 -0
- package/test/activity.test.ts +27 -0
- package/test/config.test.ts +114 -0
- package/test/index.test.ts +262 -0
- package/test/package.test.ts +18 -0
- package/test/tools.test.ts +48 -0
- package/test/tracker.test.ts +104 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tests.json +8 -0
|
@@ -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
|
+
})
|