pi-code 1.0.5 → 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.
@@ -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
@@ -24,7 +26,7 @@ import * as os from 'node:os'
24
26
  import * as path from 'node:path'
25
27
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
26
28
 
27
- import { hookFiles, runHookCommand } from './hooks.js'
29
+ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
28
30
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
29
31
  import { isProjectApprovedSilently } from './internal/project-approval.js'
30
32
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
@@ -250,7 +252,7 @@ export default function statusLine(pi: ExtensionAPI) {
250
252
  })
251
253
  // The last message's token usage, for the breakdown getContextUsage() omits.
252
254
  pi.on('message_end', async (event) => {
253
- const usage = (event as { message?: { usage?: typeof lastUsage } }).message?.usage
255
+ const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
254
256
  if (usage) lastUsage = usage
255
257
  })
256
258
 
@@ -271,7 +273,10 @@ export default function statusLine(pi: ExtensionAPI) {
271
273
  // the keys meant for it. An undecided project simply skips project settings.
272
274
  const trusted = isProjectApprovedSilently(ctx)
273
275
  projectApproved = trusted
274
- config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
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)
275
280
  if (config?.refreshInterval) {
276
281
  refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
277
282
  }
@@ -30,8 +30,8 @@ import { repoRoot } from '../internal/project-root.js'
30
30
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
31
31
  import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
32
32
  import { skillDirs } from '../skills.js'
33
- import { type AgentConfig, type AgentMemoryScope, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
34
- import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
33
+ import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
34
+ import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
35
35
 
36
36
  const MAX_PARALLEL_TASKS = 8
37
37
  const MAX_CONCURRENCY = 4
@@ -557,6 +557,67 @@ export function cancelResultText(id: string): string {
557
557
  return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
558
558
  }
559
559
 
560
+ /** The registry fields the /tasks listing prints. */
561
+ type BackgroundRunView = Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'output' | 'stderr'>
562
+
563
+ const TASK_PREVIEW_CHARS = 60
564
+ const TAIL_PREVIEW_CHARS = 200
565
+
566
+ /** Truncate to at most `max` codepoints, iterating by codepoint so a multi-byte
567
+ * character on the boundary is never cut into a lone surrogate. Returns the whole
568
+ * string when it already fits, so a caller can tell it did not clip. */
569
+ function clipCodepoints(text: string, max: number): string {
570
+ const points = Array.from(text)
571
+ return points.length > max ? points.slice(0, max).join('') : text
572
+ }
573
+
574
+ /** A one-line tail of what a run last said: the stderr tail for a failure (the only
575
+ * diagnostics a boot failure leaves), the latest assistant text otherwise. */
576
+ function runOutputTail(run: BackgroundRunView): string | undefined {
577
+ const stderrTail = run.state === 'failed' ? run.stderr?.trim() : undefined
578
+ const raw = (stderrTail || run.output)?.trim()
579
+ if (!raw) return undefined
580
+ const last = raw.split('\n').at(-1)?.trim() ?? ''
581
+ const shortened = clipCodepoints(last, TAIL_PREVIEW_CHARS)
582
+ const clipped = shortened === last ? last : `${shortened}...`
583
+ return stderrTail ? `stderr: ${clipped}` : clipped
584
+ }
585
+
586
+ /** The /tasks listing: one line per background run, plus the short output tail the
587
+ * registry's own status lines omit. A pure formatter so it tests against a plain list. */
588
+ export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
589
+ if (runs.length === 0) return 'No background subagent runs in this session.'
590
+ return runs
591
+ .map((run) => {
592
+ const plural = run.turns === 1 ? '' : 's'
593
+ const label = run.state === 'running' ? 'running' : `${run.state} (${run.turns} turn${plural})`
594
+ const head = `${run.id} ${run.agent}: ${label} - ${clipCodepoints(run.task, TASK_PREVIEW_CHARS)}`
595
+ const tail = runOutputTail(run)
596
+ return tail ? `${head}\n ${tail}` : head
597
+ })
598
+ .join('\n')
599
+ }
600
+
601
+ /** Listing order for /agents: lowest to highest precedence, matching how discovery
602
+ * lets a later source win a name clash. */
603
+ const AGENT_SOURCE_ORDER: ReadonlyArray<AgentSource> = ['builtin', 'plugin', 'user', 'project']
604
+
605
+ const AGENTS_DIR_HINT = 'Add agents as markdown files under ~/.claude/agents (user) or .claude/agents (project).'
606
+
607
+ /** The /agents listing: the discovered roster grouped by source, with file paths.
608
+ * A pure formatter so it tests against a sample roster. */
609
+ export function agentsListText(agents: ReadonlyArray<Pick<AgentConfig, 'name' | 'source' | 'filePath'>>): string {
610
+ if (agents.length === 0) return `No agents discovered.\n${AGENTS_DIR_HINT}`
611
+ const sections: string[] = []
612
+ for (const source of AGENT_SOURCE_ORDER) {
613
+ const group = agents.filter((agent) => agent.source === source)
614
+ if (group.length === 0) continue
615
+ const lines = group.map((agent) => ` ${agent.name} - ${agent.filePath}`).join('\n')
616
+ sections.push(`${source}:\n${lines}`)
617
+ }
618
+ return `${sections.join('\n')}\n\n${AGENTS_DIR_HINT}`
619
+ }
620
+
560
621
  /** Everything a mode handler needs from the surrounding execute() call. */
561
622
  interface ModeContext {
562
623
  agents: AgentConfig[]
@@ -729,7 +790,20 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
729
790
  }
730
791
  }
731
792
 
732
- async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>, projectApproved: boolean): Promise<ToolResult> {
793
+ /** Everything runBackgroundMode needs from the surrounding execute() call, grouped so
794
+ * the parameter list stays in bounds. */
795
+ interface BackgroundContext {
796
+ agents: AgentConfig[]
797
+ defaultCwd: string
798
+ pi: ExtensionAPI
799
+ makeDetails: MakeDetails
800
+ skillRoots: string[]
801
+ availableModels: ReadonlyArray<{ id: string }>
802
+ projectApproved: boolean
803
+ }
804
+
805
+ async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext, onStarted?: (id: string) => void): Promise<ToolResult> {
806
+ const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
733
807
  const task = params.task
734
808
  const agentName = params.agent
735
809
  if (!task || !agentName) {
@@ -775,6 +849,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
775
849
  removeTmpPrompt(tmpPrompt)
776
850
  return backgroundCapResult(makeDetails)
777
851
  }
852
+ onStarted?.(id)
778
853
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
779
854
  return {
780
855
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
@@ -1249,6 +1324,21 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1249
1324
  }
1250
1325
 
1251
1326
  export default function subagentExtension(pi: ExtensionAPI) {
1327
+ // /tasks resolves these against the registry at print time. background.ts owns the
1328
+ // run records but does not enumerate them, so the ids started here are remembered;
1329
+ // a run the registry has since evicted simply drops out of the listing.
1330
+ const startedBackgroundRuns = new Set<string>()
1331
+
1332
+ // The registry self-caps and evicts old runs, so an id kept here after its record is
1333
+ // gone is dead weight. Drop those on every add, bounding the set to the registry's
1334
+ // live capacity rather than letting it grow for the whole session.
1335
+ const rememberBackgroundRun = (id: string): void => {
1336
+ for (const known of startedBackgroundRuns) {
1337
+ if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
1338
+ }
1339
+ startedBackgroundRuns.add(id)
1340
+ }
1341
+
1252
1342
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1253
1343
  // Runs through driveRun's guard, same as the background-mode callback above.
1254
1344
  // The stop event fires here too, so SubagentStop hooks see resumed runs end.
@@ -1344,7 +1434,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
1344
1434
  })
1345
1435
 
1346
1436
  if (params.resume) {
1347
- return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, (run) => pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })) }], details: makeDetails('single')([]) }
1437
+ const onResumed = (run: { id: string; agent: string }): void => {
1438
+ rememberBackgroundRun(run.id)
1439
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
1440
+ }
1441
+ return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
1348
1442
  }
1349
1443
 
1350
1444
  if (params.cancel) {
@@ -1385,7 +1479,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
1385
1479
  // unavailable tier still falls back to the session model.
1386
1480
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1387
1481
 
1388
- if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved)
1482
+ if (params.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1389
1483
 
1390
1484
  const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, projectApproved, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1391
1485
 
@@ -1426,4 +1520,24 @@ export default function subagentExtension(pi: ExtensionAPI) {
1426
1520
  return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
1427
1521
  },
1428
1522
  })
1523
+
1524
+ // Claude's /tasks: background-run status at a glance, returning immediately without
1525
+ // interrupting the agent; the only other way to see these is to ask the model.
1526
+ pi.registerCommand('tasks', {
1527
+ description: 'Show background subagent runs',
1528
+ handler: async (_args, ctx) => {
1529
+ const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
1530
+ ctx.ui.notify(tasksStatusText(runs), 'info')
1531
+ },
1532
+ })
1533
+
1534
+ // Claude's /agents: the discovered roster with sources and paths. Approval is read
1535
+ // silently, like the roster above: project agents list only once the project is trusted.
1536
+ pi.registerCommand('agents', {
1537
+ description: 'List discovered subagents and where they come from',
1538
+ handler: async (_args, ctx) => {
1539
+ const { agents } = discoverAgents(ctx.cwd, isProjectApprovedSilently(ctx) ? 'both' : 'user')
1540
+ ctx.ui.notify(agentsListText(agents), 'info')
1541
+ },
1542
+ })
1429
1543
  }
package/extensions/web.ts CHANGED
@@ -233,6 +233,40 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
233
233
  const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
234
234
  const FETCH_CACHE_MAX_ENTRIES = 50
235
235
 
236
+ type FetchCache = Map<string, { expires: number; body: string }>
237
+
238
+ /** Store a freshly fetched body. Drop expired entries first, then evict the oldest
239
+ * live one if still full; deleting before set keeps Map insertion order a true
240
+ * recency order, so a refreshed URL moves to the newest slot instead of keeping its
241
+ * stale one. Only a delivered body reaches here, so a thrown fetch retries next call. */
242
+ function rememberFetch(cache: FetchCache, url: string, body: string, now: number): void {
243
+ cache.delete(url)
244
+ for (const [key, entry] of cache) {
245
+ if (entry.expires <= now) cache.delete(key)
246
+ }
247
+ if (cache.size >= FETCH_CACHE_MAX_ENTRIES) {
248
+ const oldest = cache.keys().next().value
249
+ if (oldest !== undefined) cache.delete(oldest)
250
+ }
251
+ cache.set(url, { expires: now + FETCH_CACHE_TTL_MS, body })
252
+ }
253
+
254
+ /** Claude's WebFetch runs the prompt over the page with a fast model and returns
255
+ * that answer, not the raw page. Best-effort: any failure (no model, provider error)
256
+ * yields null so the caller falls back to the markdown. */
257
+ async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<string | null> {
258
+ try {
259
+ const answer = await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
260
+ system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
261
+ maxTokens: 1024,
262
+ signal,
263
+ })
264
+ return answer || null
265
+ } catch {
266
+ return null
267
+ }
268
+ }
269
+
236
270
  export default function webExtension(pi: ExtensionAPI) {
237
271
  const fetchCache = new Map<string, { expires: number; body: string }>()
238
272
  pi.registerTool({
@@ -280,35 +314,14 @@ export default function webExtension(pi: ExtensionAPI) {
280
314
  } else {
281
315
  const { text, contentType } = await fetchText(params.url)
282
316
  body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
283
- // Only a delivered body is cached; a thrown fetch must retry next call.
284
- // Drop expired entries first, then evict the oldest live one if still full;
285
- // deleting before set keeps Map insertion order a true recency order, so a
286
- // refreshed URL moves to the newest slot instead of keeping its stale one.
287
- fetchCache.delete(params.url)
288
- for (const [url, entry] of fetchCache) {
289
- if (entry.expires <= now) fetchCache.delete(url)
290
- }
291
- if (fetchCache.size >= FETCH_CACHE_MAX_ENTRIES) {
292
- const oldest = fetchCache.keys().next().value
293
- if (oldest !== undefined) fetchCache.delete(oldest)
294
- }
295
- fetchCache.set(params.url, { expires: now + FETCH_CACHE_TTL_MS, body })
317
+ rememberFetch(fetchCache, params.url, body, now)
296
318
  }
297
319
 
298
- // Claude's WebFetch runs the prompt over the page with a fast model and returns
299
- // that answer, not the raw page. Best-effort: any failure (no model, provider
300
- // error) falls back to the markdown, so web_fetch always returns something.
320
+ // Best-effort prompt-over-page: a failure returns null, so web_fetch always
321
+ // falls back to the raw markdown and returns something.
301
322
  if (params.prompt && ctx?.model) {
302
- try {
303
- const answer = await completeText(ctx.model, `${params.prompt}\n\nAnswer using only the page content below, fetched from ${params.url}:\n\n${body}`, {
304
- system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
305
- maxTokens: 1024,
306
- signal,
307
- })
308
- if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
309
- } catch {
310
- // fall through to the raw markdown
311
- }
323
+ const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
324
+ if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
312
325
  }
313
326
  // The char cap alone admits thousands of short lines; pi's tool-output budget
314
327
  // bounds lines too, which the shared guard enforces.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",