pi-code 1.0.55 → 1.0.57
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 +2 -2
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +2 -10
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +10 -4
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +15 -5
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- package/package.json +1 -1
package/extensions/mcp/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { installedPlugins } from '../internal/plugins.js'
|
|
|
44
44
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
45
45
|
import { repoRoot } from '../internal/project-root.js'
|
|
46
46
|
import { claudeSettingsChain } from '../internal/settings-chain.js'
|
|
47
|
+
import { errorMessage } from '../internal/values.js'
|
|
47
48
|
import { disabledServerNames, loadConfigFrom, loadPluginServers, loadUserScope, localScopeServerNames, projectConfigPaths, type ServerConfig, warnOnTypelessUrl } from './config.js'
|
|
48
49
|
import { collectServerResourceEntries, listAllPrompts, listAllTools, type McpToolInfo, resourceServerFilter } from './listing.js'
|
|
49
50
|
import { formatPromptCommandName, formatToolName, type McpContentBlock, type McpPromptInfo, mapContent, mapPromptArguments, normalizeSchema, promptMessageContent } from './mapping.js'
|
|
@@ -272,7 +273,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
272
273
|
// streaming, so mid-stream invocations queue as a follow-up turn.
|
|
273
274
|
pi.sendUserMessage(content, ctx.isIdle() ? {} : { deliverAs: 'followUp' })
|
|
274
275
|
} catch (error) {
|
|
275
|
-
ctx.ui.notify(`${commandName}: ${
|
|
276
|
+
ctx.ui.notify(`${commandName}: ${errorMessage(error)}`, 'error')
|
|
276
277
|
}
|
|
277
278
|
},
|
|
278
279
|
})
|
|
@@ -286,7 +287,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
286
287
|
try {
|
|
287
288
|
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
288
289
|
} catch (error) {
|
|
289
|
-
console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${
|
|
290
|
+
console.warn(`pi-code-mcp: prompt listing failed for ${name}: ${errorMessage(error)}`)
|
|
290
291
|
}
|
|
291
292
|
}
|
|
292
293
|
|
|
@@ -298,7 +299,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
298
299
|
try {
|
|
299
300
|
registerPrompts(name, await withTimeout(listAllPrompts(client), connectTimeoutMs(), `list prompts ${name}`))
|
|
300
301
|
} catch (error) {
|
|
301
|
-
console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${
|
|
302
|
+
console.warn(`pi-code-mcp: prompt refresh failed for ${name}: ${errorMessage(error)}`)
|
|
302
303
|
}
|
|
303
304
|
})
|
|
304
305
|
} catch {
|
|
@@ -386,7 +387,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
386
387
|
status.set(name, { state: current?.state ?? 'connected', tools: serverToolCount(name) })
|
|
387
388
|
pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
|
|
388
389
|
} catch (error) {
|
|
389
|
-
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${
|
|
390
|
+
console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${errorMessage(error)}`)
|
|
390
391
|
}
|
|
391
392
|
})
|
|
392
393
|
} catch {
|
|
@@ -445,7 +446,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
445
446
|
if (!shuttingDown && !serverCallTuning(config).stdio) void reconnectWithBackoff(name, config)
|
|
446
447
|
}
|
|
447
448
|
} catch (error) {
|
|
448
|
-
status.set(name, { state: `failed: ${
|
|
449
|
+
status.set(name, { state: `failed: ${errorMessage(error)}`, tools: 0 })
|
|
449
450
|
// Connected but failed after (tool listing hung or errored): left in the
|
|
450
451
|
// map, the client idles its process for the whole session and the
|
|
451
452
|
// duplicate-name guard blocks the name for every later attempt.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
7
|
+
import { errorMessage } from '../internal/values.js'
|
|
7
8
|
import type { McpPromptInfo } from './mapping.js'
|
|
8
9
|
import { callRequestOptions, withTimeout } from './transport.js'
|
|
9
10
|
|
|
@@ -76,7 +77,7 @@ export async function collectServerResourceEntries(entries: Array<Record<string,
|
|
|
76
77
|
try {
|
|
77
78
|
await collectResources(entries, name, client, budget)
|
|
78
79
|
} catch (error) {
|
|
79
|
-
entries.push({ server: name, error:
|
|
80
|
+
entries.push({ server: name, error: errorMessage(error) })
|
|
80
81
|
}
|
|
81
82
|
try {
|
|
82
83
|
await collectResourceTemplates(entries, name, client, budget)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
9
9
|
import { FileOAuthProvider, type OAuthServerConfig, openBrowser, startCallbackServer, waitForAuthCode } from '../internal/mcp-oauth.js'
|
|
10
|
+
import { errorMessage } from '../internal/values.js'
|
|
10
11
|
import { type AuthUi, connectWithTimeout, isUnauthorized, type MakeTransport, OAuthRequiredError } from './transport.js'
|
|
11
12
|
|
|
12
13
|
/** Browser logins are human-paced; a connect-sized timeout would cut them off. */
|
|
@@ -16,7 +17,7 @@ const OAUTH_FLOW_TIMEOUT_MS = 180_000
|
|
|
16
17
|
* unchanged so its message is not doubled. */
|
|
17
18
|
function asOAuthRequiredError(name: string, error: unknown): OAuthRequiredError {
|
|
18
19
|
if (error instanceof OAuthRequiredError) return error
|
|
19
|
-
const detail =
|
|
20
|
+
const detail = errorMessage(error)
|
|
20
21
|
return new OAuthRequiredError(`login for ${name} failed: ${detail}`)
|
|
21
22
|
}
|
|
22
23
|
|
package/extensions/mcp/policy.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as path from 'node:path'
|
|
|
9
9
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
10
10
|
import { managedSettingsFile } from '../internal/managed-settings.js'
|
|
11
11
|
import { findNearestFile } from '../internal/project-root.js'
|
|
12
|
+
import { errorMessage } from '../internal/values.js'
|
|
12
13
|
import { interpolateEnv, type ServerConfig } from './config.js'
|
|
13
14
|
|
|
14
15
|
export interface ProjectServerPolicy {
|
|
@@ -151,7 +152,13 @@ export function urlPatternMatches(pattern: string, url: string): boolean {
|
|
|
151
152
|
return wildcardRegExp(patternParts.path).test(urlParts.path ?? '/')
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
+
/** A config file is user- or repo-written JSON, so any field can hold any value. Only a
|
|
156
|
+
* string `url` makes a server remote: a config that carries some other value there is
|
|
157
|
+
* still gated by its command, and no policy entry is evaluated against a non-string. */
|
|
158
|
+
const configUrl = (config: ServerConfig): string | undefined => {
|
|
159
|
+
const url = (config as { url?: unknown }).url
|
|
160
|
+
return typeof url === 'string' ? url : undefined
|
|
161
|
+
}
|
|
155
162
|
const configArgv = (config: ServerConfig): string[] | undefined => {
|
|
156
163
|
const command = (config as { command?: string }).command
|
|
157
164
|
if (typeof command !== 'string') return undefined
|
|
@@ -227,7 +234,7 @@ export function loadManagedMcpServers(managedFile: string = managedSettingsFile(
|
|
|
227
234
|
} catch (error) {
|
|
228
235
|
// Present but corrupt: fail closed to an empty managed set, exactly like an empty map,
|
|
229
236
|
// rather than reopening the user/project/plugin scopes.
|
|
230
|
-
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${
|
|
237
|
+
console.warn(`pi-code-mcp: managed-mcp.json is present but not valid JSON (${file}); failing closed to no MCP servers: ${errorMessage(error)}`)
|
|
231
238
|
return {}
|
|
232
239
|
}
|
|
233
240
|
if (parsed === null || typeof parsed !== 'object') return {}
|
package/extensions/memory.ts
CHANGED
|
@@ -20,8 +20,9 @@ import { readManagedSettings } from './internal/managed-settings.js'
|
|
|
20
20
|
import { capForContext } from './internal/output-guard.js'
|
|
21
21
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
22
22
|
import { repoRoot } from './internal/project-root.js'
|
|
23
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
23
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
24
24
|
import { statToken } from './internal/stat-token.js'
|
|
25
|
+
import { errorMessage } from './internal/values.js'
|
|
25
26
|
|
|
26
27
|
export const INDEX_FILE = 'MEMORY.md'
|
|
27
28
|
|
|
@@ -152,7 +153,7 @@ export function migrateLegacyStore(cwd: string): void {
|
|
|
152
153
|
} catch (error) {
|
|
153
154
|
// A failed migration must not take down session start, but the session then has no
|
|
154
155
|
// memories while they sit under the old slug, which reads as having lost them.
|
|
155
|
-
console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${
|
|
156
|
+
console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${errorMessage(error)}; this session starts without those memories`)
|
|
156
157
|
}
|
|
157
158
|
return
|
|
158
159
|
}
|
|
@@ -236,7 +237,7 @@ function readMemory(dir: string, name: string): MemoryToolResult {
|
|
|
236
237
|
// Only a missing file is "no such memory"; anything else (a directory in its place, a
|
|
237
238
|
// permission problem) sends the model hunting for a name that is actually there.
|
|
238
239
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
239
|
-
return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${
|
|
240
|
+
return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${errorMessage(error)}` }], details: {} }
|
|
240
241
|
}
|
|
241
242
|
return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
|
|
242
243
|
}
|
|
@@ -257,7 +258,7 @@ async function deleteMemory(dir: string, indexPath: string, name: string): Promi
|
|
|
257
258
|
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
258
259
|
})
|
|
259
260
|
} catch (error) {
|
|
260
|
-
return { content: [{ type: 'text', text: `Memory delete failed: ${
|
|
261
|
+
return { content: [{ type: 'text', text: `Memory delete failed: ${errorMessage(error)}. Nothing was deleted.` }], details: {} }
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
@@ -350,15 +351,9 @@ export function memorySettingsFiles(cwd: string, home: string, approved: boolean
|
|
|
350
351
|
* managed policy settings win over every file, per Claude's settings precedence. */
|
|
351
352
|
export function readMemorySettings(files: string[], managed: Record<string, unknown> = readManagedSettings()): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
|
|
352
353
|
const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
|
|
353
|
-
for (const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (settings === null || typeof settings !== 'object') continue
|
|
357
|
-
if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
|
|
358
|
-
if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
|
|
359
|
-
} catch {
|
|
360
|
-
// missing or invalid settings file: skip
|
|
361
|
-
}
|
|
354
|
+
for (const settings of readSettingsChain(files)) {
|
|
355
|
+
if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
|
|
356
|
+
if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
|
|
362
357
|
}
|
|
363
358
|
if ('autoMemoryEnabled' in managed) merged.autoMemoryEnabled = managed.autoMemoryEnabled
|
|
364
359
|
if ('autoMemoryDirectory' in managed) merged.autoMemoryDirectory = managed.autoMemoryDirectory
|
|
@@ -478,7 +473,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
478
473
|
// Awaited here, not returned: the catch must see a queued write's rejection.
|
|
479
474
|
return await saveMemory(dir, indexPath, name, params.description, params.content)
|
|
480
475
|
} catch (error) {
|
|
481
|
-
return { content: [{ type: 'text' as const, text: `Memory save failed: ${
|
|
476
|
+
return { content: [{ type: 'text' as const, text: `Memory save failed: ${errorMessage(error)}. The index was left untouched.` }], details: {} }
|
|
482
477
|
} finally {
|
|
483
478
|
indexCache = null
|
|
484
479
|
}
|
|
@@ -520,7 +515,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
520
515
|
try {
|
|
521
516
|
result = setAutoMemoryEnabledSetting(home, next)
|
|
522
517
|
} catch (error) {
|
|
523
|
-
ctx.ui.notify(`Could not update auto memory: ${
|
|
518
|
+
ctx.ui.notify(`Could not update auto memory: ${errorMessage(error)}`, 'error')
|
|
524
519
|
return
|
|
525
520
|
}
|
|
526
521
|
if (!result.ok) {
|
|
@@ -24,13 +24,13 @@ import * as os from 'node:os'
|
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
26
|
import { atomicWriteFile } from './internal/atomic-write.js'
|
|
27
|
-
|
|
28
27
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
29
28
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
30
29
|
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
31
30
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
32
31
|
import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
33
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
32
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
33
|
+
import { isDirectory } from './internal/values.js'
|
|
34
34
|
|
|
35
35
|
export interface OutputStyle {
|
|
36
36
|
name: string
|
|
@@ -87,14 +87,6 @@ export function applyStyle(systemPrompt: string, style: OutputStyle): string {
|
|
|
87
87
|
return `${systemPrompt}\n\n${styleSection}`
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function isDirectory(target: string): boolean {
|
|
91
|
-
try {
|
|
92
|
-
return fs.statSync(target).isDirectory()
|
|
93
|
-
} catch {
|
|
94
|
-
return false
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
90
|
/**
|
|
99
91
|
* Existing `.claude/output-styles` directories, user first then project. The project
|
|
100
92
|
* directory is included only for trusted projects, since its style body is injected
|
|
@@ -159,13 +151,8 @@ export function settingsFiles(cwd: string, home: string, trusted: boolean): stri
|
|
|
159
151
|
export function readActiveStyleName(files: string[], managed: Record<string, unknown> = readManagedSettings()): string | undefined {
|
|
160
152
|
if (typeof managed.outputStyle === 'string') return managed.outputStyle
|
|
161
153
|
let name: string | undefined
|
|
162
|
-
for (const
|
|
163
|
-
|
|
164
|
-
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
165
|
-
if (typeof settings.outputStyle === 'string') name = settings.outputStyle
|
|
166
|
-
} catch {
|
|
167
|
-
// missing or invalid file: skip
|
|
168
|
-
}
|
|
154
|
+
for (const settings of readSettingsChain(files)) {
|
|
155
|
+
if (typeof settings.outputStyle === 'string') name = settings.outputStyle
|
|
169
156
|
}
|
|
170
157
|
return name
|
|
171
158
|
}
|
|
@@ -19,7 +19,7 @@ import { Key } from '@earendil-works/pi-tui'
|
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
20
|
|
|
21
21
|
import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
22
|
-
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
22
|
+
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, restoredPlanState, type TodoItem } from './utils.js'
|
|
23
23
|
|
|
24
24
|
// Tools
|
|
25
25
|
const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
|
|
@@ -433,14 +433,14 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
433
433
|
// rewind past a plan it would resurrect the abandoned plan and its tool restriction.
|
|
434
434
|
const entries = ctx.sessionManager.getBranch()
|
|
435
435
|
|
|
436
|
-
// Restore persisted state
|
|
437
|
-
|
|
436
|
+
// Restore persisted state. The entry is JSON on disk, so every field is checked
|
|
437
|
+
// before it is used: savedTools reaches pi.setActiveTools.
|
|
438
|
+
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: unknown } | undefined
|
|
439
|
+
const restored = restoredPlanState(planModeEntry?.data)
|
|
438
440
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
executionMode = planModeEntry.data.executing ?? executionMode
|
|
443
|
-
}
|
|
441
|
+
planModeEnabled = restored.enabled ?? planModeEnabled
|
|
442
|
+
todoItems = restored.todos ?? todoItems
|
|
443
|
+
executionMode = restored.executing ?? executionMode
|
|
444
444
|
publishPlanState()
|
|
445
445
|
|
|
446
446
|
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
@@ -456,7 +456,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
456
456
|
// across /reload and cost the session edit and write for good; applying the
|
|
457
457
|
// snapshot when plan mode is off would instead push a stale set over whatever
|
|
458
458
|
// pi has registered since, so it stays scoped to this branch.
|
|
459
|
-
savedTools =
|
|
459
|
+
savedTools = restored.savedTools ?? pi.getActiveTools()
|
|
460
460
|
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
461
461
|
// --plan enters plan mode without ever toggling, so nothing has persisted yet
|
|
462
462
|
// and a /reload would find no snapshot to restore from. Record it now, while
|
|
@@ -120,6 +120,37 @@ export interface TodoItem {
|
|
|
120
120
|
completed: boolean
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/** The plan state persisted in a session entry, once each field has been checked.
|
|
124
|
+
* A field the restore cannot recognize is simply absent, so the caller keeps its
|
|
125
|
+
* current value. */
|
|
126
|
+
export interface RestoredPlanState {
|
|
127
|
+
enabled?: boolean
|
|
128
|
+
todos?: TodoItem[]
|
|
129
|
+
executing?: boolean
|
|
130
|
+
savedTools?: string[]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const isTodoItem = (value: unknown): value is TodoItem => {
|
|
134
|
+
if (value === null || typeof value !== 'object') return false
|
|
135
|
+
const item = value as Record<string, unknown>
|
|
136
|
+
return typeof item.step === 'number' && typeof item.text === 'string' && typeof item.completed === 'boolean'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read a persisted plan-mode entry, keeping only fields of the expected shape.
|
|
140
|
+
* The session file is data on disk, and `savedTools` feeds the active tool set: a
|
|
141
|
+
* string there would be spread character by character into the tool gating, and a
|
|
142
|
+
* non-array `todos` throws on the first restore that iterates it. */
|
|
143
|
+
export function restoredPlanState(data: unknown): RestoredPlanState {
|
|
144
|
+
if (data === null || typeof data !== 'object') return {}
|
|
145
|
+
const raw = data as Record<string, unknown>
|
|
146
|
+
const state: RestoredPlanState = {}
|
|
147
|
+
if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled
|
|
148
|
+
if (typeof raw.executing === 'boolean') state.executing = raw.executing
|
|
149
|
+
if (Array.isArray(raw.todos) && raw.todos.every(isTodoItem)) state.todos = raw.todos
|
|
150
|
+
if (Array.isArray(raw.savedTools) && raw.savedTools.every((tool) => typeof tool === 'string')) state.savedTools = raw.savedTools
|
|
151
|
+
return state
|
|
152
|
+
}
|
|
153
|
+
|
|
123
154
|
function cleanStepText(text: string): string {
|
|
124
155
|
let cleaned = text
|
|
125
156
|
.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') // Remove bold/italic
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
22
22
|
|
|
23
23
|
import { completeText } from './internal/model-complete.js'
|
|
24
|
+
import { contentText } from './internal/values.js'
|
|
24
25
|
|
|
25
26
|
const TITLE_SYSTEM = 'You name a coding session from its first user message. Reply with a terse 3 to 6 word title in Title Case that captures the task. No quotes, no surrounding punctuation, no trailing period. Output the title only, nothing else.'
|
|
26
27
|
/** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
|
|
@@ -29,23 +30,12 @@ const TITLE_MAX_TOKENS = 24
|
|
|
29
30
|
* bounded prompt keeps the input cost of the extra call small. */
|
|
30
31
|
const MAX_PROMPT_CHARS = 1000
|
|
31
32
|
|
|
32
|
-
/** Join the text of a message's content, mirroring git-checkpoint's extraction: content is
|
|
33
|
-
* either a plain string or an array of parts, of which only text parts carry a title's worth. */
|
|
34
|
-
function extractText(content: unknown): string {
|
|
35
|
-
if (typeof content === 'string') return content
|
|
36
|
-
if (!Array.isArray(content)) return ''
|
|
37
|
-
return content
|
|
38
|
-
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
39
|
-
.map((part) => part.text)
|
|
40
|
-
.join(' ')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
33
|
/** Text of the first user message in the branch, or empty when the run carried no user text
|
|
44
34
|
* (for example a slash-command-only turn), in which case there is nothing to title from. */
|
|
45
35
|
export function firstUserText(ctx: ExtensionContext): string {
|
|
46
36
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
47
37
|
if (entry?.type === 'message' && entry.message.role === 'user') {
|
|
48
|
-
return
|
|
38
|
+
return contentText(entry.message.content, ' ').trim()
|
|
49
39
|
}
|
|
50
40
|
}
|
|
51
41
|
return ''
|
package/extensions/skills.ts
CHANGED
|
@@ -23,7 +23,6 @@ import * as fs from 'node:fs'
|
|
|
23
23
|
import * as os from 'node:os'
|
|
24
24
|
import * as path from 'node:path'
|
|
25
25
|
import { type ExtensionAPI, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
26
|
-
|
|
27
26
|
import { expandCommand, shellExecutionDisabled } from './commands.js'
|
|
28
27
|
import { runAgent } from './internal/agent-run.js'
|
|
29
28
|
import { parseCommandFile } from './internal/command-file.js'
|
|
@@ -32,16 +31,9 @@ import { managedSettingsFile } from './internal/managed-settings.js'
|
|
|
32
31
|
import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
|
|
33
32
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
34
33
|
import { ancestorDirs } from './internal/project-root.js'
|
|
35
|
-
import { claudeSettingsChain } from './internal/settings-chain.js'
|
|
34
|
+
import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
|
|
36
35
|
import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
|
|
37
|
-
|
|
38
|
-
function isDirectory(target: string): boolean {
|
|
39
|
-
try {
|
|
40
|
-
return fs.statSync(target).isDirectory()
|
|
41
|
-
} catch {
|
|
42
|
-
return false
|
|
43
|
-
}
|
|
44
|
-
}
|
|
36
|
+
import { errorMessage, isDirectory, isRecord } from './internal/values.js'
|
|
45
37
|
|
|
46
38
|
/** Existing `.claude/skills` directories, user first then project. The project
|
|
47
39
|
* directory is included only for approved projects: pi's loader surfaces every skill's
|
|
@@ -136,13 +128,9 @@ export default function skillsExtension(pi: ExtensionAPI) {
|
|
|
136
128
|
* (a pi-loader surface, noted in docs). */
|
|
137
129
|
function skillOverrideFor(name: string, cwd: string, trusted: boolean): string | undefined {
|
|
138
130
|
let value: string | undefined
|
|
139
|
-
for (const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (overrides !== null && typeof overrides === 'object' && typeof overrides[name] === 'string') value = overrides[name]
|
|
143
|
-
} catch {
|
|
144
|
-
// missing or invalid file: skip
|
|
145
|
-
}
|
|
131
|
+
for (const settings of readSettingsChain(claudeSettingsChain(cwd, os.homedir(), trusted))) {
|
|
132
|
+
const overrides = settings.skillOverrides
|
|
133
|
+
if (isRecord(overrides) && typeof overrides[name] === 'string') value = overrides[name]
|
|
146
134
|
}
|
|
147
135
|
return value
|
|
148
136
|
}
|
|
@@ -165,7 +153,7 @@ async function runForkedSkill(name: string, filePath: string, expanded: string,
|
|
|
165
153
|
const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
|
|
166
154
|
return { action: 'transform', text: `<skill name="${name}" location="${filePath}">\nThe skill ran in a forked subagent (no conversation history shared). Its result:\n\n${output}\n</skill>` }
|
|
167
155
|
} catch (error) {
|
|
168
|
-
return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${
|
|
156
|
+
return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${errorMessage(error)}\n</skill>` }
|
|
169
157
|
}
|
|
170
158
|
}
|
|
171
159
|
|
|
@@ -36,6 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
|
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
38
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
39
|
+
import { readSettingsChain } from './internal/settings-chain.js'
|
|
39
40
|
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
40
41
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
41
42
|
|
|
@@ -178,14 +179,7 @@ export function readStatusLineConfig(files: string[], managed: Record<string, un
|
|
|
178
179
|
if (managedConfig) return managedConfig
|
|
179
180
|
if (managed.allowManagedHooksOnly === true) return undefined
|
|
180
181
|
let found: StatusLineConfig | undefined
|
|
181
|
-
for (const
|
|
182
|
-
try {
|
|
183
|
-
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
184
|
-
found = parseStatusLineEntry(settings.statusLine) ?? found
|
|
185
|
-
} catch {
|
|
186
|
-
// missing or invalid file: skip
|
|
187
|
-
}
|
|
188
|
-
}
|
|
182
|
+
for (const settings of readSettingsChain(files)) found = parseStatusLineEntry(settings.statusLine) ?? found
|
|
189
183
|
return found
|
|
190
184
|
}
|
|
191
185
|
|
|
@@ -18,9 +18,19 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|
|
18
18
|
```
|
|
19
19
|
subagent/
|
|
20
20
|
├── README.md # This file
|
|
21
|
-
├── index.ts # The extension
|
|
22
|
-
├── agents.ts # Agent discovery
|
|
21
|
+
├── index.ts # The extension: tool schema, dispatch, session hooks
|
|
22
|
+
├── agents.ts # Agent discovery and frontmatter
|
|
23
|
+
├── child.ts # How a child is configured before it is spawned
|
|
24
|
+
├── run.ts # Spawning one child and parsing its event stream
|
|
25
|
+
├── modes.ts # Single, parallel, chain, background, and the project-agent gate
|
|
23
26
|
├── background.ts # Background run registry and spawning
|
|
27
|
+
├── worktree.ts # isolation: worktree setup and teardown
|
|
28
|
+
├── params.ts # The tool schema and the types derived from it
|
|
29
|
+
├── types.ts # Result shapes shared by the tool and its renderers
|
|
30
|
+
├── concurrency.ts # Parallel-run caps and the bounded worker pool
|
|
31
|
+
├── registry-text.ts # Text for /tasks, /agents and completion notices
|
|
32
|
+
├── render.ts # Transcript formatting shared with the parent
|
|
33
|
+
├── render-result.ts # How a call and its results are drawn
|
|
24
34
|
├── agents/ # Bundled builtin agents, always available (lowest precedence)
|
|
25
35
|
│ ├── explore.md # Explore: fast read-only codebase exploration
|
|
26
36
|
│ ├── plan.md # Plan: read-only implementation planning
|
|
@@ -39,7 +49,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
|
|
|
39
49
|
|
|
40
50
|
**Default behavior:** Loads the bundled builtin agents (Explore, Plan, general-purpose) plus **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`. A user or project agent with the same name overrides a builtin. Discovered agents and their descriptions are listed in the system prompt each turn, so the model can pick one itself; project agent descriptions appear only once the project is approved.
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
Project-local agents (`.claude/agents`, `.pi/agents`) load once the project is approved: a default call resolves `agentScope` to `"both"` for an approved project and `"user"` otherwise, and an explicit `agentScope` still narrows or widens it. Every invocation passes the project-agent gate either way.
|
|
43
53
|
|
|
44
54
|
When running interactively, the tool prompts for confirmation before running project-local agents. `confirmProjectAgents: false` skips that prompt for a project you have already approved; an unapproved project is still asked about.
|
|
45
55
|
|
|
@@ -134,9 +144,9 @@ enforces per call.
|
|
|
134
144
|
|
|
135
145
|
**Locations:**
|
|
136
146
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
137
|
-
- `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (
|
|
147
|
+
- `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (an approved project, or an explicit `agentScope` of `"project"`/`"both"`; `.pi` wins a name conflict)
|
|
138
148
|
|
|
139
|
-
Project agents override user agents with the same name when
|
|
149
|
+
Project agents override user agents with the same name when both scopes are in play.
|
|
140
150
|
|
|
141
151
|
## Builtin Agents
|
|
142
152
|
|
|
@@ -6,7 +6,6 @@ import * as fs from 'node:fs'
|
|
|
6
6
|
import * as os from 'node:os'
|
|
7
7
|
import * as path from 'node:path'
|
|
8
8
|
import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
|
-
|
|
10
9
|
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
11
10
|
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
11
|
// is not merely ignored, it narrows the child's registry.
|
|
@@ -14,6 +13,7 @@ import { parseToolGrants } from '../internal/command-file.js'
|
|
|
14
13
|
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
15
14
|
import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
|
|
16
15
|
import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
|
|
16
|
+
import { errorMessage } from '../internal/values.js'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
@@ -180,7 +180,7 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string,
|
|
|
180
180
|
} catch (error) {
|
|
181
181
|
// Malformed YAML must not abort discovery for the whole directory, but a silent drop
|
|
182
182
|
// reads as "that agent does not exist", so it is named like the other rejections here.
|
|
183
|
-
console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${
|
|
183
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${errorMessage(error)})`)
|
|
184
184
|
return null
|
|
185
185
|
}
|
|
186
186
|
const { frontmatter, body } = parsed
|
|
@@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
11
11
|
import * as fs from 'node:fs'
|
|
12
12
|
import * as os from 'node:os'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
|
+
import { errorMessage } from '../internal/values.js'
|
|
14
15
|
|
|
15
16
|
export interface BackgroundRun {
|
|
16
17
|
id: string
|
|
@@ -245,7 +246,7 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: s
|
|
|
245
246
|
// prompt text, which would replace the agent persona with a temp path. The child then
|
|
246
247
|
// runs as a plain assistant instead of the agent asked for, and nothing in its output
|
|
247
248
|
// says so, hence the notice.
|
|
248
|
-
console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${
|
|
249
|
+
console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${errorMessage(error)}`)
|
|
249
250
|
return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
|
|
250
251
|
}
|
|
251
252
|
}
|