pi-code 1.0.4 → 1.0.6
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/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* analogue, off the shared bus), and on the optional `refreshInterval` timer
|
|
10
10
|
* (minimum 1s). A project-defined command is arbitrary shell, so project settings
|
|
11
11
|
* count only once the project is already approved, read without prompting.
|
|
12
|
+
* Claude's `disableAllHooks` setting turns the configured command off too, and
|
|
13
|
+
* the built-in segment stands in.
|
|
12
14
|
*
|
|
13
15
|
* Without a configured statusLine, the built-in segment shows turn state plus
|
|
14
16
|
* running session cost, summed from per-message usage on the current branch so it
|
|
@@ -21,9 +23,10 @@
|
|
|
21
23
|
|
|
22
24
|
import * as fs from 'node:fs'
|
|
23
25
|
import * as os from 'node:os'
|
|
26
|
+
import * as path from 'node:path'
|
|
24
27
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
25
28
|
|
|
26
|
-
import { hookFiles, runHookCommand } from './hooks.js'
|
|
29
|
+
import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
|
|
27
30
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
28
31
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
29
32
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
@@ -31,6 +34,15 @@ import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
|
31
34
|
const COMMAND_TIMEOUT_MS = 5_000
|
|
32
35
|
const DEBOUNCE_MS = 300
|
|
33
36
|
|
|
37
|
+
/** Claude sends its CLI version; pi-code's own version is the honest analogue. */
|
|
38
|
+
const PACKAGE_VERSION = (() => {
|
|
39
|
+
try {
|
|
40
|
+
return String(JSON.parse(fs.readFileSync(path.join(import.meta.dirname, '..', 'package.json'), 'utf-8')).version ?? '')
|
|
41
|
+
} catch {
|
|
42
|
+
return ''
|
|
43
|
+
}
|
|
44
|
+
})()
|
|
45
|
+
|
|
34
46
|
interface UsageEntry {
|
|
35
47
|
type: string
|
|
36
48
|
message?: { usage?: { cost?: { total?: number } } }
|
|
@@ -84,6 +96,17 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
84
96
|
let commandLine: string | undefined
|
|
85
97
|
let permissionMode = 'default'
|
|
86
98
|
let projectApproved = false
|
|
99
|
+
let sessionStartMs = Date.now()
|
|
100
|
+
// Lines changed, counted from successful edit/write inputs: newText and content
|
|
101
|
+
// lines add, oldText lines remove. An approximation of Claude's counters, which
|
|
102
|
+
// is honest for the tools pi has; bash-side changes are invisible to both.
|
|
103
|
+
let linesAdded = 0
|
|
104
|
+
let linesRemoved = 0
|
|
105
|
+
// API timing and the last message's token usage, from provider/message events:
|
|
106
|
+
// the fields ctx.getContextUsage() does not expose (output/cache tokens, API time).
|
|
107
|
+
let apiDurationMs = 0
|
|
108
|
+
let requestStartMs: number | undefined
|
|
109
|
+
let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
|
|
87
110
|
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
|
88
111
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
89
112
|
let running = false
|
|
@@ -109,19 +132,52 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
109
132
|
// so reporting it here would describe a style the session is not using.
|
|
110
133
|
const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
|
|
111
134
|
const payload: Record<string, unknown> = {
|
|
135
|
+
hook_event_name: 'Status',
|
|
112
136
|
session_id: ctx.sessionManager.getSessionId(),
|
|
113
137
|
cwd: ctx.cwd,
|
|
138
|
+
version: PACKAGE_VERSION,
|
|
114
139
|
workspace: { current_dir: ctx.cwd, project_dir: ctx.cwd },
|
|
115
140
|
// Both fields, per Claude's documented contract: published statusline scripts
|
|
116
141
|
// read .model.display_name and render the literal "null" when it is missing.
|
|
117
142
|
model: { id: model?.id ?? '', display_name: model?.name ?? model?.id ?? '' },
|
|
118
|
-
cost: {
|
|
119
|
-
|
|
143
|
+
cost: {
|
|
144
|
+
total_cost_usd: sessionCost(ctx),
|
|
145
|
+
total_duration_ms: Date.now() - sessionStartMs,
|
|
146
|
+
total_api_duration_ms: apiDurationMs,
|
|
147
|
+
total_lines_added: linesAdded,
|
|
148
|
+
total_lines_removed: linesRemoved,
|
|
149
|
+
},
|
|
150
|
+
context_window: {
|
|
151
|
+
context_window_size: usage.contextWindow,
|
|
152
|
+
used_percentage: usage.percent,
|
|
153
|
+
remaining_percentage: usage.percent === null ? null : 100 - usage.percent,
|
|
154
|
+
total_input_tokens: usage.tokens,
|
|
155
|
+
// The per-component breakdown from the last message's usage, which
|
|
156
|
+
// ctx.getContextUsage() (input-side estimate only) cannot provide.
|
|
157
|
+
...(lastUsage
|
|
158
|
+
? {
|
|
159
|
+
total_output_tokens: lastUsage.output,
|
|
160
|
+
current_usage: {
|
|
161
|
+
input_tokens: lastUsage.input,
|
|
162
|
+
output_tokens: lastUsage.output,
|
|
163
|
+
cache_read_input_tokens: lastUsage.cacheRead,
|
|
164
|
+
cache_creation_input_tokens: lastUsage.cacheWrite,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
},
|
|
169
|
+
// The true combined total when a message usage is known, else the input-side estimate.
|
|
170
|
+
exceeds_200k_tokens: (lastUsage?.totalTokens ?? usage.tokens ?? 0) > 200_000,
|
|
120
171
|
permission_mode: permissionMode,
|
|
121
172
|
}
|
|
122
173
|
const transcript = ctx.sessionManager.getSessionFile()
|
|
123
174
|
if (transcript) payload.transcript_path = transcript
|
|
124
|
-
|
|
175
|
+
const sessionName = ctx.sessionManager.getSessionName?.()
|
|
176
|
+
if (sessionName) payload.session_name = sessionName
|
|
177
|
+
if (ctx.thinkingLevel) {
|
|
178
|
+
payload.effort = { level: ctx.thinkingLevel }
|
|
179
|
+
payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
|
|
180
|
+
}
|
|
125
181
|
if (styleName) payload.output_style = { name: styleName }
|
|
126
182
|
return payload
|
|
127
183
|
}
|
|
@@ -170,18 +226,57 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
170
226
|
scheduleRefresh()
|
|
171
227
|
})
|
|
172
228
|
|
|
229
|
+
// Counted here rather than in buildPayload so the numbers accumulate across the
|
|
230
|
+
// session the way Claude's counters do.
|
|
231
|
+
pi.on('tool_result', async (event) => {
|
|
232
|
+
if (event.isError) return
|
|
233
|
+
const input = event.input as Record<string, unknown>
|
|
234
|
+
const lines = (text: unknown): number => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
|
|
235
|
+
if (event.toolName === 'write') linesAdded += lines(input.content)
|
|
236
|
+
if (event.toolName === 'edit' && Array.isArray(input.edits)) {
|
|
237
|
+
for (const edit of input.edits as Array<{ oldText?: unknown; newText?: unknown }>) {
|
|
238
|
+
linesAdded += lines(edit.newText)
|
|
239
|
+
linesRemoved += lines(edit.oldText)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// API round-trip timing: the window between the request and its response, summed
|
|
245
|
+
// across the session. ctx exposes no API-duration getter, so it is measured here.
|
|
246
|
+
pi.on('before_provider_request', async () => {
|
|
247
|
+
requestStartMs = Date.now()
|
|
248
|
+
})
|
|
249
|
+
pi.on('after_provider_response', async () => {
|
|
250
|
+
if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
|
|
251
|
+
requestStartMs = undefined
|
|
252
|
+
})
|
|
253
|
+
// The last message's token usage, for the breakdown getContextUsage() omits.
|
|
254
|
+
pi.on('message_end', async (event) => {
|
|
255
|
+
const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
|
|
256
|
+
if (usage) lastUsage = usage
|
|
257
|
+
})
|
|
258
|
+
|
|
173
259
|
pi.on('session_start', async (_event, ctx) => {
|
|
174
260
|
// One instance serves every session, so a fresh session must not inherit state.
|
|
175
261
|
turnCount = 0
|
|
176
262
|
commandLine = undefined
|
|
177
263
|
sessionCtx = ctx
|
|
264
|
+
sessionStartMs = Date.now()
|
|
265
|
+
linesAdded = 0
|
|
266
|
+
linesRemoved = 0
|
|
267
|
+
apiDurationMs = 0
|
|
268
|
+
requestStartMs = undefined
|
|
269
|
+
lastUsage = undefined
|
|
178
270
|
clearInterval(refreshTimer)
|
|
179
271
|
// Reading config must never open a trust dialog: several extensions resolve
|
|
180
272
|
// approval at session start, and a second prompt stacks over the first and eats
|
|
181
273
|
// the keys meant for it. An undecided project simply skips project settings.
|
|
182
274
|
const trusted = isProjectApprovedSilently(ctx)
|
|
183
275
|
projectApproved = trusted
|
|
184
|
-
|
|
276
|
+
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
277
|
+
// Claude's disableAllHooks also turns off the custom statusLine command; the
|
|
278
|
+
// built-in segment still renders as the fallback.
|
|
279
|
+
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
185
280
|
if (config?.refreshInterval) {
|
|
186
281
|
refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
|
|
187
282
|
}
|
|
@@ -10,22 +10,31 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
|
|
|
10
10
|
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
11
11
|
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
12
|
// is not merely ignored, it narrows the child's registry.
|
|
13
|
-
import {
|
|
13
|
+
import { parseToolGrants } from '../internal/command-file.js'
|
|
14
|
+
import { installedPlugins } from '../internal/plugins.js'
|
|
15
|
+
import { findNearestDir } from '../internal/project-root.js'
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
17
19
|
* list. Anything else returns null: a restriction that failed to parse must not run
|
|
18
20
|
* the agent unrestricted.
|
|
21
|
+
*
|
|
22
|
+
* An argument-scoped grant (`Bash(git log:*)`) cannot be expressed in the child's
|
|
23
|
+
* --tools allowlist, and the parent cannot reach into the child process to enforce
|
|
24
|
+
* it at call time the way commands.ts does, so on the granting side it is rejected
|
|
25
|
+
* like any other unexpressable restriction. A scoped *disallow* only denies more
|
|
26
|
+
* than the file asked for, which is the safe direction, so it stands.
|
|
19
27
|
*/
|
|
20
|
-
function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
28
|
+
function parseToolsField(raw: unknown, granting: boolean): string[] | undefined | null {
|
|
21
29
|
if (raw === undefined) return undefined
|
|
22
30
|
// Shares the command parser's splitting, so a comma inside an argument scope stays
|
|
23
31
|
// inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
|
|
24
32
|
if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
|
|
25
33
|
if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
28
|
-
|
|
34
|
+
const grants = parseToolGrants(raw)
|
|
35
|
+
if (!grants) return null
|
|
36
|
+
if (granting && grants.scopedEntries.length > 0) return null
|
|
37
|
+
return grants.tools.length > 0 ? grants.tools : undefined
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
/**
|
|
@@ -35,7 +44,7 @@ function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
|
35
44
|
* (Claude's `inherit`) is the degradation that works everywhere; users who want a
|
|
36
45
|
* tier pinned should name a concrete model id.
|
|
37
46
|
*/
|
|
38
|
-
const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
|
|
47
|
+
const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable', 'inherit'])
|
|
39
48
|
|
|
40
49
|
function parseModelField(raw: unknown): string | undefined {
|
|
41
50
|
if (typeof raw !== 'string') return undefined
|
|
@@ -70,6 +79,20 @@ function parseEffortField(raw: unknown): string | undefined {
|
|
|
70
79
|
return THINKING_LEVELS.has(effort) ? effort : undefined
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
/** Claude's agent `memory:` scopes: a persistent per-agent store for cross-session
|
|
83
|
+
* learning, separate from the parent conversation's auto memory. */
|
|
84
|
+
export type AgentMemoryScope = 'user' | 'project' | 'local'
|
|
85
|
+
|
|
86
|
+
const MEMORY_SCOPES: ReadonlySet<string> = new Set(['user', 'project', 'local'])
|
|
87
|
+
|
|
88
|
+
/** Anything other than the three scopes is ignored, so the agent still runs, just
|
|
89
|
+
* without memory; a typo in an optional enhancement should not drop the agent. */
|
|
90
|
+
function parseMemoryField(raw: unknown): AgentMemoryScope | undefined {
|
|
91
|
+
if (typeof raw !== 'string') return undefined
|
|
92
|
+
const scope = raw.trim().toLowerCase()
|
|
93
|
+
return MEMORY_SCOPES.has(scope) ? (scope as AgentMemoryScope) : undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
73
96
|
/** Claude's `skills` frontmatter: a comma string or YAML list of skill names. */
|
|
74
97
|
function parseSkillsField(raw: unknown): string[] | undefined {
|
|
75
98
|
let names: string[] = []
|
|
@@ -145,9 +168,15 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
145
168
|
const name = typeof frontmatter.name === 'string' ? frontmatter.name : ''
|
|
146
169
|
const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
|
|
147
170
|
if (!name || !description) return null
|
|
148
|
-
const tools = parseToolsField(frontmatter.tools)
|
|
149
|
-
if (tools === null)
|
|
150
|
-
|
|
171
|
+
const tools = parseToolsField(frontmatter.tools, true)
|
|
172
|
+
if (tools === null) {
|
|
173
|
+
// A silent drop reads as "agent does not exist"; say why, since an
|
|
174
|
+
// argument-scoped grant is a shape Claude's own docs recommend but pi cannot
|
|
175
|
+
// enforce on a child process.
|
|
176
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: its tools: grant could not be applied (an argument scope like Bash(git log:*) cannot be enforced on a subagent; grant the whole tool or drop it)`)
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
const disallowedTools = parseToolsField(frontmatter.disallowedTools, false)
|
|
151
180
|
if (disallowedTools === null) return null
|
|
152
181
|
return {
|
|
153
182
|
name,
|
|
@@ -158,12 +187,20 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
158
187
|
effort: parseEffortField(frontmatter.effort),
|
|
159
188
|
modelAlias: parseModelAlias(frontmatter.model),
|
|
160
189
|
skills: parseSkillsField(frontmatter.skills),
|
|
190
|
+
memory: parseMemoryField(frontmatter.memory),
|
|
191
|
+
maxTurns: parseMaxTurns(frontmatter.maxTurns),
|
|
161
192
|
systemPrompt: body,
|
|
162
193
|
source,
|
|
163
194
|
filePath,
|
|
164
195
|
}
|
|
165
196
|
}
|
|
166
197
|
|
|
198
|
+
/** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
|
|
199
|
+
* Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
|
|
200
|
+
function parseMaxTurns(raw: unknown): number | undefined {
|
|
201
|
+
return typeof raw === 'number' && Number.isInteger(raw) && raw > 0 ? raw : undefined
|
|
202
|
+
}
|
|
203
|
+
|
|
167
204
|
export type AgentScope = 'user' | 'project' | 'both'
|
|
168
205
|
|
|
169
206
|
export interface AgentConfig {
|
|
@@ -177,6 +214,10 @@ export interface AgentConfig {
|
|
|
177
214
|
modelAlias?: string
|
|
178
215
|
/** Skill names to inline into the child's prompt, per Claude's `skills` field. */
|
|
179
216
|
skills?: string[]
|
|
217
|
+
/** Persistent per-agent memory scope, per Claude's `memory:` field. */
|
|
218
|
+
memory?: AgentMemoryScope
|
|
219
|
+
/** Cap on the child's agentic turns, enforced by killing at the turn boundary. */
|
|
220
|
+
maxTurns?: number
|
|
180
221
|
systemPrompt: string
|
|
181
222
|
source: AgentSource
|
|
182
223
|
filePath: string
|
|
@@ -187,25 +228,27 @@ export interface AgentDiscoveryResult {
|
|
|
187
228
|
projectAgentsDir: string | null
|
|
188
229
|
}
|
|
189
230
|
|
|
231
|
+
/** Claude scans .claude/agents recursively so agents can be organized into
|
|
232
|
+
* subfolders (agents/review/, agents/research/); the walk mirrors that. */
|
|
190
233
|
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
191
234
|
const agents: AgentConfig[] = []
|
|
192
235
|
|
|
193
|
-
if (!fs.existsSync(dir)) {
|
|
194
|
-
return agents
|
|
195
|
-
}
|
|
196
|
-
|
|
197
236
|
let entries: fs.Dirent[]
|
|
198
237
|
try {
|
|
199
238
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
200
239
|
} catch {
|
|
201
|
-
return agents
|
|
240
|
+
return agents // a missing or unreadable directory contributes nothing
|
|
202
241
|
}
|
|
203
242
|
|
|
204
243
|
for (const entry of entries) {
|
|
244
|
+
const filePath = path.join(dir, entry.name)
|
|
245
|
+
if (entry.isDirectory()) {
|
|
246
|
+
agents.push(...loadAgentsFromDir(filePath, source))
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
205
249
|
if (!entry.name.endsWith('.md')) continue
|
|
206
250
|
if (!entry.isFile() && !entry.isSymbolicLink()) continue
|
|
207
251
|
|
|
208
|
-
const filePath = path.join(dir, entry.name)
|
|
209
252
|
let content: string
|
|
210
253
|
try {
|
|
211
254
|
content = fs.readFileSync(filePath, 'utf-8')
|
|
@@ -220,49 +263,6 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
220
263
|
return agents
|
|
221
264
|
}
|
|
222
265
|
|
|
223
|
-
function isDirectory(p: string): boolean {
|
|
224
|
-
try {
|
|
225
|
-
return fs.statSync(p).isDirectory()
|
|
226
|
-
} catch {
|
|
227
|
-
return false
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Project root at or above `from`. `.git` is a file in worktrees and submodules. */
|
|
232
|
-
const ROOT_MARKERS = ['.git', 'package.json']
|
|
233
|
-
|
|
234
|
-
function repoRoot(from: string): string | undefined {
|
|
235
|
-
let currentDir = from
|
|
236
|
-
while (true) {
|
|
237
|
-
if (ROOT_MARKERS.some((marker) => fs.existsSync(path.join(currentDir, marker)))) return currentDir
|
|
238
|
-
const parentDir = path.dirname(currentDir)
|
|
239
|
-
if (parentDir === currentDir) return undefined
|
|
240
|
-
currentDir = parentDir
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Nearest `relative` directory at or above `cwd`, stopping at the repository root.
|
|
246
|
-
*
|
|
247
|
-
* Without the boundary the search runs to the filesystem root, so an agent planted in a
|
|
248
|
-
* world-writable ancestor such as /tmp is offered as a project agent for every session
|
|
249
|
-
* beneath it. With no project marker (.git, package.json) the extent is unknown, so only
|
|
250
|
-
* `cwd` is considered.
|
|
251
|
-
*/
|
|
252
|
-
function findNearestDir(cwd: string, relative: string): string | null {
|
|
253
|
-
const boundary = repoRoot(cwd) ?? cwd
|
|
254
|
-
let currentDir = cwd
|
|
255
|
-
while (true) {
|
|
256
|
-
const candidate = path.join(currentDir, relative)
|
|
257
|
-
if (isDirectory(candidate)) return candidate
|
|
258
|
-
|
|
259
|
-
if (currentDir === boundary) return null
|
|
260
|
-
const parentDir = path.dirname(currentDir)
|
|
261
|
-
if (parentDir === currentDir) return null
|
|
262
|
-
currentDir = parentDir
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
266
|
function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
|
|
267
267
|
const agentMap = new Map<string, AgentConfig>()
|
|
268
268
|
const register = (agents: AgentConfig[]): void => {
|
|
@@ -274,19 +274,30 @@ function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[],
|
|
|
274
274
|
return agentMap
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
export type AgentSource = 'user' | 'project' | 'builtin'
|
|
277
|
+
export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
|
|
278
278
|
|
|
279
279
|
/** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
|
|
280
280
|
export const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
|
|
281
281
|
|
|
282
|
+
/** Agent directories of every enabled plugin: `agents/` unless the manifest
|
|
283
|
+
* points elsewhere. Plugins are user-installed, so user scope only decides. */
|
|
284
|
+
function pluginAgentDirs(home: string): string[] {
|
|
285
|
+
return installedPlugins(home).flatMap((plugin) => {
|
|
286
|
+
const declared = plugin.manifest.agents
|
|
287
|
+
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
|
|
288
|
+
return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
282
292
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
283
293
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
284
294
|
const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
|
|
285
295
|
const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
|
|
286
296
|
const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
|
|
287
297
|
|
|
288
|
-
//
|
|
289
|
-
|
|
298
|
+
// Plugins load after builtins and before the user's own dirs, so a user agent
|
|
299
|
+
// wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
|
|
300
|
+
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
|
|
290
301
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
291
302
|
const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
292
303
|
|
|
@@ -40,6 +40,8 @@ export interface BackgroundSpawn {
|
|
|
40
40
|
* completing run deleted. Without it the resumed child is handed a path that no
|
|
41
41
|
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
42
42
|
promptBody?: string
|
|
43
|
+
/** Claude's maxTurns: kill the child once it has produced this many turns. */
|
|
44
|
+
maxTurns?: number
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
const runs = new Map<string, BackgroundRun>()
|
|
@@ -68,7 +70,7 @@ function evictFinishedRuns(): void {
|
|
|
68
70
|
|
|
69
71
|
/** Line-by-line parser keeping only the last assistant text and a turn count, so a
|
|
70
72
|
* long run's JSONL stdout never accumulates whole in the parent's memory. */
|
|
71
|
-
export function createJsonlOutputParser(): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
|
|
73
|
+
export function createJsonlOutputParser(onTurn?: (turns: number) => void): { push: (chunk: string) => void; flush: () => { text: string; turns: number } } {
|
|
72
74
|
let buffer = ''
|
|
73
75
|
let text = ''
|
|
74
76
|
let turns = 0
|
|
@@ -82,6 +84,7 @@ export function createJsonlOutputParser(): { push: (chunk: string) => void; flus
|
|
|
82
84
|
}
|
|
83
85
|
if (event.type !== 'message_end' || event.message?.role !== 'assistant') return
|
|
84
86
|
turns++
|
|
87
|
+
onTurn?.(turns)
|
|
85
88
|
// The complete text of the last assistant message, matching getFinalOutput on the
|
|
86
89
|
// foreground path so a multi-part message reads the same in both.
|
|
87
90
|
const parts = (event.message.content ?? []).filter((p) => p.type === 'text' && p.text).map((p) => p.text as string)
|
|
@@ -230,8 +233,23 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
230
233
|
proc.once('close', () => clearTimeout(escalate))
|
|
231
234
|
}
|
|
232
235
|
// Parsed as it streams: buffering the whole JSONL replays every tool result echoed
|
|
233
|
-
// by the child through the parent's memory for the life of the run.
|
|
234
|
-
|
|
236
|
+
// by the child through the parent's memory for the life of the run. A maxTurns cap
|
|
237
|
+
// kills the child at the turn boundary after its Nth turn, so the output so far is
|
|
238
|
+
// preserved and no turn is cut mid-flight.
|
|
239
|
+
const maxTurns = invocation.maxTurns
|
|
240
|
+
// A maxTurns cap kills the child with SIGTERM, so its close arrives with a null code.
|
|
241
|
+
// That is a clean boundary end (the foreground path treats the same cap as success),
|
|
242
|
+
// so remember it and do not misreport the run as failed.
|
|
243
|
+
let cappedByMaxTurns = false
|
|
244
|
+
const parser = createJsonlOutputParser(
|
|
245
|
+
maxTurns
|
|
246
|
+
? (turns) => {
|
|
247
|
+
if (turns < maxTurns) return
|
|
248
|
+
cappedByMaxTurns = true
|
|
249
|
+
run.kill?.()
|
|
250
|
+
}
|
|
251
|
+
: undefined,
|
|
252
|
+
)
|
|
235
253
|
let stderrTail = ''
|
|
236
254
|
// Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
|
|
237
255
|
let completed = false
|
|
@@ -264,9 +282,10 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
264
282
|
const { text, turns } = parser.flush()
|
|
265
283
|
run.kill = undefined
|
|
266
284
|
run.live = false
|
|
267
|
-
// A cancelled run keeps that state: its non-zero exit is the cancellation.
|
|
268
|
-
|
|
269
|
-
run.
|
|
285
|
+
// A cancelled run keeps that state: its non-zero exit is the cancellation. A
|
|
286
|
+
// maxTurns cap ends cleanly with output preserved, so it counts as done, not failed.
|
|
287
|
+
if (run.state !== 'cancelled') run.state = code === 0 || cappedByMaxTurns ? 'done' : 'failed'
|
|
288
|
+
run.exitCode = cappedByMaxTurns ? 0 : (code ?? 0)
|
|
270
289
|
run.output = text
|
|
271
290
|
run.turns = turns
|
|
272
291
|
run.stderr = stderrTail.trim() || undefined
|