pi-code 1.0.47 → 1.0.49

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.
@@ -97,6 +97,7 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
97
97
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
98
98
  import { readManagedSettings } from '../internal/managed-settings.js'
99
99
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
100
+ import { resolveModelOverride } from '../internal/model-lookup.js'
100
101
  import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
101
102
  import { installedPlugins } from '../internal/plugins.js'
102
103
  import { isProjectApproved } from '../internal/project-approval.js'
@@ -230,13 +231,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
230
231
  }
231
232
  /** Claude's prompt-hook `model` override, resolved against the models this user
232
233
  * can run (exact id first, then a substring match); the session model otherwise. */
233
- const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => {
234
- if (!override) return ctx.model
235
- const available = (ctx as { modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? []
236
- const needle = override.toLowerCase()
237
- const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
238
- return (match as ExtensionContext['model']) ?? ctx.model
239
- }
234
+ const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => resolveModelOverride(ctx, override)
240
235
 
241
236
  /** Kills for background hooks still running; Claude kills async hooks at teardown,
242
237
  * so session_shutdown reaps anything left rather than let a hung hook pin the
@@ -13,8 +13,8 @@ import * as fs from 'node:fs'
13
13
  import * as path from 'node:path'
14
14
 
15
15
  import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
-
17
16
  import { splitSegments } from './shell-split.js'
17
+ import { type Fence, fenceMarker, stepFence } from './strip-comments.js'
18
18
 
19
19
  /** The pi file tools a Claude path rule can govern. */
20
20
  export type PathRuleTool = 'read' | 'edit' | 'write'
@@ -421,7 +421,7 @@ export function discoverCommandFiles(root: string): DiscoveredCommand[] {
421
421
  return found
422
422
  }
423
423
 
424
- export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number }>
424
+ export type CommandExec = (command: string) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>
425
425
 
426
426
  /** PowerShell single-quote escaping: inside a '...' literal the only special
427
427
  * characters are the quote delimiters themselves, written doubled. PowerShell's
@@ -511,20 +511,27 @@ interface FenceBlock {
511
511
  }
512
512
 
513
513
  /** Fenced blocks of a body: Claude's dynamic syntax is literal text inside a plain
514
- * fence, while a ```! fence is itself a placeholder that executes. */
514
+ * fence, while a ```! fence is itself a placeholder that executes. Fences follow
515
+ * CommonMark: any indentation, closed only by the opener's character in a run at
516
+ * least as long, so a tilde line or a shorter fence inside stays content. */
515
517
  function fenceBlocks(body: string): FenceBlock[] {
516
518
  const blocks: FenceBlock[] = []
517
- const fence = /^(```|~~~)([^\n]*)$/gm
519
+ let fence: Fence | null = null
518
520
  let open: { index: number; exec: boolean; contentStart: number } | undefined
519
- let match = fence.exec(body)
520
- while (match !== null) {
521
- if (open === undefined) {
522
- open = { index: match.index, exec: match[1] === '```' && match[2].trim() === '!', contentStart: match.index + match[0].length + 1 }
523
- } else {
524
- blocks.push({ start: open.index, end: match.index + match[0].length, exec: open.exec, content: body.slice(Math.min(open.contentStart, match.index), match.index).replace(/\n$/, '') })
521
+ let offset = 0
522
+ for (const line of body.split('\n')) {
523
+ const trimmed = line.trimStart()
524
+ const step = stepFence(fence, trimmed, fenceMarker(trimmed))
525
+ const lineEnd = offset + line.length
526
+ if (fence === null && step.fence !== null) {
527
+ // Only the exact, unindented ```! opener executes, as Claude documents it.
528
+ open = { index: offset, exec: line.startsWith('```') && step.fence.length === 3 && trimmed.slice(3).trim() === '!', contentStart: lineEnd + 1 }
529
+ } else if (fence !== null && step.fence === null && open !== undefined) {
530
+ blocks.push({ start: open.index, end: lineEnd, exec: open.exec, content: body.slice(Math.min(open.contentStart, offset), offset).replace(/\n$/, '') })
525
531
  open = undefined
526
532
  }
527
- match = fence.exec(body)
533
+ fence = step.fence
534
+ offset = lineEnd + 1
528
535
  }
529
536
  // An unterminated fence protects to the end of the body rather than executing.
530
537
  if (open !== undefined) blocks.push({ start: open.index, end: body.length, exec: false, content: '' })
@@ -563,6 +570,10 @@ export function benignExitOne(command: string, shell: SpanShell = 'bash'): boole
563
570
  * documents: the model never sees a half-expanded body. */
564
571
  async function runSpan(exec: CommandExec, command: string, pattern: string, shell: SpanShell): Promise<string> {
565
572
  const result = await exec(command)
573
+ // A timeout kill arrives as killed:true with code 0 (a signal death has no exit code),
574
+ // so the code alone would paste the partial output as a success. Claude kills a span
575
+ // at the Bash timeout and that failure aborts the invocation.
576
+ if (result.killed) throw new Error(`Shell command timed out for pattern "${pattern}"`)
566
577
  if (result.code !== 0 && !(result.code === 1 && benignExitOne(command, shell))) {
567
578
  throw new Error(`Shell command failed for pattern "${pattern}"\n[stderr]\n${(result.stderr || result.stdout).trim()}`)
568
579
  }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The pure half of /goal: the directive Claude Code sends when a goal is set, the
3
+ * evaluator prompt its small fast model judges the condition with, the transcript
4
+ * rendering that prompt sees, verdict parsing, the classifier for the errors that
5
+ * clear a goal, the check-in schedule, and the status text. No pi state lives here,
6
+ * so goal.ts stays the lifecycle wiring and each contract is pinned on its own.
7
+ */
8
+
9
+ /** Claude caps a goal condition at 4,000 characters. */
10
+ export const GOAL_CONDITION_MAX_CHARS = 4000
11
+
12
+ /** `/goal clear` and its documented aliases, matched case-insensitively. */
13
+ const CLEAR_ALIASES = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])
14
+
15
+ export function isClearAlias(text: string): boolean {
16
+ return CLEAR_ALIASES.has(text.toLowerCase())
17
+ }
18
+
19
+ /** The directive a set goal starts its first turn with: the condition itself is the task. */
20
+ export function kickoffPrompt(condition: string): string {
21
+ return `A session-scoped Stop hook is now active with condition: "${condition}". Briefly acknowledge the goal, then immediately start (or continue) working toward it: treat the condition itself as your directive and do not pause to ask the user what to do. The hook will block stopping until the condition holds. It auto-clears once the condition is met, so do not tell the user to run \`/goal clear\` after success; that is only for clearing a goal early.`
22
+ }
23
+
24
+ /** Claude's stop-condition evaluator instructions: transcript evidence only, three
25
+ * verdict shapes, and impossible reserved for a condition that can never hold. */
26
+ export const EVALUATOR_SYSTEM = [
27
+ 'You are evaluating a stop-condition hook for a coding agent session. Read the conversation transcript carefully, then judge whether the user-provided condition is satisfied.',
28
+ 'Your response must be a JSON object with one of these shapes:',
29
+ '- {"ok": true, "reason": "<quote evidence from the transcript that satisfies the condition>"}',
30
+ '- {"ok": false, "reason": "<quote what is missing or what blocks the condition>"}',
31
+ '- {"ok": false, "impossible": true, "reason": "<explain why the condition can never be satisfied>"}',
32
+ 'Always include a "reason" field, quoting specific text from the transcript whenever possible. If the transcript does not contain clear evidence that the condition is satisfied, return {"ok": false, "reason": "insufficient evidence in transcript"}.',
33
+ 'Only use {"ok": false, "impossible": true} when the condition is genuinely unachievable in this session, for example: the condition is self-contradictory, it depends on a resource or capability that is unavailable, or the assistant has explicitly tried, exhausted reasonable approaches, and stated it cannot be done. Apply your own judgment when deciding this: the assistant claiming the goal is impossible is evidence, not proof; independently confirm the condition is genuinely unachievable rather than deferring to the assistant\'s self-assessment. Do not use it just because the goal has not been reached yet or because progress is slow. When in doubt, return {"ok": false} without "impossible".',
34
+ // Claude pins the reply with a JSON schema; pi's one-off completion cannot, so the
35
+ // instruction has to carry that weight.
36
+ 'Output the JSON object only, with no text before or after it and no code fence.',
37
+ ].join('\n')
38
+
39
+ /** The user turn of the evaluation: the rendered transcript, then Claude's question. */
40
+ export function evaluatorPrompt(transcript: string, condition: string): string {
41
+ return `<transcript>\n${transcript}\n</transcript>\n\nBased on the conversation transcript above, has the following stopping condition been satisfied? Answer based on transcript evidence only.\nCondition: ${condition}`
42
+ }
43
+
44
+ export interface GoalVerdict {
45
+ ok: boolean
46
+ reason: string
47
+ /** Only meaningful when ok is false: the condition can never be satisfied. */
48
+ impossible: boolean
49
+ }
50
+
51
+ /** A reply with no JSON at all but an opening yes/no: a small model answering the
52
+ * question in prose. The whole reply becomes the reason. Anything less clear-cut is
53
+ * not a verdict. */
54
+ function proseVerdict(text: string): GoalVerdict | undefined {
55
+ const lead = /^\s*(yes|no)\b/i.exec(text)?.[1]?.toLowerCase()
56
+ if (lead === undefined) return undefined
57
+ return { ok: lead === 'yes', reason: text.trim(), impossible: false }
58
+ }
59
+
60
+ /** The evaluator's JSON verdict, tolerating prose or a code fence around the object; a
61
+ * reply with no object at all falls back to its yes/no lead. Undefined when the object
62
+ * does not parse, `ok` is not a boolean, or no lead is there, which the caller treats
63
+ * as an evaluator error rather than a verdict. */
64
+ export function parseVerdict(text: string): GoalVerdict | undefined {
65
+ const start = text.indexOf('{')
66
+ const end = text.lastIndexOf('}')
67
+ if (start === -1) return proseVerdict(text)
68
+ if (end <= start) return undefined
69
+ let parsed: unknown
70
+ try {
71
+ parsed = JSON.parse(text.slice(start, end + 1))
72
+ } catch {
73
+ return undefined
74
+ }
75
+ if (typeof parsed !== 'object' || parsed === null) return undefined
76
+ const { ok, reason, impossible } = parsed as Record<string, unknown>
77
+ if (typeof ok !== 'boolean') return undefined
78
+ return { ok, reason: typeof reason === 'string' ? reason.trim() : '', impossible: !ok && impossible === true }
79
+ }
80
+
81
+ /** One message's rendering is capped so a single tool dump cannot consume the budget. */
82
+ const BLOCK_MAX_CHARS = 6000
83
+ /** Tool arguments are context, not evidence; a short prefix identifies the call. */
84
+ const TOOL_ARGS_MAX_CHARS = 400
85
+ const OMITTED_MARKER = '[earlier transcript omitted]'
86
+
87
+ interface ContentPart {
88
+ type?: string
89
+ text?: string
90
+ name?: string
91
+ arguments?: unknown
92
+ }
93
+
94
+ interface TranscriptMessage {
95
+ role?: string
96
+ content?: unknown
97
+ toolName?: string
98
+ customType?: string
99
+ stopReason?: string
100
+ errorMessage?: string
101
+ }
102
+
103
+ function truncate(text: string, max: number): string {
104
+ if (text.length <= max) return text
105
+ return `${text.slice(0, max)}... [truncated ${text.length - max} chars]`
106
+ }
107
+
108
+ /** Text and tool-call parts of a content value; thinking is dropped, images noted. */
109
+ function partsText(content: unknown): string {
110
+ if (typeof content === 'string') return content
111
+ if (!Array.isArray(content)) return ''
112
+ const rendered: string[] = []
113
+ for (const part of content as ContentPart[]) {
114
+ if (part?.type === 'text' && typeof part.text === 'string') rendered.push(part.text)
115
+ else if (part?.type === 'image') rendered.push('[image]')
116
+ else if (part?.type === 'toolCall') rendered.push(`[tool call ${part.name}(${truncate(JSON.stringify(part.arguments ?? {}), TOOL_ARGS_MAX_CHARS)})]`)
117
+ }
118
+ return rendered.join('\n')
119
+ }
120
+
121
+ function renderMessage(message: TranscriptMessage): string | undefined {
122
+ switch (message.role) {
123
+ case 'user':
124
+ return `User: ${partsText(message.content)}`
125
+ case 'assistant': {
126
+ const body = message.stopReason === 'error' ? `[error: ${message.errorMessage ?? 'unknown error'}]` : partsText(message.content)
127
+ return `Assistant: ${body}`
128
+ }
129
+ case 'toolResult':
130
+ return `Tool result (${message.toolName ?? 'tool'}): ${partsText(message.content)}`
131
+ case 'custom':
132
+ return `Note (${message.customType ?? 'note'}): ${partsText(message.content)}`
133
+ default:
134
+ return undefined
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The conversation as the evaluator reads it: one block per message, newest last,
140
+ * trimmed from the head to `budgetChars` (Claude trims to half the evaluator's
141
+ * context window). A cut is marked so the model knows evidence may predate it; the
142
+ * newest message always survives, cut to the budget when it alone exceeds it.
143
+ */
144
+ export function renderTranscript(messages: readonly unknown[], budgetChars: number): string {
145
+ const blocks: string[] = []
146
+ for (const message of messages as TranscriptMessage[]) {
147
+ const rendered = renderMessage(message)
148
+ if (rendered !== undefined) blocks.push(truncate(rendered, BLOCK_MAX_CHARS))
149
+ }
150
+ const newest = blocks.at(-1)
151
+ if (newest === undefined) return ''
152
+ const separator = '\n\n'
153
+ const kept: string[] = []
154
+ let used = 0
155
+ for (let i = blocks.length - 1; i >= 0; i--) {
156
+ const cost = blocks[i].length + (kept.length > 0 ? separator.length : 0)
157
+ if (used + cost > budgetChars) {
158
+ if (kept.length > 0) kept.unshift(OMITTED_MARKER)
159
+ break
160
+ }
161
+ kept.unshift(blocks[i])
162
+ used += cost
163
+ }
164
+ if (kept.length === 0) return newest.slice(0, budgetChars)
165
+ return kept.join(separator)
166
+ }
167
+
168
+ export type UnrecoverableKind = 'authentication' | 'credits' | 'context overflow' | 'model unavailable'
169
+
170
+ /** Claude clears a goal after an error the user has to fix. Transient failures (rate
171
+ * limits, overloads, network) deliberately match nothing so the goal stays set. */
172
+ const UNRECOVERABLE: ReadonlyArray<[UnrecoverableKind, RegExp]> = [
173
+ ['authentication', /\b40[13]\b|unauthori[sz]ed|authentication|invalid (?:api[ -])?key|x-api-key/i],
174
+ ['credits', /\bcredit|billing|insufficient[ _](?:funds|balance|quota)|payment required|\b402\b/i],
175
+ ['context overflow', /context (?:window|length)|too (?:long|many tokens)|maximum (?:context|input) (?:length|tokens)/i],
176
+ ['model unavailable', /model.*(?:not found|unavailable|does not exist|not available|unsupported)|no such model|not_found_error|\b404\b/i],
177
+ ]
178
+
179
+ export function classifyUnrecoverable(errorMessage: string): UnrecoverableKind | undefined {
180
+ return UNRECOVERABLE.find(([, pattern]) => pattern.test(errorMessage))?.[0]
181
+ }
182
+
183
+ export function formatDuration(ms: number): string {
184
+ const seconds = Math.max(0, Math.round(ms / 1000))
185
+ if (seconds < 60) return `${seconds}s`
186
+ const minutes = Math.floor(seconds / 60)
187
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`
188
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
189
+ }
190
+
191
+ export function formatTokens(tokens: number): string {
192
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
193
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
194
+ return String(tokens)
195
+ }
196
+
197
+ function plural(count: number, noun: string): string {
198
+ return `${count} ${noun}${count === 1 ? '' : 's'}`
199
+ }
200
+
201
+ export interface GoalSummary {
202
+ condition: string
203
+ durationMs: number
204
+ /** Turns the evaluator has judged. */
205
+ iterations: number
206
+ /** Tokens spent since the goal was set, evaluator calls included. */
207
+ tokens: number
208
+ lastReason?: string
209
+ }
210
+
211
+ /** Claude's status view fields: duration, turn count, and token spend. */
212
+ export function summaryText(summary: GoalSummary): string {
213
+ return `${formatDuration(summary.durationMs)} · ${plural(summary.iterations, 'turn')} · ${formatTokens(summary.tokens)} tokens`
214
+ }
215
+
216
+ export const NO_GOAL_TEXT = 'No goal set. Usage: /goal <condition>'
217
+
218
+ export function formatActiveGoal(summary: GoalSummary): string {
219
+ const turns = summary.iterations === 0 ? 'not yet evaluated' : plural(summary.iterations, 'turn')
220
+ const lines = [`Goal active: ${summary.condition} (${turns})`, `Running for ${formatDuration(summary.durationMs)} · ${formatTokens(summary.tokens)} tokens`]
221
+ if (summary.lastReason) lines.push(`Last check: ${summary.lastReason}`)
222
+ lines.push('/goal clear to stop early')
223
+ return lines.join('\n')
224
+ }
225
+
226
+ export function formatAchievedGoal(summary: GoalSummary): string {
227
+ return `Goal achieved: ${summary.condition} (${summaryText(summary)})\n/goal <condition> to set another`
228
+ }
229
+
230
+ /** Claude's first check-in interval while background work keeps a goal waiting. */
231
+ export const DEFAULT_CHECKIN_MINUTES = 30
232
+ /** Later check-ins wait twice as long each, up to four times the first interval. */
233
+ const MAX_CHECKIN_DOUBLINGS = 2
234
+ /** Idle check-ins per goal between user prompts; the third says they are paused. */
235
+ export const MAX_IDLE_CHECKINS = 3
236
+
237
+ /** Milliseconds until the next check-in given how many have been delivered for this
238
+ * goal: CLAUDE_CODE_GOAL_CHECKIN_MINUTES (0 turns check-ins off, junk falls back to
239
+ * the default) scaled by Claude's doubling. */
240
+ export function checkinIntervalMs(env: Record<string, string | undefined>, delivered: number): number {
241
+ const raw = env.CLAUDE_CODE_GOAL_CHECKIN_MINUTES
242
+ const parsed = raw === undefined || raw.trim() === '' ? Number.NaN : Number(raw)
243
+ const minutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_CHECKIN_MINUTES
244
+ return minutes * 60_000 * 2 ** Math.min(delivered, MAX_CHECKIN_DOUBLINGS)
245
+ }
246
+
247
+ export interface RunningWork {
248
+ id: string
249
+ agentType: string
250
+ }
251
+
252
+ /** Claude's check-in turn: the running work to look at, or a nudge to continue when
253
+ * the work stopped without reporting. `paused` marks the capped idle check-in. */
254
+ export function checkinText(condition: string, deferredMs: number, running: readonly RunningWork[], paused: boolean): string {
255
+ const minutes = Math.max(1, Math.round(deferredMs / 60_000))
256
+ const pausedNote = paused ? ' Idle check-ins are paused until your next message, so say clearly where things stand.' : ''
257
+ if (running.length === 0) {
258
+ return `Goal check-in: «${condition}» is still active. Its evaluation was deferred for ${minutes} min while background work ran, and that work is no longer running (it finished or was stopped without reporting back). Continue toward the goal.${pausedNote}`
259
+ }
260
+ const list = running.map((work) => `- ${work.id} · subagent ${work.agentType}`).join('\n')
261
+ return `Goal check-in: «${condition}» is still active, and evaluation has been deferred for ${minutes} min because background work is still running:\n${list}\nCheck on their progress (e.g. read their output). If they are progressing, say so briefly and keep waiting; if they are stuck or no longer needed, fix or stop them and continue toward the goal.${pausedNote}`
262
+ }
@@ -5,7 +5,8 @@
5
5
  * A regex pipeline, not a DOM: pi ships no HTML parser and the output is prose
6
6
  * for a model, not a rendering. Every pattern bounds its tag matches with
7
7
  * [^<>]* so a failed match stops at the next tag instead of rescanning to the
8
- * end of input, keeping the pass linear on hostile pages.
8
+ * end of input, keeping the pass linear on hostile pages. Bare tag removal is a
9
+ * scanner rather than a regex: see removeTags.
9
10
  */
10
11
 
11
12
  const NAMED_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
@@ -18,7 +19,34 @@ function decodeAllEntities(text: string): string {
18
19
  })
19
20
  }
20
21
 
21
- const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
22
+ /** The HTML tokenizer's tag-open rule: `<` starts a tag only before a letter, `/`,
23
+ * `!`, or `?`; any other `<` (as in `1 < 2`) is text. */
24
+ const TAG_OPEN = /^<[A-Za-z/!?]/
25
+
26
+ /**
27
+ * Drop every tag in one linear pass. A regex strip can rebuild a tag out of nested
28
+ * brackets: `<scr<b>ipt>` loses `<b>` and becomes `<script>` (CodeQL's incomplete
29
+ * multi-character sanitization). Skipping from a tag's `<` to the next `>` removes the
30
+ * whole span, so nothing removed can reassemble; a tag that never closes stays as text.
31
+ */
32
+ export function removeTags(html: string): string {
33
+ let out = ''
34
+ let cursor = 0
35
+ while (cursor < html.length) {
36
+ const open = html.indexOf('<', cursor)
37
+ if (open === -1) return out + html.slice(cursor)
38
+ if (!TAG_OPEN.test(html.slice(open, open + 2))) {
39
+ out += html.slice(cursor, open + 1)
40
+ cursor = open + 1
41
+ continue
42
+ }
43
+ const close = html.indexOf('>', open + 1)
44
+ if (close === -1) return out + html.slice(cursor)
45
+ out += html.slice(cursor, open)
46
+ cursor = close + 1
47
+ }
48
+ return out
49
+ }
22
50
 
23
51
  // Strip leading and trailing newline runs in linear time. The equivalent
24
52
  // /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
@@ -37,23 +65,23 @@ export function htmlToMarkdown(html: string): string {
37
65
  .replace(/<!--[\s\S]*?-->/g, ' ')
38
66
  .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
39
67
  .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
40
- preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
68
+ preBodies.push(trimNewlines(decodeAllEntities(removeTags(inner))))
41
69
  return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
42
70
  })
43
71
 
44
72
  work = work
45
- .replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${stripInnerTags(inner)}\``)
73
+ .replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${removeTags(inner)}\``)
46
74
  // Only real web links become markdown links; fragment and javascript hrefs
47
75
  // keep their label and lose the target.
48
76
  .replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
49
77
  const href = decodeAllEntities(dq ?? sq ?? '')
50
- const label = stripInnerTags(inner).trim()
78
+ const label = removeTags(inner).trim()
51
79
  if (!label) return ' '
52
80
  return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
53
81
  })
54
- .replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${stripInnerTags(inner).trim()}**`)
55
- .replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${stripInnerTags(inner).trim()}*`)
56
- .replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${stripInnerTags(inner).trim()}\n\n`)
82
+ .replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${removeTags(inner).trim()}**`)
83
+ .replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${removeTags(inner).trim()}*`)
84
+ .replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${removeTags(inner).trim()}\n\n`)
57
85
  .replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
58
86
  .replace(/<li\b[^<>]*>/gi, '\n- ')
59
87
  .replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
@@ -61,7 +89,7 @@ export function htmlToMarkdown(html: string): string {
61
89
  .replace(/<(?:br|hr)\b[^<>]*>/gi, '\n')
62
90
  .replace(/<\/(?:p|div|section|article|ul|ol|li|table|tr|blockquote|tbody|thead|header|footer|main|nav)[^<>]*>/gi, '\n\n')
63
91
 
64
- const text = decodeAllEntities(work.replace(/<[^<>]*>/g, ''))
92
+ const text = decodeAllEntities(removeTags(work))
65
93
  .replace(/[ \t]+/g, ' ')
66
94
  .replace(/ ?\n ?/g, '\n')
67
95
  .replace(/\n{3,}/g, '\n\n')
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A model named by an override (a prompt hook's `model`, /goal's evaluator model),
3
+ * resolved against the models this user can run: exact id first, then a substring
4
+ * of the id or display name. The session model stands in when nothing matches or no
5
+ * override was given, so a misspelled override degrades to the default rather than
6
+ * silently disabling the feature.
7
+ */
8
+
9
+ export interface ModelLookupContext<M> {
10
+ model: M | undefined
11
+ modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> }
12
+ }
13
+
14
+ export function resolveModelOverride<M>(ctx: ModelLookupContext<M>, override: string | undefined): M | undefined {
15
+ if (!override) return ctx.model
16
+ const available = ctx.modelRegistry?.getAvailable?.() ?? []
17
+ const needle = override.toLowerCase()
18
+ const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
19
+ return (match as M | undefined) ?? ctx.model
20
+ }
@@ -15,12 +15,13 @@ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from '
15
15
  /**
16
16
  * Trim `text` to a byte budget. `String.slice` counts UTF-16 units, so slicing a CJK
17
17
  * string by a byte budget keeps up to three times the bytes asked for; cutting the
18
- * encoded buffer is exact. A character straddling the cut decodes to U+FFFD.
19
- * Shorter input comes back whole and a negative budget yields nothing, so callers
20
- * need no length check of their own.
18
+ * encoded buffer is exact. A character straddling the cut is dropped (the streaming
19
+ * decoder holds back an incomplete sequence instead of emitting U+FFFD), so the
20
+ * result is whole characters within the budget. Shorter input comes back whole and a
21
+ * negative budget yields nothing, so callers need no length check of their own.
21
22
  */
22
- function sliceBytes(text: string, maxBytes: number): string {
23
- return Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)).toString('utf-8')
23
+ export function sliceBytes(text: string, maxBytes: number): string {
24
+ return new TextDecoder().decode(Buffer.from(text, 'utf-8').subarray(0, Math.max(0, maxBytes)), { stream: true })
24
25
  }
25
26
 
26
27
  /** Trim `text` to pi's documented tool-output budget, noting what was dropped. */
@@ -11,7 +11,7 @@
11
11
  * fenced code block (backtick or tilde).
12
12
  */
13
13
 
14
- /** The fence a line opens or closes, if any; mirrors context-imports. */
14
+ /** The fence a line opens or closes, if any. */
15
15
  export function fenceMarker(lineStart: string): string | null {
16
16
  if (lineStart.startsWith('```')) return '`'
17
17
  if (lineStart.startsWith('~~~')) return '~'
@@ -29,14 +29,14 @@ function fenceLength(lineStart: string, marker: string): number {
29
29
  // is at least as long as the opener, so both are tracked: a shorter same-char
30
30
  // fence line (the classic 3-backtick block quoted inside a 4-backtick one) is
31
31
  // content, not a closer.
32
- interface Fence {
32
+ export interface Fence {
33
33
  marker: string
34
34
  length: number
35
35
  }
36
36
 
37
37
  /** The fence state after a line, plus whether the line is fenced code (opener,
38
38
  * body, or closer) and so emitted verbatim rather than scanned for comments. */
39
- function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
39
+ export function stepFence(fence: Fence | null, trimmed: string, marker: string | null): { fence: Fence | null; fenced: boolean } {
40
40
  if (marker !== null && fence === null) {
41
41
  return { fence: { marker, length: fenceLength(trimmed, marker) }, fenced: true }
42
42
  }
@@ -98,11 +98,36 @@ export function stampModified(content: string, iso: string): string {
98
98
  return `---\n${body}modified: ${iso}\n---${rest}`
99
99
  }
100
100
 
101
+ /** Length of the line break `text` starts with: CRLF, LF, or none. */
102
+ function leadingLineBreak(text: string): number {
103
+ if (text.startsWith('\r\n')) return 2
104
+ return text.startsWith('\n') ? 1 : 0
105
+ }
106
+
107
+ /** Drop every `<!-- ... -->` (and the line break after it) in one pass. A regex strip
108
+ * can rebuild a comment from one nested in another: `<!-<!-- x -->-- y -->` loses the
109
+ * inner comment and becomes `<!--- y -->`. After a removal the scan resumes three
110
+ * characters back, so a comment assembled across the cut is removed too; an opener
111
+ * that never closes stays as text. */
112
+ function removeComments(text: string): string {
113
+ let out = text
114
+ let cursor = 0
115
+ while (cursor < out.length) {
116
+ const open = out.indexOf('<!--', cursor)
117
+ const close = open === -1 ? -1 : out.indexOf('-->', open + 4)
118
+ if (close === -1) return out
119
+ const tail = out.slice(close + 3)
120
+ out = out.slice(0, open) + tail.slice(leadingLineBreak(tail))
121
+ cursor = Math.max(0, open - 3)
122
+ }
123
+ return out
124
+ }
125
+
101
126
  /** The index content that actually loads: YAML frontmatter and block-level HTML
102
127
  * comments are stripped, so they neither show in the prompt nor count toward the
103
128
  * 200-line / 25KB read limits, matching Claude Code. */
104
129
  export function stripNonLoaded(text: string): string {
105
- return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').replace(/<!--[\s\S]*?-->\r?\n?/g, '')
130
+ return removeComments(text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, ''))
106
131
  }
107
132
 
108
133
  /** Move a store written under an older slug to the current one, once. Two earlier
@@ -429,7 +429,9 @@ After completing a step, include a [DONE:n] tag in your response.`,
429
429
  planModeEnabled = true
430
430
  }
431
431
 
432
- const entries = ctx.sessionManager.getEntries()
432
+ // The current branch only: getEntries() lists every branch in the file, so after a
433
+ // rewind past a plan it would resurrect the abandoned plan and its tool restriction.
434
+ const entries = ctx.sessionManager.getBranch()
433
435
 
434
436
  // Restore persisted state
435
437
  const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
@@ -179,7 +179,7 @@ export default function question(pi: ExtensionAPI) {
179
179
 
180
180
  renderCall(args, theme, _context) {
181
181
  const multi = args.multiSelect === true
182
- const heading = args.header ? `[${args.header}] ` : ''
182
+ const heading = args.header ? `[${shortHeader(String(args.header))}] ` : ''
183
183
  let text = theme.fg('toolTitle', theme.bold('question ')) + theme.fg('muted', heading + String(args.question ?? ''))
184
184
  const opts = Array.isArray(args.options) ? args.options : []
185
185
  if (opts.length) {