pi-code 1.0.47 → 1.0.48

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 CHANGED
@@ -11,7 +11,7 @@
11
11
  [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
12
12
  [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ilovepixelart_pi-code&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ilovepixelart_pi-code)
13
13
 
14
- Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and subagents.
14
+ Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, subagents, and goals.
15
15
 
16
16
  What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes.
17
17
 
@@ -53,9 +53,10 @@ Each topic links to its own doc with the full contract and any divergences from
53
53
  - **[Statusline](docs/statusline.md)** — your Claude `statusLine` command with the documented stdin JSON.
54
54
  - **[WebSearch / WebFetch](docs/web.md)** — key-free search and SSRF-guarded fetch.
55
55
  - **[Claude plugins](docs/plugins.md)** — installed marketplace plugins: commands, agents, hooks, MCP servers, styles, skills.
56
+ - **[Goal](docs/goal.md)**: `/goal <condition>` keeps the session working until a separate model check confirms the condition holds, with status, clear, block cap, background-work deferral, and resume.
56
57
  - **[Session extras](docs/session-extras.md)** — project trust, plan mode, todos, checkpoints/rewind, AskUserQuestion, notifications, think keywords, session titles, `/context`, `/init`.
57
58
 
58
- Slash commands: `/init`, `/context`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
59
+ Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
59
60
 
60
61
  pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
61
62
 
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Goal Extension
3
+ *
4
+ * Claude Code's /goal: a completion condition the session keeps working toward. After
5
+ * each turn a separate model judges the condition against the conversation and returns
6
+ * met, not yet met (its reason becomes the next turn's guidance), or impossible. Claude
7
+ * implements it as a session-scoped prompt-based Stop hook; pi-code runs the same loop
8
+ * on agent_end next to the hooks extension's Stop path:
9
+ * - `/goal <condition>` sets (or replaces) the goal and starts a turn with Claude's
10
+ * kickoff directive; `/goal` shows status; `/goal clear` (stop/off/reset/none/cancel)
11
+ * removes it. The condition is capped at 4,000 characters.
12
+ * - The evaluator is the session model, or ANTHROPIC_DEFAULT_HAIKU_MODEL resolved against
13
+ * the models this user can run. It reads the branch transcript trimmed to half its
14
+ * context window and cannot run tools, so the condition must be provable from output.
15
+ * - The Stop hooks' consecutive-block cap (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP, default 8)
16
+ * bounds a stalled loop: that many not-met verdicts in a row on turns that used no tool
17
+ * pause the loop with a warning, goal still set, until the next user prompt.
18
+ * - Evaluation is skipped while a subagent is still running (tracked from the subagent
19
+ * extension's bus events; pi has no other background work). A check-in turn is
20
+ * injected once the wait reaches CLAUDE_CODE_GOAL_CHECKIN_MINUTES (30, doubling up to
21
+ * four times the first interval, at most three idle check-ins between user prompts;
22
+ * 0 turns check-ins off).
23
+ * - A turn ended by the user (Esc) or by an error is not evaluated, and an Esc during
24
+ * the evaluation cancels it with the goal left set. An unrecoverable
25
+ * error (authentication, credits, context overflow, model unavailable) clears the goal
26
+ * with a warning once the run settles; a transient one leaves it set.
27
+ * - The active goal persists as a session entry and is restored on resume or reload with
28
+ * the turn count, timer, and token baseline reset; an achieved, failed, or cleared goal
29
+ * is not restored.
30
+ * - Gated as Claude documents: unavailable when hooks are restricted (disableAllHooks or
31
+ * allowManagedHooksOnly) or the project is not trusted, with the reason shown.
32
+ * - Headless (`pi -p "/goal ..."`), the command holds the process open until the goal
33
+ * resolves or pauses, and warnings also go to stderr since there is no notify surface.
34
+ *
35
+ * Docs: https://code.claude.com/docs/en/goal.md
36
+ */
37
+
38
+ import * as os from 'node:os'
39
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
40
+
41
+ import { hookFiles, readSettingsDisableAllHooks, stopHookBlockCap } from './hooks/index.js'
42
+ import {
43
+ checkinIntervalMs,
44
+ checkinText,
45
+ classifyUnrecoverable,
46
+ EVALUATOR_SYSTEM,
47
+ evaluatorPrompt,
48
+ formatAchievedGoal,
49
+ formatActiveGoal,
50
+ formatDuration,
51
+ formatTokens,
52
+ GOAL_CONDITION_MAX_CHARS,
53
+ type GoalSummary,
54
+ type GoalVerdict,
55
+ isClearAlias,
56
+ kickoffPrompt,
57
+ MAX_IDLE_CHECKINS,
58
+ NO_GOAL_TEXT,
59
+ parseVerdict,
60
+ type RunningWork,
61
+ renderTranscript,
62
+ summaryText,
63
+ } from './internal/goal-evaluator.js'
64
+ import { readManagedSettings } from './internal/managed-settings.js'
65
+ import { completeText } from './internal/model-complete.js'
66
+ import { resolveModelOverride } from './internal/model-lookup.js'
67
+ import { isProjectApprovedSilently } from './internal/project-approval.js'
68
+ import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
69
+
70
+ /** Session entry type the goal state persists under, and the custom message type its
71
+ * transcript lines (kickoff, verdicts, check-ins) carry. */
72
+ const GOAL_ENTRY = 'goal'
73
+ const GOAL_MESSAGE = 'goal'
74
+ /** Claude's prompt-hook default; a slow evaluator is an error, not a stall. */
75
+ const EVALUATOR_TIMEOUT_MS = 30_000
76
+ /** A verdict is one JSON object; the cap stops a runaway reply. */
77
+ const EVALUATOR_MAX_TOKENS = 512
78
+ /** Claude trims the transcript to half the evaluator's context window. */
79
+ const TRANSCRIPT_WINDOW_SHARE = 0.5
80
+ const CHARS_PER_TOKEN = 4
81
+ const DEFAULT_CONTEXT_WINDOW = 200_000
82
+ /** The `◎ goal <elapsed>` indicator re-renders on this cadence while a goal is active. */
83
+ const INDICATOR_REFRESH_MS = 60_000
84
+
85
+ const HOOKS_GATE = "/goal can't run while hooks are restricted (disableAllHooks or allowManagedHooksOnly is set in settings or by policy)."
86
+ const TRUST_GATE = '/goal is only available in trusted workspaces. Restart, accept the trust dialog, and try again.'
87
+
88
+ type GoalState = 'active' | 'cleared' | 'achieved' | 'failed'
89
+
90
+ interface GoalEntry {
91
+ state: GoalState
92
+ condition: string
93
+ }
94
+
95
+ interface ActiveGoal {
96
+ condition: string
97
+ setAt: number
98
+ iterations: number
99
+ lastReason?: string
100
+ /** Session token total when the goal was set; spend is measured from here. */
101
+ tokensAtStart: number
102
+ /** Evaluator calls are not on the session total; they count toward the goal's spend. */
103
+ evaluatorTokens: number
104
+ }
105
+
106
+ interface TurnMessage {
107
+ role?: string
108
+ stopReason?: string
109
+ errorMessage?: string
110
+ }
111
+
112
+ interface TokenUsage {
113
+ totalTokens?: number
114
+ input?: number
115
+ output?: number
116
+ cacheRead?: number
117
+ cacheWrite?: number
118
+ }
119
+
120
+ function usageTokens(usage: TokenUsage | undefined): number {
121
+ if (!usage) return 0
122
+ return usage.totalTokens ?? (usage.input ?? 0) + (usage.output ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0)
123
+ }
124
+
125
+ function lastAssistant(messages: readonly unknown[]): TurnMessage | undefined {
126
+ for (let i = messages.length - 1; i >= 0; i--) {
127
+ const message = messages[i] as TurnMessage
128
+ if (message?.role === 'assistant') return message
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ type SessionReader = Pick<ExtensionContext, 'sessionManager'>
134
+
135
+ /** The branch as the evaluator reads it: messages plus the goal's own transcript lines,
136
+ * which live in custom_message entries. */
137
+ function branchMessages(ctx: SessionReader): unknown[] {
138
+ const messages: unknown[] = []
139
+ for (const entry of ctx.sessionManager.getBranch()) {
140
+ if (entry.type === 'message') messages.push(entry.message)
141
+ else if (entry.type === 'custom_message') messages.push({ role: 'custom', customType: entry.customType, content: entry.content })
142
+ }
143
+ return messages
144
+ }
145
+
146
+ /** Cumulative assistant usage on the branch: the session's token spend so far. */
147
+ function sessionTokens(ctx: SessionReader): number {
148
+ let total = 0
149
+ for (const entry of ctx.sessionManager.getBranch()) {
150
+ if (entry.type === 'message' && entry.message.role === 'assistant') total += usageTokens(entry.message.usage as TokenUsage | undefined)
151
+ }
152
+ return total
153
+ }
154
+
155
+ /** The newest goal entry on the branch, which is the goal's persisted state. */
156
+ function lastGoalEntry(ctx: SessionReader): GoalEntry | undefined {
157
+ const branch = ctx.sessionManager.getBranch()
158
+ for (let i = branch.length - 1; i >= 0; i--) {
159
+ const entry = branch[i]
160
+ if (entry.type !== 'custom' || entry.customType !== GOAL_ENTRY) continue
161
+ const data = entry.data as Partial<GoalEntry> | undefined
162
+ return typeof data?.condition === 'string' && typeof data.state === 'string' ? { state: data.state, condition: data.condition } : undefined
163
+ }
164
+ return undefined
165
+ }
166
+
167
+ /** Claude's availability rule: /goal rides the hooks system, so a hooks restriction or an
168
+ * untrusted workspace refuses it with the reason. Checked in Claude's order. */
169
+ function goalUnavailable(ctx: Pick<ExtensionContext, 'cwd' | 'isProjectTrusted' | 'ui'>): string | undefined {
170
+ const managed = readManagedSettings()
171
+ const trusted = isProjectApprovedSilently(ctx)
172
+ const restricted = managed.disableAllHooks === true || managed.allowManagedHooksOnly === true || readSettingsDisableAllHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
173
+ if (restricted) return HOOKS_GATE
174
+ if (!trusted) return TRUST_GATE
175
+ return undefined
176
+ }
177
+
178
+ /** Claude evaluates on its small fast model, ANTHROPIC_DEFAULT_HAIKU_MODEL overriding it;
179
+ * pi has no such tier, so the session model stands in unless the override names one of
180
+ * the models this user can run. */
181
+ function evaluatorModel(ctx: ExtensionContext): ExtensionContext['model'] {
182
+ return resolveModelOverride(ctx, process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() || undefined)
183
+ }
184
+
185
+ function transcriptBudget(model: { contextWindow?: number }): number {
186
+ return Math.floor((model.contextWindow || DEFAULT_CONTEXT_WINDOW) * TRANSCRIPT_WINDOW_SHARE * CHARS_PER_TOKEN)
187
+ }
188
+
189
+ export default function goalExtension(pi: ExtensionAPI) {
190
+ let goal: ActiveGoal | undefined
191
+ /** The last goal achieved this session, for the status view after it clears. */
192
+ let achieved: GoalSummary | undefined
193
+ /** The live context, for timer callbacks that fire outside any pi event. */
194
+ let sessionCtx: ExtensionContext | undefined
195
+ /** Bumped whenever the goal is set, cleared, or the session changes, so an evaluation
196
+ * that resolves after any of those is dropped instead of steering the wrong goal. */
197
+ let generation = 0
198
+ /** Whether the run now ending executed a tool: Claude's "progress" for the block cap. */
199
+ let turnUsedTools = false
200
+ /** Not-met verdicts in a row on turns that used no tool. */
201
+ let noProgressStreak = 0
202
+ /** The error the last run ended on, judged once the run settles (past pi's retries). */
203
+ let lastTurnError: string | undefined
204
+ /** Subagents still running, by id, from the subagent extension's bus events. */
205
+ const running = new Map<string, string>()
206
+ let deferredSince: number | undefined
207
+ let checkinsDelivered = 0
208
+ let idleCheckins = 0
209
+ /** A check-in came due while a turn was running: deliver it at the next turn end. */
210
+ let checkinDue = false
211
+ let checkinTimer: ReturnType<typeof setTimeout> | undefined
212
+ let indicatorTimer: ReturnType<typeof setInterval> | undefined
213
+
214
+ /** A goal line in the transcript, which the model also reads. A timer can outlive the
215
+ * session that armed it, and sendMessage throws on a disposed one. */
216
+ function send(content: string, options: { triggerTurn: boolean; deliverAs?: 'followUp' }): void {
217
+ try {
218
+ pi.sendMessage({ customType: GOAL_MESSAGE, content, display: true }, options)
219
+ } catch {
220
+ // Disposed session: nothing left to tell.
221
+ }
222
+ }
223
+
224
+ /** A line for the user. Headless runs have no notify surface, and a refusal or a
225
+ * loop that stops there without a word would look like a hang, so it also goes to
226
+ * stderr (where Claude's -p mode prints its goal messages). */
227
+ function tell(ctx: ExtensionContext, text: string, level: 'info' | 'warning' | 'error'): void {
228
+ ctx.ui.notify(text, level)
229
+ if (!ctx.hasUI) process.stderr.write(`${text}\n`)
230
+ }
231
+
232
+ function currentSummary(ctx: SessionReader): GoalSummary | undefined {
233
+ if (!goal) return undefined
234
+ return { condition: goal.condition, durationMs: Date.now() - goal.setAt, iterations: goal.iterations, tokens: sessionTokens(ctx) - goal.tokensAtStart + goal.evaluatorTokens, lastReason: goal.lastReason }
235
+ }
236
+
237
+ function showIndicator(ctx: ExtensionContext): void {
238
+ if (!goal) return
239
+ ctx.ui.setStatus('goal', ctx.ui.theme.fg('accent', `◎ goal ${formatDuration(Date.now() - goal.setAt)}`))
240
+ if (!indicatorTimer) {
241
+ indicatorTimer = setInterval(() => {
242
+ if (sessionCtx) showIndicator(sessionCtx)
243
+ }, INDICATOR_REFRESH_MS)
244
+ indicatorTimer.unref?.()
245
+ }
246
+ }
247
+
248
+ function stopIndicator(ctx: ExtensionContext): void {
249
+ clearInterval(indicatorTimer)
250
+ indicatorTimer = undefined
251
+ ctx.ui.setStatus('goal', undefined)
252
+ }
253
+
254
+ function stopCheckin(): void {
255
+ clearTimeout(checkinTimer)
256
+ checkinTimer = undefined
257
+ }
258
+
259
+ function endDeferral(): void {
260
+ deferredSince = undefined
261
+ checkinDue = false
262
+ stopCheckin()
263
+ }
264
+
265
+ /** Make `condition` the active goal with fresh counters; the entry is the caller's. */
266
+ function activate(ctx: ExtensionContext, condition: string): void {
267
+ goal = { condition, setAt: Date.now(), iterations: 0, tokensAtStart: sessionTokens(ctx), evaluatorTokens: 0 }
268
+ generation += 1
269
+ noProgressStreak = 0
270
+ checkinsDelivered = 0
271
+ idleCheckins = 0
272
+ endDeferral()
273
+ showIndicator(ctx)
274
+ }
275
+
276
+ /** Drop the active goal, recording how it ended; returns its condition. */
277
+ function endGoal(ctx: ExtensionContext, state: Exclude<GoalState, 'active'>): string | undefined {
278
+ const ended = goal
279
+ if (!ended) return undefined
280
+ goal = undefined
281
+ generation += 1
282
+ endDeferral()
283
+ stopIndicator(ctx)
284
+ pi.appendEntry(GOAL_ENTRY, { state, condition: ended.condition } satisfies GoalEntry)
285
+ return ended.condition
286
+ }
287
+
288
+ function finish(ctx: ExtensionContext, state: 'achieved' | 'failed', reason: string): void {
289
+ const summary = currentSummary(ctx)
290
+ if (!summary) return
291
+ if (state === 'achieved') achieved = summary
292
+ endGoal(ctx, state)
293
+ const head = state === 'achieved' ? `Goal achieved (${summaryText(summary)}): ${summary.condition}` : `Goal could not be achieved (${summaryText(summary)}): ${summary.condition}`
294
+ send(reason ? `${head}\nEvaluator: ${reason}` : head, { triggerTurn: false })
295
+ }
296
+
297
+ /** Claude feeds a not-met reason back as the next turn, under the Stop hooks' cap. */
298
+ function continueGoal(ctx: ExtensionContext, active: ActiveGoal, reason: string): void {
299
+ noProgressStreak = turnUsedTools ? 0 : noProgressStreak + 1
300
+ const cap = stopHookBlockCap()
301
+ showIndicator(ctx)
302
+ if (noProgressStreak >= cap) {
303
+ noProgressStreak = 0
304
+ tell(ctx, `Goal paused after ${cap} turns in a row without tool use; it stays set and evaluation resumes after your next prompt.`, 'warning')
305
+ return
306
+ }
307
+ const summary = currentSummary(ctx)
308
+ const spent = summary ? ` · ${formatDuration(summary.durationMs)} · ${formatTokens(summary.tokens)} tokens` : ''
309
+ send(`Goal not yet met (turn ${active.iterations}${spent}): ${reason}\nGoal: ${active.condition}`, { triggerTurn: true })
310
+ }
311
+
312
+ async function askEvaluator(ctx: ExtensionContext, active: ActiveGoal, model: NonNullable<ExtensionContext['model']>): Promise<GoalVerdict> {
313
+ const transcript = renderTranscript(branchMessages(ctx), transcriptBudget(model))
314
+ // Esc during the evaluation aborts the run's signal; the call must die with it, or
315
+ // a late verdict would queue the next goal turn into a run the user just stopped.
316
+ const deadline = AbortSignal.timeout(EVALUATOR_TIMEOUT_MS)
317
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline
318
+ // Claude runs its evaluator with thinking disabled: the verdict is mechanical.
319
+ // completeText requests no thinking level, which is the same for pi.
320
+ const { text, usage } = await completeText(model, evaluatorPrompt(transcript, active.condition), { system: EVALUATOR_SYSTEM, maxTokens: EVALUATOR_MAX_TOKENS, signal })
321
+ active.evaluatorTokens += usageTokens(usage as TokenUsage | undefined)
322
+ const verdict = parseVerdict(text)
323
+ if (!verdict) throw new Error(`unreadable verdict: ${text.slice(0, 200)}`)
324
+ return verdict
325
+ }
326
+
327
+ async function evaluate(ctx: ExtensionContext): Promise<void> {
328
+ const active = goal
329
+ if (!active) return
330
+ const startedGeneration = generation
331
+ const model = evaluatorModel(ctx)
332
+ if (!model) {
333
+ tell(ctx, 'Goal evaluator has no model to run on; the goal stays set.', 'warning')
334
+ return
335
+ }
336
+ let verdict: GoalVerdict
337
+ try {
338
+ verdict = await askEvaluator(ctx, active, model)
339
+ } catch (error) {
340
+ // A user interrupt is not an evaluator failure: the goal stays, nothing to say.
341
+ if (ctx.signal?.aborted) return
342
+ // No verdict is a hook error in Claude's terms: the turn ends and the goal stays.
343
+ if (generation === startedGeneration) tell(ctx, `Goal evaluator error: ${error instanceof Error ? error.message : String(error)}. The goal stays set; the next turn is evaluated again.`, 'warning')
344
+ return
345
+ }
346
+ // Interrupted, cleared, or replaced during the await: this verdict must not act.
347
+ if (ctx.signal?.aborted || generation !== startedGeneration) return
348
+ active.iterations += 1
349
+ active.lastReason = verdict.reason
350
+ if (verdict.ok) finish(ctx, 'achieved', verdict.reason)
351
+ else if (verdict.impossible) finish(ctx, 'failed', verdict.reason)
352
+ else continueGoal(ctx, active, verdict.reason)
353
+ }
354
+
355
+ function deliverCheckin(paused: boolean): void {
356
+ if (!goal) return
357
+ checkinDue = false
358
+ checkinsDelivered += 1
359
+ const work: RunningWork[] = [...running].map(([id, agentType]) => ({ id, agentType }))
360
+ send(checkinText(goal.condition, Date.now() - (deferredSince ?? Date.now()), work, paused), { triggerTurn: true })
361
+ }
362
+
363
+ function onCheckinDue(): void {
364
+ checkinTimer = undefined
365
+ if (!goal || !sessionCtx) return
366
+ // Claude delivers a due check-in at the next turn end when a turn is running, and
367
+ // only starts idle turns for it up to the per-prompt cap.
368
+ if (!sessionCtx.isIdle() || idleCheckins >= MAX_IDLE_CHECKINS) {
369
+ checkinDue = true
370
+ return
371
+ }
372
+ idleCheckins += 1
373
+ deliverCheckin(idleCheckins >= MAX_IDLE_CHECKINS)
374
+ }
375
+
376
+ function armCheckin(): void {
377
+ if (checkinTimer) return
378
+ const ms = checkinIntervalMs(process.env, checkinsDelivered)
379
+ if (ms <= 0) return
380
+ checkinTimer = setTimeout(onCheckinDue, ms)
381
+ checkinTimer.unref?.()
382
+ }
383
+
384
+ /** Background work keeps the goal waiting: no verdict this turn, a check-in later. */
385
+ function deferEvaluation(): void {
386
+ deferredSince ??= Date.now()
387
+ if (checkinDue) {
388
+ deliverCheckin(false)
389
+ return
390
+ }
391
+ armCheckin()
392
+ }
393
+
394
+ function statusText(ctx: SessionReader): string {
395
+ const summary = currentSummary(ctx)
396
+ if (summary) return formatActiveGoal(summary)
397
+ if (achieved) return formatAchievedGoal(achieved)
398
+ return NO_GOAL_TEXT
399
+ }
400
+
401
+ pi.registerCommand('goal', {
402
+ description: 'Set a goal the session keeps working toward until a separate check confirms it is met; /goal shows status, /goal clear stops',
403
+ handler: async (args, ctx) => {
404
+ sessionCtx = ctx
405
+ const condition = args.trim()
406
+ if (condition === '') {
407
+ tell(ctx, statusText(ctx), 'info')
408
+ return
409
+ }
410
+ if (isClearAlias(condition)) {
411
+ const cleared = endGoal(ctx, 'cleared')
412
+ if (cleared === undefined) tell(ctx, 'No goal set', 'info')
413
+ else send(`Goal cleared: ${cleared}`, { triggerTurn: false })
414
+ return
415
+ }
416
+ if (condition.length > GOAL_CONDITION_MAX_CHARS) {
417
+ tell(ctx, `Goal condition is limited to ${GOAL_CONDITION_MAX_CHARS} characters (got ${condition.length})`, 'error')
418
+ return
419
+ }
420
+ const blocked = goalUnavailable(ctx)
421
+ if (blocked) {
422
+ tell(ctx, blocked, 'error')
423
+ return
424
+ }
425
+ // A new goal replaces the current one, as Claude documents; the entry written here
426
+ // is the state a resume restores.
427
+ activate(ctx, condition)
428
+ pi.appendEntry(GOAL_ENTRY, { state: 'active', condition } satisfies GoalEntry)
429
+ tell(ctx, `Goal set: ${condition}`, 'info')
430
+ // Setting a goal starts a turn immediately with the condition as the directive; a
431
+ // send during streaming queues it as a follow-up turn.
432
+ send(kickoffPrompt(condition), ctx.isIdle() ? { triggerTurn: true } : { triggerTurn: true, deliverAs: 'followUp' })
433
+ // A headless run (`pi -p "/goal ..."`) exits as soon as the command returns, before
434
+ // the kickoff turn runs. pi marks the run active synchronously on the send, and the
435
+ // continuations queued at agent_end extend that run, so waiting here holds the
436
+ // process open until the goal resolves, as Claude's -p mode does. The TUI's main
437
+ // loop returns to the editor on its own, so it must not block on the loop.
438
+ if (!ctx.hasUI) await ctx.waitForIdle()
439
+ },
440
+ })
441
+
442
+ pi.events.on(SUBAGENT_CHANNEL, (data) => {
443
+ if (!isSubagentPhaseEvent(data)) return
444
+ if (data.phase === 'start') running.set(data.agentId, data.agentType)
445
+ else running.delete(data.agentId)
446
+ })
447
+
448
+ pi.on('session_start', (_event, ctx) => {
449
+ // One extension instance serves every session: drop the previous session's goal and
450
+ // timers before reading this session's persisted state.
451
+ sessionCtx = ctx
452
+ goal = undefined
453
+ achieved = undefined
454
+ generation += 1
455
+ turnUsedTools = false
456
+ noProgressStreak = 0
457
+ lastTurnError = undefined
458
+ checkinsDelivered = 0
459
+ idleCheckins = 0
460
+ endDeferral()
461
+ stopIndicator(ctx)
462
+ const entry = lastGoalEntry(ctx)
463
+ if (entry?.state !== 'active') return
464
+ // Claude restores a still-active goal on resume with its counters reset.
465
+ activate(ctx, entry.condition)
466
+ tell(ctx, `Goal restored: ${entry.condition}`, 'info')
467
+ })
468
+
469
+ pi.on('session_shutdown', () => {
470
+ stopCheckin()
471
+ clearInterval(indicatorTimer)
472
+ indicatorTimer = undefined
473
+ })
474
+
475
+ pi.on('agent_start', () => {
476
+ turnUsedTools = false
477
+ })
478
+
479
+ pi.on('tool_execution_start', () => {
480
+ turnUsedTools = true
481
+ })
482
+
483
+ pi.on('input', (event) => {
484
+ // Only genuine user input is progress: a goal continuation or a subagent prompt
485
+ // arrives with source 'extension'. Claude resets the block cap and the idle
486
+ // check-in allowance on the user's next prompt.
487
+ if (event.source === 'extension') return
488
+ noProgressStreak = 0
489
+ idleCheckins = 0
490
+ })
491
+
492
+ // On agent_end rather than agent_settled, for the reason hooks.ts gives: a peer
493
+ // extension can hold its agent_end handler on a dialog, which would starve a settle-
494
+ // based evaluation; and a continuation sent here is picked up as the run's own
495
+ // continuation rather than a fresh prompt.
496
+ pi.on('agent_end', async (event, ctx) => {
497
+ sessionCtx = ctx
498
+ const last = lastAssistant(event.messages)
499
+ lastTurnError = last?.stopReason === 'error' ? (last.errorMessage ?? 'unknown error') : undefined
500
+ // Claude runs no Stop evaluation after a user interrupt; a failed turn is judged once
501
+ // the run settles, so neither is evaluated here and the goal stays set.
502
+ if (!goal || last?.stopReason === 'aborted' || lastTurnError !== undefined) return
503
+ if (running.size > 0) {
504
+ deferEvaluation()
505
+ return
506
+ }
507
+ endDeferral()
508
+ await evaluate(ctx)
509
+ })
510
+
511
+ pi.on('agent_settled', (_event, ctx) => {
512
+ const message = lastTurnError
513
+ lastTurnError = undefined
514
+ if (!goal || message === undefined) return
515
+ const kind = classifyUnrecoverable(message)
516
+ if (!kind) return
517
+ endGoal(ctx, 'cleared')
518
+ const text = `Goal cleared after an unrecoverable error (${kind}): "${message}". Run /goal again to continue.`
519
+ send(text, { triggerTurn: false })
520
+ tell(ctx, text, 'warning')
521
+ })
522
+ }
@@ -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
@@ -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
+ }
@@ -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
package/extensions/web.ts CHANGED
@@ -13,7 +13,7 @@ import type { Usage } from '@earendil-works/pi-ai'
13
13
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
14
14
  import { Type } from 'typebox'
15
15
 
16
- import { htmlToMarkdown } from './internal/html-markdown.js'
16
+ import { htmlToMarkdown, removeTags } from './internal/html-markdown.js'
17
17
  import { completeText } from './internal/model-complete.js'
18
18
  import { capForContext } from './internal/output-guard.js'
19
19
  import { httpFetch } from './internal/web-transport.js'
@@ -40,11 +40,7 @@ export function decodeEntities(text: string): string {
40
40
  }
41
41
 
42
42
  export function stripTags(html: string): string {
43
- // [^<>] rather than [^>]: excluding `<` bounds a failed match at the next tag start
44
- // instead of rescanning to end of input, which is what makes the strip linear.
45
- return decodeEntities(html.replace(/<[^<>]*>/g, ''))
46
- .replace(/\s+/g, ' ')
47
- .trim()
43
+ return decodeEntities(removeTags(html)).replace(/\s+/g, ' ').trim()
48
44
  }
49
45
 
50
46
  /** Resolve DuckDuckGo's redirect links (/l/?uddg=<encoded>) to the target URL. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.47",
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",
3
+ "version": "1.0.48",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",