pi-code 1.0.9 → 1.0.11
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/extensions/claude-rules.ts +2 -1
- package/extensions/commands.ts +35 -4
- package/extensions/context-imports.ts +196 -38
- package/extensions/context-usage.ts +45 -0
- package/extensions/env-settings.ts +130 -0
- package/extensions/hooks.ts +92 -11
- package/extensions/internal/command-file.ts +27 -2
- package/extensions/internal/config-dir.ts +24 -0
- package/extensions/internal/plugins.ts +5 -3
- package/extensions/mcp.ts +146 -24
- package/extensions/memory.ts +86 -2
- package/extensions/notify.ts +3 -1
- package/extensions/output-styles.ts +10 -2
- package/extensions/session-title.ts +126 -0
- package/extensions/skills.ts +2 -1
- package/extensions/status-line.ts +94 -1
- package/extensions/subagent/agents.ts +2 -1
- package/extensions/subagent/index.ts +2 -1
- package/extensions/thinking.ts +80 -0
- package/package.json +1 -1
package/extensions/memory.ts
CHANGED
|
@@ -14,11 +14,12 @@ import * as path from 'node:path'
|
|
|
14
14
|
import { StringEnum } from '@earendil-works/pi-ai'
|
|
15
15
|
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
17
18
|
import { capForContext } from './internal/output-guard.js'
|
|
18
19
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
19
20
|
import { findNearestFile, repoRoot } from './internal/project-root.js'
|
|
20
21
|
|
|
21
|
-
const INDEX_FILE = 'MEMORY.md'
|
|
22
|
+
export const INDEX_FILE = 'MEMORY.md'
|
|
22
23
|
|
|
23
24
|
/** Claude loads the first 200 lines or 25KB of the memory index at startup. */
|
|
24
25
|
export const INDEX_MAX_LINES = 200
|
|
@@ -282,7 +283,7 @@ function writeIndex(indexPath: string, content: string): void {
|
|
|
282
283
|
* approved, since a project's `autoMemoryDirectory` is honored under the same trust
|
|
283
284
|
* rule as hooks in settings files. Later files win. */
|
|
284
285
|
export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
285
|
-
const files = [path.join(home, '
|
|
286
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
286
287
|
if (!approved) return files
|
|
287
288
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
288
289
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -306,6 +307,40 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
|
|
|
306
307
|
return merged
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
/** Write `autoMemoryEnabled` into the user settings file, preserving every other key
|
|
311
|
+
* and creating the file and its config directory when absent. Claude's /memory toggle
|
|
312
|
+
* writes to the user scope (relocated by CLAUDE_CONFIG_DIR); the value takes effect from
|
|
313
|
+
* the next session start, which is where autoMemoryEnabled is read.
|
|
314
|
+
*
|
|
315
|
+
* An absent file starts from an empty object so the toggle still lands. A file that is
|
|
316
|
+
* PRESENT but unparseable is refused, not overwritten: clobbering it would destroy the
|
|
317
|
+
* user's hooks, env and permissions config. The caller surfaces the returned failure. */
|
|
318
|
+
export function setAutoMemoryEnabledSetting(home: string, value: boolean): { ok: true } | { ok: false; error: string } {
|
|
319
|
+
const dir = claudeConfigDir(home)
|
|
320
|
+
const file = path.join(dir, 'settings.json')
|
|
321
|
+
let current: Record<string, unknown> = {}
|
|
322
|
+
let raw: string | undefined
|
|
323
|
+
try {
|
|
324
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
325
|
+
} catch (error) {
|
|
326
|
+
// Only a missing file means start fresh; any other read failure propagates.
|
|
327
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
328
|
+
}
|
|
329
|
+
if (raw !== undefined) {
|
|
330
|
+
try {
|
|
331
|
+
const parsed = JSON.parse(raw)
|
|
332
|
+
if (parsed !== null && typeof parsed === 'object') current = parsed as Record<string, unknown>
|
|
333
|
+
} catch {
|
|
334
|
+
// Present but unparseable: refuse rather than overwrite the user's config.
|
|
335
|
+
return { ok: false, error: 'settings.json is not valid JSON; not modified' }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
current.autoMemoryEnabled = value
|
|
339
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
340
|
+
fs.writeFileSync(file, `${JSON.stringify(current, null, 2)}\n`)
|
|
341
|
+
return { ok: true }
|
|
342
|
+
}
|
|
343
|
+
|
|
309
344
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
310
345
|
let dir = memoryDir(process.cwd())
|
|
311
346
|
let enabled = true
|
|
@@ -409,4 +444,53 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
409
444
|
return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
|
|
410
445
|
},
|
|
411
446
|
})
|
|
447
|
+
|
|
448
|
+
// Claude's /memory lists the memory locations and toggles auto memory. pi has no
|
|
449
|
+
// editor seam, so the paths are printed rather than opened. The listing reads the
|
|
450
|
+
// settings chain live so it reflects a toggle written in the same session.
|
|
451
|
+
pi.registerCommand('memory', {
|
|
452
|
+
description: 'Show memory file locations and toggle auto memory (/memory [on|off])',
|
|
453
|
+
handler: async (args, ctx) => {
|
|
454
|
+
const home = os.homedir()
|
|
455
|
+
const arg = args.trim().toLowerCase()
|
|
456
|
+
|
|
457
|
+
if (arg === 'on' || arg === 'off') {
|
|
458
|
+
const next = arg === 'on'
|
|
459
|
+
let result: { ok: true } | { ok: false; error: string }
|
|
460
|
+
try {
|
|
461
|
+
result = setAutoMemoryEnabledSetting(home, next)
|
|
462
|
+
} catch (error) {
|
|
463
|
+
ctx.ui.notify(`Could not update auto memory: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
if (!result.ok) {
|
|
467
|
+
ctx.ui.notify(result.error, 'error')
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
ctx.ui.notify(`Auto memory ${next ? 'enabled' : 'disabled'} in ${path.join(claudeConfigDir(home), 'settings.json')} (applies next session).`, 'info')
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (arg.length > 0) {
|
|
475
|
+
ctx.ui.notify('Usage: /memory [on|off]', 'error')
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
480
|
+
const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, home, approved))
|
|
481
|
+
const isEnabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
|
|
482
|
+
const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
|
|
483
|
+
const store = resolveMemoryDir(ctx.cwd, override)
|
|
484
|
+
const lines = [
|
|
485
|
+
'Memory',
|
|
486
|
+
` Auto memory: ${isEnabled ? 'on' : 'off'}`,
|
|
487
|
+
` Store: ${store}`,
|
|
488
|
+
` Index: ${path.join(store, INDEX_FILE)}`,
|
|
489
|
+
` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
|
|
490
|
+
` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
|
|
491
|
+
'Toggle with /memory on or /memory off.',
|
|
492
|
+
]
|
|
493
|
+
ctx.ui.notify(lines.join('\n'), 'info')
|
|
494
|
+
},
|
|
495
|
+
})
|
|
412
496
|
}
|
package/extensions/notify.ts
CHANGED
|
@@ -21,6 +21,8 @@ import * as os from 'node:os'
|
|
|
21
21
|
import * as path from 'node:path'
|
|
22
22
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
23
23
|
|
|
24
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
25
|
+
|
|
24
26
|
/** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
|
|
25
27
|
export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
|
|
26
28
|
|
|
@@ -55,7 +57,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
|
|
|
55
57
|
* or change your notifications. */
|
|
56
58
|
function readPreferredNotifChannel(home: string): unknown {
|
|
57
59
|
try {
|
|
58
|
-
const settings = JSON.parse(fs.readFileSync(path.join(home, '
|
|
60
|
+
const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
|
|
59
61
|
return settings?.preferredNotifChannel
|
|
60
62
|
} catch {
|
|
61
63
|
return undefined
|
|
@@ -24,6 +24,7 @@ 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
|
|
|
27
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
27
28
|
import { installedPlugins } from './internal/plugins.js'
|
|
28
29
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
29
30
|
import { findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
@@ -82,7 +83,7 @@ function isDirectory(target: string): boolean {
|
|
|
82
83
|
* verbatim into the system prompt.
|
|
83
84
|
*/
|
|
84
85
|
export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
85
|
-
const dirs = [path.join(home, '
|
|
86
|
+
const dirs = [path.join(claudeConfigDir(home), 'output-styles')]
|
|
86
87
|
if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
|
|
87
88
|
return dirs.filter((dir) => isDirectory(dir))
|
|
88
89
|
}
|
|
@@ -129,7 +130,7 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
|
|
|
129
130
|
/** Settings files that carry `outputStyle`. Project settings apply only when trusted,
|
|
130
131
|
* each the nearest of its name at or above cwd, as the hooks settings chain reads. */
|
|
131
132
|
export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
132
|
-
const files = [path.join(home, '
|
|
133
|
+
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
133
134
|
if (!trusted) return files
|
|
134
135
|
for (const name of ['settings.json', 'settings.local.json']) {
|
|
135
136
|
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
@@ -199,6 +200,13 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
199
200
|
|
|
200
201
|
pi.registerCommand('output-style', {
|
|
201
202
|
description: 'Choose the active Claude output style (or /output-style <name>)',
|
|
203
|
+
// /output-style <name> takes a style name, so complete the discovered names by the
|
|
204
|
+
// typed prefix (case-insensitive, like the handler's own name lookup). An empty
|
|
205
|
+
// prefix offers every style.
|
|
206
|
+
getArgumentCompletions: (argumentPrefix) => {
|
|
207
|
+
const prefix = argumentPrefix.trim().toLowerCase()
|
|
208
|
+
return styles.filter((style) => style.name.toLowerCase().startsWith(prefix)).map((style) => ({ value: style.name, label: style.name, ...(style.description ? { description: style.description } : {}) }))
|
|
209
|
+
},
|
|
202
210
|
handler: async (args, ctx) => {
|
|
203
211
|
const requested = args.trim()
|
|
204
212
|
if (requested) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Auto-Title Extension
|
|
3
|
+
*
|
|
4
|
+
* Claude auto-names a new conversation from its first message; this does the same for
|
|
5
|
+
* pi. After the first run of an unnamed session settles, it asks the current model for
|
|
6
|
+
* a short title based on the first user message and applies it two ways: setSessionName
|
|
7
|
+
* (the name shown in the session selector) and the terminal window/tab title.
|
|
8
|
+
*
|
|
9
|
+
* It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
|
|
10
|
+
* headless run that persists its session still benefits from a readable name later. The
|
|
11
|
+
* window-title update is the only terminal-specific part, so it is optional-called rather
|
|
12
|
+
* than gated on hasUI. Titling is best-effort throughout: a session that already has a
|
|
13
|
+
* name, a run with no user text (a slash-command-only turn), a headless run with no model,
|
|
14
|
+
* or any provider error leaves the session untitled and never throws.
|
|
15
|
+
*
|
|
16
|
+
* Cost: one model call per session at most. The guard is claimed before the completion so
|
|
17
|
+
* repeated settles cannot each fire a call, and a failed attempt is not retried until a
|
|
18
|
+
* new session resets the guard.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
22
|
+
|
|
23
|
+
import { completeText } from './internal/model-complete.js'
|
|
24
|
+
|
|
25
|
+
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
|
+
/** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
|
|
27
|
+
const TITLE_MAX_TOKENS = 24
|
|
28
|
+
/** The first message can be huge; only its opening is needed to name the session, and a
|
|
29
|
+
* bounded prompt keeps the input cost of the extra call small. */
|
|
30
|
+
const MAX_PROMPT_CHARS = 1000
|
|
31
|
+
|
|
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
|
+
/** Text of the first user message in the branch, or empty when the run carried no user text
|
|
44
|
+
* (for example a slash-command-only turn), in which case there is nothing to title from. */
|
|
45
|
+
export function firstUserText(ctx: ExtensionContext): string {
|
|
46
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
47
|
+
if (entry?.type === 'message' && entry.message.role === 'user') {
|
|
48
|
+
return extractText(entry.message.content).trim()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Wrapping quotes (straight, smart, and backtick) and trailing sentence punctuation that
|
|
55
|
+
* cleanTitle peels, held as plain strings so the trims below can test membership by index
|
|
56
|
+
* rather than with an anchored regex. */
|
|
57
|
+
const WRAPPING_QUOTES = '"\'`“”‘’'
|
|
58
|
+
const TRAILING_PUNCTUATION = '.,;:!?'
|
|
59
|
+
|
|
60
|
+
/** Strip runs of `chars` from both ends of `value` in linear time. The equivalent
|
|
61
|
+
* /^[chars]+|[chars]+$/g backtracks super-linearly on a long run (S8786). */
|
|
62
|
+
function trimBothEnds(value: string, chars: string): string {
|
|
63
|
+
let start = 0
|
|
64
|
+
let end = value.length
|
|
65
|
+
while (start < end && chars.includes(value[start])) start++
|
|
66
|
+
while (end > start && chars.includes(value[end - 1])) end--
|
|
67
|
+
return value.slice(start, end)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Strip a trailing run of `chars` from `value` in linear time. The equivalent
|
|
71
|
+
* /[chars]+$/g backtracks super-linearly on a long trailing run (S8786). */
|
|
72
|
+
function trimTrailing(value: string, chars: string): string {
|
|
73
|
+
let end = value.length
|
|
74
|
+
while (end > 0 && chars.includes(value[end - 1])) end--
|
|
75
|
+
return value.slice(0, end)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Trim the model's reply to a bare title: collapse whitespace, then peel wrapping quotes
|
|
79
|
+
* and trailing punctuation until stable, so `"Fix The Parser."` and `Fix The Parser.` both
|
|
80
|
+
* land on the plain phrase. */
|
|
81
|
+
export function cleanTitle(raw: string): string {
|
|
82
|
+
let title = raw.trim().replace(/\s+/g, ' ')
|
|
83
|
+
let prev: string
|
|
84
|
+
do {
|
|
85
|
+
prev = title
|
|
86
|
+
title = trimTrailing(trimBothEnds(title, WRAPPING_QUOTES), TRAILING_PUNCTUATION).trim()
|
|
87
|
+
} while (title !== prev)
|
|
88
|
+
return title
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default function sessionTitleExtension(pi: ExtensionAPI) {
|
|
92
|
+
// One title per session, reset when a new session takes over so a resumed or forked
|
|
93
|
+
// session can still earn its own name.
|
|
94
|
+
let titled = false
|
|
95
|
+
|
|
96
|
+
pi.on('session_start', () => {
|
|
97
|
+
titled = false
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
101
|
+
if (titled) return
|
|
102
|
+
// Never clobber an existing name: a user-chosen or resumed name wins.
|
|
103
|
+
if (pi.getSessionName?.()) return
|
|
104
|
+
const model = ctx.model
|
|
105
|
+
if (!model) return // headless with no model: nothing to name with, and the guard is left unspent
|
|
106
|
+
const prompt = firstUserText(ctx)
|
|
107
|
+
if (!prompt) return // no user text this run: leave the guard unspent for a later real message
|
|
108
|
+
|
|
109
|
+
// Claim the single attempt before the await, so overlapping or repeated settles cannot
|
|
110
|
+
// each fire a model call; a failed attempt below is not retried within this session.
|
|
111
|
+
titled = true
|
|
112
|
+
let title: string
|
|
113
|
+
try {
|
|
114
|
+
const { text } = await completeText(model, `First user message of a new coding session:\n\n${prompt.slice(0, MAX_PROMPT_CHARS)}`, {
|
|
115
|
+
system: TITLE_SYSTEM,
|
|
116
|
+
maxTokens: TITLE_MAX_TOKENS,
|
|
117
|
+
})
|
|
118
|
+
title = cleanTitle(text)
|
|
119
|
+
} catch {
|
|
120
|
+
return // no model, provider error: leave the session untitled (best-effort)
|
|
121
|
+
}
|
|
122
|
+
if (!title) return
|
|
123
|
+
pi.setSessionName(title)
|
|
124
|
+
ctx.ui.setTitle?.(title)
|
|
125
|
+
})
|
|
126
|
+
}
|
package/extensions/skills.ts
CHANGED
|
@@ -15,6 +15,7 @@ import * as os from 'node:os'
|
|
|
15
15
|
import * as path from 'node:path'
|
|
16
16
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
17
17
|
|
|
18
|
+
import { claudeConfigDir } from './internal/config-dir.js'
|
|
18
19
|
import { installedPlugins } from './internal/plugins.js'
|
|
19
20
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
20
21
|
import { findNearestDir } from './internal/project-root.js'
|
|
@@ -33,7 +34,7 @@ function isDirectory(target: string): boolean {
|
|
|
33
34
|
* name and description to the model, so an untrusted repository would otherwise get
|
|
34
35
|
* text into the prompt without the user ever agreeing to load its config. */
|
|
35
36
|
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
36
|
-
const candidates = [path.join(home, '
|
|
37
|
+
const candidates = [path.join(claudeConfigDir(home), 'skills')]
|
|
37
38
|
// Enabled plugins contribute their skills directories. pi's loader names a
|
|
38
39
|
// skill by its directory, so a plugin skill registers without Claude's
|
|
39
40
|
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|
|
@@ -64,6 +64,66 @@ function formatCost(cost: number): string {
|
|
|
64
64
|
return cost >= 0.01 ? `$${cost.toFixed(2)}` : `$${cost.toFixed(4)}`
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface RateLimitWindow {
|
|
68
|
+
used_percentage: number
|
|
69
|
+
resets_at?: string
|
|
70
|
+
}
|
|
71
|
+
interface RateLimitSnapshot {
|
|
72
|
+
five_hour?: RateLimitWindow
|
|
73
|
+
seven_day?: RateLimitWindow
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Lowercase every header name (HTTP names are case-insensitive) and drop null
|
|
77
|
+
* values, so a single lookup shape works regardless of how the provider cased
|
|
78
|
+
* them. ProviderHeaders values are string | null. */
|
|
79
|
+
function normalizeHeaders(raw: Record<string, string | null> | undefined): Record<string, string> {
|
|
80
|
+
const out: Record<string, string> = {}
|
|
81
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
82
|
+
if (typeof value === 'string') out[key.toLowerCase()] = value
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toNumber(value: string | undefined): number | undefined {
|
|
88
|
+
if (value === undefined || value.trim() === '') return undefined
|
|
89
|
+
const parsed = Number(value)
|
|
90
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One rate-limit window from the `anthropic-ratelimit-<prefix>-*` header family,
|
|
94
|
+
* taking a direct `-utilization` percentage when present, else computing it from
|
|
95
|
+
* `-limit` and `-remaining`. resets_at comes from `-reset` when the header is set.
|
|
96
|
+
* Header names vary, so only what is present is read and a bare number is enough. */
|
|
97
|
+
function readRateLimitWindow(headers: Record<string, string>, prefix: string): RateLimitWindow | undefined {
|
|
98
|
+
const base = `anthropic-ratelimit-${prefix}`
|
|
99
|
+
let usedPercentage = toNumber(headers[`${base}-utilization`])
|
|
100
|
+
if (usedPercentage === undefined) {
|
|
101
|
+
const limit = toNumber(headers[`${base}-limit`])
|
|
102
|
+
const remaining = toNumber(headers[`${base}-remaining`])
|
|
103
|
+
if (limit !== undefined && limit > 0 && remaining !== undefined) {
|
|
104
|
+
usedPercentage = ((limit - remaining) / limit) * 100
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (usedPercentage === undefined) return undefined
|
|
108
|
+
const window: RateLimitWindow = { used_percentage: usedPercentage }
|
|
109
|
+
const resetsAt = headers[`${base}-reset`] ?? headers[`${base}-resets-at`]
|
|
110
|
+
if (resetsAt) window.resets_at = resetsAt
|
|
111
|
+
return window
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The five-hour and seven-day utilization windows Claude's statusline reports,
|
|
115
|
+
* from the unified rate-limit response headers. Undefined when neither is present
|
|
116
|
+
* so a response without them never clobbers an earlier snapshot. */
|
|
117
|
+
function parseRateLimits(headers: Record<string, string>): RateLimitSnapshot | undefined {
|
|
118
|
+
const fiveHour = readRateLimitWindow(headers, 'unified-5h')
|
|
119
|
+
const sevenDay = readRateLimitWindow(headers, 'unified-7d')
|
|
120
|
+
if (!fiveHour && !sevenDay) return undefined
|
|
121
|
+
const snapshot: RateLimitSnapshot = {}
|
|
122
|
+
if (fiveHour) snapshot.five_hour = fiveHour
|
|
123
|
+
if (sevenDay) snapshot.seven_day = sevenDay
|
|
124
|
+
return snapshot
|
|
125
|
+
}
|
|
126
|
+
|
|
67
127
|
export interface StatusLineConfig {
|
|
68
128
|
command: string
|
|
69
129
|
padding: number
|
|
@@ -120,6 +180,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
120
180
|
let apiDurationMs = 0
|
|
121
181
|
let requestStartMs: number | undefined
|
|
122
182
|
let lastUsage: { input: number; output: number; cacheRead: number; cacheWrite: number; totalTokens: number } | undefined
|
|
183
|
+
// The most recent rate-limit snapshot parsed from provider response headers, and
|
|
184
|
+
// a once-per-session guard so a 429 warns the user only the first time it lands.
|
|
185
|
+
let rateLimits: RateLimitSnapshot | undefined
|
|
186
|
+
let rateLimitWarned = false
|
|
123
187
|
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
|
124
188
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
125
189
|
let running = false
|
|
@@ -193,6 +257,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
193
257
|
payload.thinking = { enabled: ctx.thinkingLevel !== 'off' }
|
|
194
258
|
}
|
|
195
259
|
if (styleName) payload.output_style = { name: styleName }
|
|
260
|
+
// The current utilization of the account's rate-limit windows, when the
|
|
261
|
+
// provider reported them; omitted entirely until a response has carried them.
|
|
262
|
+
if (rateLimits) payload.rate_limits = rateLimits
|
|
196
263
|
return payload
|
|
197
264
|
}
|
|
198
265
|
|
|
@@ -260,9 +327,24 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
260
327
|
pi.on('before_provider_request', async () => {
|
|
261
328
|
requestStartMs = Date.now()
|
|
262
329
|
})
|
|
263
|
-
pi.on('after_provider_response', async () => {
|
|
330
|
+
pi.on('after_provider_response', async (event, ctx) => {
|
|
264
331
|
if (requestStartMs !== undefined) apiDurationMs += Date.now() - requestStartMs
|
|
265
332
|
requestStartMs = undefined
|
|
333
|
+
// Rate-limit windows and 429 handling ride on the same response event. Header
|
|
334
|
+
// names and presence vary, so parse only what is there and never throw.
|
|
335
|
+
const headers = normalizeHeaders(event.headers)
|
|
336
|
+
const snapshot = parseRateLimits(headers)
|
|
337
|
+
if (snapshot) rateLimits = snapshot
|
|
338
|
+
if (event.status === 429 && !rateLimitWarned) {
|
|
339
|
+
rateLimitWarned = true
|
|
340
|
+
const retryAfter = headers['retry-after']
|
|
341
|
+
const detail = retryAfter ? `; retry after ${retryAfter}s` : ''
|
|
342
|
+
try {
|
|
343
|
+
ctx.ui.notify(`Provider rate limit reached (429)${detail}`, 'warning')
|
|
344
|
+
} catch {
|
|
345
|
+
// The session may be gone by the time a late response lands; nothing to warn.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
266
348
|
})
|
|
267
349
|
// The last message's token usage, for the breakdown getContextUsage() omits,
|
|
268
350
|
// and the running cost total, so renders never re-walk the branch.
|
|
@@ -284,6 +366,8 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
284
366
|
apiDurationMs = 0
|
|
285
367
|
requestStartMs = undefined
|
|
286
368
|
lastUsage = undefined
|
|
369
|
+
rateLimits = undefined
|
|
370
|
+
rateLimitWarned = false
|
|
287
371
|
clearInterval(refreshTimer)
|
|
288
372
|
// Seed the running cost from the branch: a resumed or forked session starts
|
|
289
373
|
// with history, and message_end only accumulates from here on.
|
|
@@ -327,6 +411,15 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
327
411
|
scheduleRefresh()
|
|
328
412
|
})
|
|
329
413
|
|
|
414
|
+
// The model and effort segments of the payload go stale between turns; a switch
|
|
415
|
+
// fires these events, so refresh at once instead of waiting for the next tick.
|
|
416
|
+
pi.on('model_select', async () => {
|
|
417
|
+
scheduleRefresh()
|
|
418
|
+
})
|
|
419
|
+
pi.on('thinking_level_select', async () => {
|
|
420
|
+
scheduleRefresh()
|
|
421
|
+
})
|
|
422
|
+
|
|
330
423
|
pi.on('session_compact', async (_event, ctx) => {
|
|
331
424
|
// Compaction replaces the branch entries; reseed the total from what remains.
|
|
332
425
|
costTotal = sessionCost(ctx)
|
|
@@ -11,6 +11,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
|
|
|
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
13
|
import { parseToolGrants } from '../internal/command-file.js'
|
|
14
|
+
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
14
15
|
import { installedPlugins } from '../internal/plugins.js'
|
|
15
16
|
import { findNearestDir } from '../internal/project-root.js'
|
|
16
17
|
|
|
@@ -291,7 +292,7 @@ function pluginAgentDirs(home: string): string[] {
|
|
|
291
292
|
|
|
292
293
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
293
294
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
294
|
-
const claudeUserDir = path.join(os.homedir(), '
|
|
295
|
+
const claudeUserDir = path.join(claudeConfigDir(os.homedir()), 'agents')
|
|
295
296
|
const projectPiDir = findNearestDir(cwd, path.join('.pi', 'agents'))
|
|
296
297
|
const projectClaudeDir = findNearestDir(cwd, path.join('.claude', 'agents'))
|
|
297
298
|
|
|
@@ -24,6 +24,7 @@ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme,
|
|
|
24
24
|
import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
25
25
|
import { type Static, Type } from 'typebox'
|
|
26
26
|
import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
|
|
27
|
+
import { claudeConfigDir } from '../internal/config-dir.js'
|
|
27
28
|
import { capForContext } from '../internal/output-guard.js'
|
|
28
29
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
29
30
|
import { repoRoot } from '../internal/project-root.js'
|
|
@@ -697,7 +698,7 @@ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: strin
|
|
|
697
698
|
const sanitized = name.replace(/[^\w.-]+/g, '_')
|
|
698
699
|
// A name of only dots ('.', '..') survives the character filter but still traverses.
|
|
699
700
|
const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
|
|
700
|
-
if (scope === 'user') return path.join(home, '
|
|
701
|
+
if (scope === 'user') return path.join(claudeConfigDir(home), 'agent-memory', segment)
|
|
701
702
|
const root = repoRoot(cwd) ?? cwd
|
|
702
703
|
return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
|
|
703
704
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking Keyword Escalation
|
|
3
|
+
*
|
|
4
|
+
* Claude Code raises reasoning effort for a single turn when the prompt carries a
|
|
5
|
+
* think keyword: `ultrathink` asks for the maximum, `think hard`/`think harder` for
|
|
6
|
+
* a high level, and a bare `think` for a medium one. The word stays in the prompt
|
|
7
|
+
* (the input is observed, never consumed or transformed), the escalation only ever
|
|
8
|
+
* raises the level, and the prior level is restored once the turn settles, mirroring
|
|
9
|
+
* how commands.ts restores a per-command model override on agent_settled.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
|
|
14
|
+
// The pi ThinkingLevel union, taken from the setter's parameter so it tracks the SDK.
|
|
15
|
+
type ThinkingLevel = Parameters<ExtensionAPI['setThinkingLevel']>[0]
|
|
16
|
+
|
|
17
|
+
/** Lowest to highest, matching pi's ThinkingLevel union; rank is the index. */
|
|
18
|
+
const THINKING_ORDER: readonly ThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
19
|
+
|
|
20
|
+
export function thinkingRank(level: ThinkingLevel): number {
|
|
21
|
+
const i = THINKING_ORDER.indexOf(level)
|
|
22
|
+
return Math.max(i, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The reasoning level a prompt requests through Claude's think keywords, or undefined
|
|
26
|
+
* when it names none. Checked most-specific first so `think harder` does not fall
|
|
27
|
+
* through to the bare-`think` branch, and on word boundaries so `rethink`/`thinking`
|
|
28
|
+
* and the whole word `ultrathink` never trip the bare match. */
|
|
29
|
+
export function requestedThinkingLevel(text: string): ThinkingLevel | undefined {
|
|
30
|
+
if (/\bultrathink\b/i.test(text)) return 'max'
|
|
31
|
+
if (/\bthink harder\b/i.test(text) || /\bthink hard\b/i.test(text)) return 'high'
|
|
32
|
+
if (/\bthink\b/i.test(text)) return 'medium'
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function thinkingExtension(pi: ExtensionAPI) {
|
|
37
|
+
// The level to restore once the escalated turn settles, captured the first time a
|
|
38
|
+
// turn escalates so back-to-back keywords in one turn still restore the original.
|
|
39
|
+
// Cleared on agent_settled, the run's true end past any retry/compaction/Stop
|
|
40
|
+
// continuation, the same clearing point commands.ts uses for its model override.
|
|
41
|
+
let pendingRestore: ThinkingLevel | undefined
|
|
42
|
+
// The level this extension last escalated to. The restore is conditional on the level
|
|
43
|
+
// still being this target at settle: commands.ts also restores an `effort:` override
|
|
44
|
+
// on agent_settled, so both fire on the same event. Keying the restore on the target
|
|
45
|
+
// makes the outcome order-independent: if a command's restore (or a manual change)
|
|
46
|
+
// already moved the level, thinking stands down instead of clobbering it.
|
|
47
|
+
let pendingTarget: ThinkingLevel | undefined
|
|
48
|
+
|
|
49
|
+
pi.on('input', (event, ctx) => {
|
|
50
|
+
// Only genuine user input escalates. sendUserMessage emits an input event with
|
|
51
|
+
// source 'extension' (a subagent prompt, a command body replayed through it); a
|
|
52
|
+
// think keyword the user did not type must not escalate, mirroring hooks.ts's guard.
|
|
53
|
+
if (event.source === 'extension') return
|
|
54
|
+
const target = requestedThinkingLevel(event.text)
|
|
55
|
+
if (!target) return
|
|
56
|
+
const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
|
|
57
|
+
// A keyword only raises reasoning: leave a level already at or above the target.
|
|
58
|
+
if (thinkingRank(current) >= thinkingRank(target)) return
|
|
59
|
+
pendingRestore = pendingRestore ?? current
|
|
60
|
+
pendingTarget = target
|
|
61
|
+
pi.setThinkingLevel?.(target)
|
|
62
|
+
// Return nothing so the input is neither consumed nor transformed: Claude keeps
|
|
63
|
+
// the keyword in the prompt.
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
pi.on('agent_settled', () => {
|
|
67
|
+
if (pendingRestore === undefined) return
|
|
68
|
+
const restore = pendingRestore
|
|
69
|
+
const target = pendingTarget
|
|
70
|
+
pendingRestore = undefined
|
|
71
|
+
pendingTarget = undefined
|
|
72
|
+
// Restore only if nothing else moved the level since this extension set it. If a
|
|
73
|
+
// command's effort restore or the user's manual change already took over (current
|
|
74
|
+
// no longer equals our target), leave that value in place and stand down. When the
|
|
75
|
+
// level cannot be read, restore unconditionally, the prior best-effort behavior.
|
|
76
|
+
const current = pi.getThinkingLevel?.()
|
|
77
|
+
if (current !== undefined && current !== target) return
|
|
78
|
+
pi.setThinkingLevel?.(restore)
|
|
79
|
+
})
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
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",
|