@tduyng/pi-prewalk 0.0.0-stage → 0.0.1

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/src/index.ts ADDED
@@ -0,0 +1,925 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ModelRoute,
5
+ ModelRouteRequest,
6
+ ToolDefinition
7
+ } from '@earendil-works/pi-coding-agent'
8
+ import {
9
+ contextForModel,
10
+ continuationNudge,
11
+ ESCALATION_BAR,
12
+ makeSpecPredicate,
13
+ modelRef,
14
+ parseArgs,
15
+ planProgress,
16
+ PLAN_DEMAND_REASON,
17
+ PLANNING_BRIEF,
18
+ PLAN_TOOL_NAME,
19
+ READ_ONLY_TOOLS,
20
+ resolveModelSpec,
21
+ shortName,
22
+ STATE_TYPE,
23
+ statusBadge,
24
+ statusLabel,
25
+ stepForCommand,
26
+ stepForPath,
27
+ swapDecision,
28
+ MAX_PLAN_FACTS,
29
+ MAX_PLAN_STEPS,
30
+ MAX_WALL_CHARS,
31
+ NUDGE_PREFIX,
32
+ SKILL_BLOCK_PREFIX,
33
+ shouldAsk,
34
+ isScratchPath,
35
+ PLANNER_GRACE_CALLS,
36
+ renderHandoff,
37
+ reviewPrompt,
38
+ wallBrief,
39
+ type ForceRoute,
40
+ type PrewalkState
41
+ } from './logic.js'
42
+ import { bashWritesFiles } from './bash-writes.js'
43
+ import { readEffectivePrewalkConfig, writePrewalkConfig, type PrewalkConfig, type PrewalkMode } from './config.js'
44
+
45
+ const MODE_LABEL: Record<PrewalkMode, string> = {
46
+ plan: 'every task plans',
47
+ off: 'the cheap model, no plan'
48
+ }
49
+
50
+ /** One decision: the model the task starts on. */
51
+ type Routed = { route: ForceRoute }
52
+
53
+ const ROUTER_PROVIDER = 'prewalk'
54
+ const ROUTER_ID = 'auto'
55
+ const ROUTER_REF = `${ROUTER_PROVIDER}/${ROUTER_ID}`
56
+
57
+ import { type Infer, Validator } from 'ata-validator'
58
+ import { t } from 'ata-validator/t'
59
+
60
+ type SessionModel = NonNullable<ExtensionContext['model']>
61
+
62
+ /**
63
+ * pi's api id for a virtual catalog entry. pi keeps that constant and its guard private, so a chain
64
+ * naming `prewalk/auto` would otherwise resolve and route() would return a virtual model as its
65
+ * target, which pi rejects with "which is not a physical model".
66
+ */
67
+ const VIRTUAL_API = 'pi-virtual'
68
+
69
+ /** The catalog prewalk routes to: everything except the virtual entries, which pi cannot dispatch to. */
70
+ function physicalModels(ctx: ExtensionContext): readonly SessionModel[] {
71
+ return ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API)
72
+ }
73
+
74
+ /**
75
+ * A chain that resolves to nothing is a configuration problem, so prewalk stops routing rather than
76
+ * ending the turn. The model already in play wins.
77
+ */
78
+ function heldModel(
79
+ ctx: ExtensionContext,
80
+ inPlay: ModelRouteRequest['previous'],
81
+ reason: string,
82
+ level: ModelRouteRequest['thinkingLevel']
83
+ ): ModelRoute {
84
+ ctx.ui.notify(`${reason}; this task runs on the model already in play`, 'error')
85
+ const held = inPlay?.model ?? physicalModels(ctx)[0]
86
+ if (!held) throw new Error('prewalk: no model is available to hold the task on')
87
+ return { model: held, thinkingLevel: inPlay?.thinkingLevel ?? level }
88
+ }
89
+
90
+ const PLAN_STEP_SCHEMA = t.object({
91
+ file: t.string({ maxLength: 4_000 }),
92
+ change: t.string({ maxLength: 8_000 }),
93
+ anchor: t.optional(t.string({ maxLength: 8_000 })),
94
+ verify: t.optional(t.string({ maxLength: 2_000 }))
95
+ })
96
+
97
+ /** The shape a `prewalk` session entry holds, so the read that restores it is bounded. */
98
+ const STORED_ENTRY_SCHEMA = t.object({
99
+ executor: t.optional(t.string()),
100
+ planner: t.optional(t.string()),
101
+ swapped: t.optional(t.boolean()),
102
+ inFlight: t.optional(t.boolean()),
103
+ reviewing: t.optional(t.boolean()),
104
+ planDemanded: t.optional(t.boolean()),
105
+ forced: t.optional(t.union([t.literal('plan'), t.literal('run')])),
106
+ stepIndex: t.optional(t.integer({ minimum: 0 })),
107
+ failures: t.optional(t.integer({ minimum: 0 })),
108
+ escalations: t.optional(t.integer({ minimum: 0 })),
109
+ wall: t.optional(t.union([t.string({ maxLength: MAX_WALL_CHARS }), t.null()])),
110
+ plan: t.optional(
111
+ t.object({
112
+ summary: t.string({ maxLength: 8_000 }),
113
+ facts: t.array(t.string(), { maxItems: MAX_PLAN_FACTS }),
114
+ steps: t.array(PLAN_STEP_SCHEMA, { maxItems: MAX_PLAN_STEPS }),
115
+ check: t.string({ maxLength: 2_000 }),
116
+ outOfScope: t.array(t.string(), { maxItems: MAX_PLAN_FACTS })
117
+ })
118
+ )
119
+ })
120
+ const isStoredEntrySchema = new Validator(STORED_ENTRY_SCHEMA).isValidObject
121
+
122
+ const TOOL_INPUT_SCHEMA = t.object({
123
+ path: t.optional(t.string()),
124
+ file: t.optional(t.string()),
125
+ command: t.optional(t.string())
126
+ })
127
+ const isToolInputSchema = new Validator(TOOL_INPUT_SCHEMA).isValidObject
128
+
129
+ const PLAN_PARAMETERS = t.object({
130
+ summary: t.string({ description: 'One-line description of what the plan accomplishes.' }),
131
+ facts: t.array(t.string(), {
132
+ maxItems: MAX_PLAN_FACTS,
133
+ description:
134
+ 'Established during exploration: findings the executor cannot re-derive (file:line, constraints, commands). Each string is one fact.'
135
+ }),
136
+ steps: t.array(
137
+ t.object({
138
+ file: t.string({ description: 'Repo-relative file path, e.g. src/index.ts.' }),
139
+ change: t.string({ description: 'What to change in this file.' }),
140
+ anchor: t.optional(
141
+ t.string({
142
+ description:
143
+ 'Exact text to match for the edit. Prevents the executor from rewriting the wrong site.'
144
+ })
145
+ ),
146
+ verify: t.optional(
147
+ t.string({
148
+ description: 'Command to run after this step, e.g. pnpm vitest run src/foo.test.ts.'
149
+ })
150
+ )
151
+ }),
152
+ { maxItems: MAX_PLAN_STEPS, description: `Steps in order. No more than ${MAX_PLAN_STEPS}.` }
153
+ ),
154
+ check: t.string({ description: 'Command to run after every step is done, e.g. pnpm check.' }),
155
+ outOfScope: t.array(t.string(), {
156
+ maxItems: MAX_PLAN_FACTS,
157
+ description: 'Files or areas the executor must not touch.'
158
+ })
159
+ })
160
+ const isPlanParameters = new Validator(PLAN_PARAMETERS).isValidObject
161
+ type PlanParams = Infer<typeof PLAN_PARAMETERS>
162
+
163
+ type PlanToolDefinition = ToolDefinition<typeof PLAN_PARAMETERS> & { defaultActive?: boolean }
164
+
165
+ export default function prewalkExtension(pi: ExtensionAPI): void {
166
+ let state: PrewalkState | null = null
167
+ let config: PrewalkConfig = readEffectivePrewalkConfig()
168
+ let stallNudged = false
169
+ let completionNudged = false
170
+ let reviewSpent = false
171
+ let postPlanCalls: number | null = null
172
+ let demandedThisTurn = false
173
+ let models: readonly SessionModel[] = []
174
+
175
+ /**
176
+ * The prompt that opened a task, set only by a prompt typed while the session was idle, or by
177
+ * `/prewalk` with a task. `route()` consumes it, so one prompt routes once. pi's
178
+ * `reason: "user"` cannot carry this: it also fires for a steer and a follow-up, which are
179
+ * turns inside the task already in flight.
180
+ */
181
+ let prompt: string | null = null
182
+ let steer: string | null = null
183
+
184
+ /**
185
+ * A prompt too short to be a task is not a task boundary. It holds whatever is in play rather
186
+ * than opening a task on the cheap tier with nothing planned to execute.
187
+ */
188
+ function recordPrompt(text: string): void {
189
+ if (shouldAsk(text)) prompt = text
190
+ }
191
+
192
+ function persist(): void {
193
+ pi.appendEntry(STATE_TYPE, state)
194
+ }
195
+
196
+ function updateStatus(ctx: ExtensionContext): void {
197
+ if (!ctx.hasUI) return
198
+ if (!state) {
199
+ ctx.ui.setStatus('prewalk', undefined)
200
+ return
201
+ }
202
+ ctx.ui.setStatus('prewalk', ctx.ui.theme.fg('accent', statusBadge(state)))
203
+ }
204
+
205
+ function chainModel(ctx: ExtensionContext, chain: readonly string[]): SessionModel | null {
206
+ for (const spec of chain) {
207
+ const result = resolveModelSpec(physicalModels(ctx), spec)
208
+ if ('error' in result) continue
209
+ return result.model
210
+ }
211
+ return null
212
+ }
213
+
214
+ function executorModel(ctx: ExtensionContext): SessionModel | null {
215
+ return chainModel(ctx, config.executor)
216
+ }
217
+
218
+ async function reconstructState(ctx: ExtensionContext): Promise<void> {
219
+ state = null
220
+ for (const entry of ctx.sessionManager.getBranch()) {
221
+ if (entry.type !== 'custom' || entry.customType !== STATE_TYPE) continue
222
+ const data: unknown = entry.data
223
+ if (!isStoredEntrySchema(data)) {
224
+ state = null
225
+ continue
226
+ }
227
+ const plan = data.plan ?? null
228
+ state = {
229
+ executor: data.executor ?? '',
230
+ planner: data.planner ?? null,
231
+ swapped: data.swapped === true && plan !== null,
232
+ inFlight: data.inFlight === true,
233
+ plan,
234
+ planDemanded: data.planDemanded === true,
235
+ forced: data.forced ?? null,
236
+ stepIndex: data.stepIndex ?? 0,
237
+ failures: data.failures ?? 0,
238
+ escalations: data.escalations ?? 0,
239
+ wall: data.wall ?? null,
240
+ reviewing: data.reviewing === true
241
+ }
242
+ }
243
+ models = physicalModels(ctx)
244
+ config = readEffectivePrewalkConfig()
245
+ prompt = null
246
+ steer = null
247
+ stallNudged = false
248
+ completionNudged = false
249
+ reviewSpent = false
250
+ postPlanCalls = null
251
+ updateStatus(ctx)
252
+ await claimRouter(ctx, ctx.model)
253
+ }
254
+
255
+ function findModel(ctx: ExtensionContext, spec: string): SessionModel | null {
256
+ const result = resolveModelSpec(physicalModels(ctx), spec)
257
+ return 'error' in result ? null : result.model
258
+ }
259
+
260
+ function resolveModel(ctx: ExtensionContext, spec: string): SessionModel | null {
261
+ const result = resolveModelSpec(physicalModels(ctx), spec)
262
+ if ('error' in result) {
263
+ ctx.ui.notify(`prewalk: ${result.error}`, 'error')
264
+ return null
265
+ }
266
+ return result.model
267
+ }
268
+
269
+ /**
270
+ * Every task routes through the router, so a session model that is not the router is a
271
+ * session where nothing routes, for the rest of its life, and it happens without a word:
272
+ * one press of the model cycle key leaves prewalk silent. Armed means prewalk owns the
273
+ * selection, so it takes it back and says so. `/prewalk off` is how a physical model is
274
+ * kept, and it is the mode that asks nothing and routes nothing.
275
+ */
276
+ async function claimRouter(ctx: ExtensionContext, selected: SessionModel | undefined): Promise<void> {
277
+ if (state === null || config.mode === 'off' || selected === undefined) return
278
+ if (selected.provider === ROUTER_PROVIDER && selected.id === ROUTER_ID) return
279
+ if (!(await selectRouter(ctx))) return
280
+ ctx.ui.notify(
281
+ `prewalk: ${modelRef(selected)} is not ${ROUTER_REF}, so no task would route. Back on it; /prewalk off keeps the model.`,
282
+ 'warning'
283
+ )
284
+ }
285
+
286
+ /**
287
+ * The session's model is the router, so nothing has to be remembered about the swap. Arming only
288
+ * has to name the two chains and select the router.
289
+ */
290
+ async function selectRouter(ctx: ExtensionContext): Promise<boolean> {
291
+ const router = ctx.modelRegistry.find(ROUTER_PROVIDER, ROUTER_ID)
292
+ if (!router) {
293
+ ctx.ui.notify(`prewalk: ${ROUTER_REF} is not registered`, 'error')
294
+ return false
295
+ }
296
+ return pi.setModel(router)
297
+ }
298
+
299
+ function ensureState(ctx: ExtensionContext): PrewalkState | null {
300
+ if (state) return state
301
+ const executor = executorModel(ctx)
302
+ if (!executor) return null
303
+ const planner = chainModel(ctx, config.planner)
304
+ state = {
305
+ executor: modelRef(executor),
306
+ planner: planner ? modelRef(planner) : null,
307
+ swapped: false,
308
+ inFlight: false,
309
+ forced: null,
310
+ plan: null,
311
+ planDemanded: false,
312
+ stepIndex: 0,
313
+ failures: 0,
314
+ escalations: 0,
315
+ wall: null,
316
+ reviewing: false
317
+ }
318
+ persist()
319
+ updateStatus(ctx)
320
+ setPlanTool()
321
+ return state
322
+ }
323
+
324
+ function disarm(ctx: ExtensionContext): void {
325
+ state = null
326
+ prompt = null
327
+ persist()
328
+ updateStatus(ctx)
329
+ }
330
+
331
+ function startTask(ctx: ExtensionContext, route_: ForceRoute): void {
332
+ if (!state) return
333
+ // A steer typed at the end of a task belongs to that task, not to the next one.
334
+ steer = null
335
+ reviewSpent = false
336
+ postPlanCalls = null
337
+ state = {
338
+ ...state,
339
+ swapped: route_ !== 'plan',
340
+ inFlight: true,
341
+ plan: null,
342
+ planDemanded: route_ !== 'plan',
343
+ stepIndex: 0,
344
+ failures: 0,
345
+ escalations: 0,
346
+ wall: null,
347
+ reviewing: false
348
+ }
349
+ persist()
350
+ updateStatus(ctx)
351
+ setPlanTool()
352
+ }
353
+
354
+ /** The executor takes over on the next request, which `route()` reads off `swapped`. */
355
+ function markSwapped(ctx: ExtensionContext): void {
356
+ if (!state || state.swapped) return
357
+ postPlanCalls = null
358
+ state = { ...state, swapped: true, failures: 0, wall: null }
359
+ persist()
360
+ updateStatus(ctx)
361
+ }
362
+
363
+ function startReview(ctx: ExtensionContext): void {
364
+ if (!state?.plan) return
365
+ reviewSpent = true
366
+ const text = reviewPrompt(state.plan, state.stepIndex)
367
+ state = { ...state, swapped: false, reviewing: true, inFlight: true, failures: 0, wall: null }
368
+ persist()
369
+ updateStatus(ctx)
370
+ setPlanTool()
371
+ pi.sendUserMessage(text, { deliverAs: 'steer' })
372
+ }
373
+
374
+ async function arm(
375
+ ctx: ExtensionContext,
376
+ args: { planner: string | null; executor: string | null; task: string; forced?: ForceRoute }
377
+ ): Promise<void> {
378
+ const executorSpec = args.executor ?? config.executor[0] ?? state?.executor
379
+ if (!executorSpec) {
380
+ ctx.ui.notify(
381
+ 'prewalk: no executor named yet.\n\nUsage: /prewalk executor <model> [more fallbacks], or /prewalk <planner> <executor> [task]',
382
+ 'error'
383
+ )
384
+ return
385
+ }
386
+ const executor = resolveModel(ctx, executorSpec)
387
+ if (!executor) return
388
+ const executorRef = modelRef(executor)
389
+
390
+ const plannerSpec = args.planner ?? config.planner[0] ?? state?.planner
391
+ if (!plannerSpec) {
392
+ ctx.ui.notify('prewalk: no planner named yet.\n\nUsage: /prewalk planner <model> [more fallbacks]', 'error')
393
+ return
394
+ }
395
+ const planner = resolveModel(ctx, plannerSpec)
396
+ if (!planner) return
397
+ const plannerRef = modelRef(planner)
398
+ if (plannerRef === executorRef) {
399
+ ctx.ui.notify(
400
+ `prewalk: planner and executor are both ${shortName(executorRef)}; name a different planner`,
401
+ 'error'
402
+ )
403
+ return
404
+ }
405
+ state = {
406
+ executor: executorRef,
407
+ planner: plannerRef,
408
+ swapped: false,
409
+ inFlight: false,
410
+ forced: args.forced ?? null,
411
+ plan: null,
412
+ planDemanded: false,
413
+ stepIndex: 0,
414
+ failures: 0,
415
+ escalations: 0,
416
+ wall: null,
417
+ reviewing: false
418
+ }
419
+ // A named role replaces its chain, a bare re-arm keeps the fallbacks the file already holds.
420
+ if (args.planner !== null || args.executor !== null) {
421
+ config = {
422
+ ...config,
423
+ planner: args.planner === null ? config.planner : [plannerRef],
424
+ executor: args.executor === null ? config.executor : [executorRef]
425
+ }
426
+ writePrewalkConfig(config)
427
+ }
428
+ persist()
429
+ updateStatus(ctx)
430
+ setPlanTool()
431
+ if (!(await selectRouter(ctx))) return
432
+
433
+ if (args.task) {
434
+ recordPrompt(args.task)
435
+ pi.sendUserMessage(args.task, ctx.isIdle() ? undefined : { deliverAs: 'followUp' })
436
+ ctx.ui.notify(
437
+ `prewalk: task started on ${shortName(args.forced === 'run' ? executorRef : plannerRef)}`,
438
+ 'info'
439
+ )
440
+ return
441
+ }
442
+
443
+ if (args.forced) {
444
+ const on = args.forced === 'run' ? shortName(executorRef) : shortName(plannerRef)
445
+ ctx.ui.notify(`prewalk: the next task ${args.forced === 'run' ? 'runs on' : 'plans on'} ${on}`, 'info')
446
+ return
447
+ }
448
+
449
+ ctx.ui.notify(
450
+ `prewalk: ${MODE_LABEL[config.mode]}. Plan on ${shortName(plannerRef)}, run on ${shortName(executorRef)}. /prewalk off stops it`,
451
+ 'info'
452
+ )
453
+ }
454
+
455
+ function chainReport(ctx: ExtensionContext, role: 'planner' | 'executor', chain: readonly string[]): string {
456
+ const lines = chain.map((spec) => {
457
+ const model = findModel(ctx, spec)
458
+ const mark = model === null ? 'unavailable' : modelRef(model) === ROUTER_REF ? 'router' : 'ready'
459
+ return ` ${spec} ${mark} ${model === null ? '' : priceLine(model)}`
460
+ })
461
+ return `${role}: ${chain.length === 0 ? '(none named)' : ''}\n${lines.join('\n')}`
462
+ }
463
+
464
+ /** Both chains with what resolves and what it costs, and the mode, which is what `/prewalk models` is for. */
465
+ function configReport(ctx: ExtensionContext): string {
466
+ const chains = `${chainReport(ctx, 'planner', config.planner)}\n${chainReport(ctx, 'executor', config.executor)}`
467
+ return `${chains}\nmode: ${MODE_LABEL[config.mode]}`
468
+ }
469
+
470
+ function setPlanTool(): void {
471
+ const enabled = state !== null && config.mode === 'plan'
472
+ const active = pi.getActiveTools()
473
+ if (active.length === 0) return
474
+ if (active.includes(PLAN_TOOL_NAME) === enabled) return
475
+ pi.setActiveTools(enabled ? [...active, PLAN_TOOL_NAME] : active.filter((name) => name !== PLAN_TOOL_NAME))
476
+ }
477
+
478
+ /**
479
+ * Nothing is in play: no task, no state, and no model on the request. Work that has not been
480
+ * planned belongs to the planner, so a session with neither starts there rather than on the
481
+ * cheap tier.
482
+ */
483
+ function coldStart(ctx: ExtensionContext): SessionModel {
484
+ const planned = chainModel(ctx, config.planner)
485
+ if (planned) return planned
486
+ const first = physicalModels(ctx)[0]
487
+ if (!first) throw new Error('prewalk: no planner in the chain resolves, and no model is available')
488
+ return first
489
+ }
490
+
491
+ /**
492
+ * One decision, and the phase answers it: a task plans and the cheap tier runs the steps of
493
+ * the plan it was given. The score that used to pick between the two read 0.548 AUC against
494
+ * how long a turn ran, so it did not separate, and it is gone.
495
+ */
496
+ function decideRoute(): Routed {
497
+ const forced = state?.forced ?? null
498
+ if (forced !== null && state) state = { ...state, forced: null }
499
+ if (forced !== null) return { route: forced }
500
+ if (config.mode === 'off') return { route: 'run' }
501
+ return { route: 'plan' }
502
+ }
503
+
504
+ /**
505
+ * A recorded wall is the executor saying it cannot finish this alone, which is the one place
506
+ * where a stuck cheap model hands the task back without asking. One failed tool result is the
507
+ * evidence, and the task buys two upgrades, because each swap re-reads the whole prefix at the
508
+ * new model's rate.
509
+ */
510
+ async function escalationRoute(
511
+ ctx: ExtensionContext,
512
+ inPlay: ModelRouteRequest['previous'],
513
+ level: ModelRouteRequest['thinkingLevel']
514
+ ): Promise<ModelRoute | null> {
515
+ if (config.mode !== 'plan' || state === null) return null
516
+ if (state.swapped === false || state.wall === null) return null
517
+ if (state.escalations >= ESCALATION_BAR.upgrades) return null
518
+ const planned = chainModel(ctx, config.planner)
519
+ if (!planned)
520
+ return heldModel(ctx, inPlay, 'prewalk: no planner in the chain resolves, or has an API key', level)
521
+ state = {
522
+ ...state,
523
+ planner: modelRef(planned),
524
+ swapped: false,
525
+ escalations: state.escalations + 1,
526
+ planDemanded: true
527
+ }
528
+ persist()
529
+ updateStatus(ctx)
530
+ setPlanTool()
531
+ ctx.ui.notify(
532
+ `prewalk: the executor is stuck; this task is on ${shortName(modelRef(planned))} until its next edit`,
533
+ 'info'
534
+ )
535
+ return { model: planned, thinkingLevel: level }
536
+ }
537
+
538
+ /**
539
+ * A question typed while the executor is mid-task wants the frontier model, and pi can carry
540
+ * it under any request reason, so this runs whatever the reason says instead of only reading
541
+ * continuations: the task goes back to the planner until its next edit hands it over again.
542
+ * The steer is spent the first time it is read, whether or not the planner is already holding
543
+ * the question, so it cannot fire on a later swap. No routing call: the phase answers it.
544
+ */
545
+ async function steerRoute(
546
+ ctx: ExtensionContext,
547
+ executor: SessionModel | null,
548
+ level: ModelRouteRequest['thinkingLevel']
549
+ ): Promise<ModelRoute | null> {
550
+ if (steer === null) return null
551
+ const text = steer
552
+ steer = null
553
+ if (executor === null || state === null) return null
554
+ if (!shouldAsk(text)) return null
555
+ const planned = chainModel(ctx, config.planner)
556
+ if (!planned) return null
557
+ state = { ...state, planner: modelRef(planned), swapped: false }
558
+ persist()
559
+ updateStatus(ctx)
560
+ setPlanTool()
561
+ ctx.ui.notify(`prewalk: that question is on ${shortName(modelRef(planned))} until the next edit`, 'info')
562
+ return { model: planned, thinkingLevel: level }
563
+ }
564
+
565
+ /** What a request costs and the context it has to work in, the two columns a role choice turns on. */
566
+ function priceLine(model: SessionModel): string {
567
+ const price = model.cost.input === 0 ? 'free' : `$${model.cost.input}/M in`
568
+ return `${Math.round(model.contextWindow / 1000)}k ctx ${price}`
569
+ }
570
+
571
+ /**
572
+ * pi has no model picker an extension can open, so the dialog carries those columns. Sorted by
573
+ * price, since the whole point of the executor is what it costs.
574
+ */
575
+ function modelChoices(ctx: ExtensionContext): { label: string; ref: string }[] {
576
+ return physicalModels(ctx)
577
+ .toSorted((a, b) => a.cost.input - b.cost.input)
578
+ .map((model) => ({ label: `${modelRef(model)} ${priceLine(model)}`, ref: modelRef(model) }))
579
+ }
580
+
581
+ pi.registerVirtualModel({
582
+ provider: ROUTER_PROVIDER,
583
+ id: ROUTER_ID,
584
+ name: 'Prewalk',
585
+ thinkingLevels: ['off', 'low', 'medium', 'high'],
586
+ async route(request, ctx) {
587
+ const level = request.thinkingLevel
588
+ const inPlay = request.previous
589
+
590
+ if (request.reason === 'retry' && request.failed) {
591
+ return { model: request.failed.model, thinkingLevel: request.failed.thinkingLevel ?? level }
592
+ }
593
+
594
+ const executor = ensureState(ctx)?.swapped === true ? executorModel(ctx) : null
595
+ if (request.reason === 'continuation') {
596
+ const escalated = await escalationRoute(ctx, inPlay, level)
597
+ if (escalated) return escalated
598
+ }
599
+
600
+ if (state?.reviewing === true && (prompt === null || request.reason !== 'user')) {
601
+ const reviewer = chainModel(ctx, config.planner)
602
+ if (reviewer) return { model: reviewer, thinkingLevel: level }
603
+ }
604
+ if (prompt === null || request.reason !== 'user') {
605
+ const asked = await steerRoute(ctx, executor, level)
606
+ if (asked) return asked
607
+ if (request.reason !== 'user' && executor) return { model: executor, thinkingLevel: level }
608
+ if (inPlay) return { model: inPlay.model, thinkingLevel: inPlay.thinkingLevel ?? level }
609
+ return { model: coldStart(ctx), thinkingLevel: level }
610
+ }
611
+ prompt = null
612
+
613
+ const decided = decideRoute()
614
+ startTask(ctx, decided.route)
615
+ if (!state) return { model: coldStart(ctx), thinkingLevel: level }
616
+ if (decided.route === 'run') {
617
+ const run = executorModel(ctx)
618
+ if (!run) {
619
+ return heldModel(
620
+ ctx,
621
+ inPlay,
622
+ 'prewalk: no executor in the chain resolves, or has an API key',
623
+ level
624
+ )
625
+ }
626
+ return { model: run, thinkingLevel: level }
627
+ }
628
+ const planned = chainModel(ctx, config.planner)
629
+ if (!planned) {
630
+ return heldModel(ctx, inPlay, 'prewalk: no planner in the chain resolves, or has an API key', level)
631
+ }
632
+ state = { ...state, planner: modelRef(planned) }
633
+ persist()
634
+ return { model: planned, thinkingLevel: level }
635
+ }
636
+ })
637
+
638
+ pi.on('before_agent_start', async () => setPlanTool())
639
+ pi.on('turn_start', async () => {
640
+ demandedThisTurn = false
641
+ })
642
+ pi.on('session_start', async (_event, ctx) => reconstructState(ctx))
643
+ pi.on('session_tree', async (_event, ctx) => reconstructState(ctx))
644
+ pi.on('model_select', async (event, ctx) => claimRouter(ctx, event.model))
645
+
646
+ pi.on('agent_end', async (_event, ctx) => {
647
+ if (!state || state.swapped || !state.plan || state.stepIndex > 0) return
648
+ markSwapped(ctx)
649
+ stallNudged = true
650
+ const first = state.plan.steps[0]
651
+ const nudge = first
652
+ ? `${NUDGE_PREFIX} Begin step 1: ${first.file}: ${first.change}. ${first.verify ? `Run \`${first.verify}\` to verify.` : ''}`
653
+ : `${NUDGE_PREFIX} Begin the plan.`
654
+ pi.sendMessage(
655
+ { customType: 'prewalk-stall-nudge', content: nudge, display: false, details: undefined },
656
+ { deliverAs: 'steer' }
657
+ )
658
+ })
659
+
660
+ pi.on('agent_settled', async (event, ctx) => {
661
+ if (!state) return
662
+ if (state.inFlight) {
663
+ state = { ...state, inFlight: false }
664
+ persist()
665
+ setPlanTool()
666
+ updateStatus(ctx)
667
+ }
668
+ if (event.aborted) return
669
+ if (!state.swapped || !state.plan || ctx.hasPendingMessages()) return
670
+ const done = state.stepIndex >= state.plan.steps.length
671
+ if (config.review && !reviewSpent && (done || stallNudged)) {
672
+ startReview(ctx)
673
+ return
674
+ }
675
+ if (completionNudged || stallNudged) return
676
+ if (done) completionNudged = true
677
+ stallNudged = true
678
+ pi.sendUserMessage(continuationNudge(state.plan, state.stepIndex), { deliverAs: 'steer' })
679
+ })
680
+
681
+ pi.on('input', async (event) => {
682
+ const text = event.text.trimStart()
683
+ if (text.startsWith('/') || text.startsWith(SKILL_BLOCK_PREFIX) || text.startsWith(NUDGE_PREFIX)) return
684
+ stallNudged = false
685
+ // A steer joins the task in flight and a follow-up waits for that turn to settle, so the
686
+ // steer never opens a task here and the follow-up routes as one when it arrives. Every
687
+ // other message opens a scored task, whichever extension sent it.
688
+ if (event.streamingBehavior === 'steer') {
689
+ steer = event.text
690
+ return
691
+ }
692
+ recordPrompt(event.text)
693
+ })
694
+
695
+ pi.on('tool_call', async (event, ctx) => {
696
+ if (!state) return
697
+ const input = isToolInputSchema(event.input) ? event.input : undefined
698
+ const inert =
699
+ event.toolName.toLowerCase() === 'bash'
700
+ ? !bashWritesFiles(input?.command ?? '', ctx.cwd)
701
+ : isScratchPath(input?.path ?? input?.file ?? '', ctx.cwd)
702
+ const decision = swapDecision(state, event.toolName, inert, demandedThisTurn)
703
+ if (decision === 'demand-plan') {
704
+ demandedThisTurn = true
705
+ state = { ...state, planDemanded: true }
706
+ persist()
707
+ return { block: true, reason: PLAN_DEMAND_REASON }
708
+ }
709
+ if (decision === 'swap') {
710
+ markSwapped(ctx)
711
+ return
712
+ }
713
+ if (postPlanCalls === null || state.swapped || state.reviewing || event.toolName === PLAN_TOOL_NAME) return
714
+ postPlanCalls += 1
715
+ if (postPlanCalls >= PLANNER_GRACE_CALLS) markSwapped(ctx)
716
+ })
717
+
718
+ pi.on('tool_call', async (event) => {
719
+ if (!state || event.toolName !== PLAN_TOOL_NAME || state.swapped) return
720
+ if (!isPlanParameters(event.input)) return
721
+ const plan = event.input
722
+ stallNudged = false
723
+ completionNudged = false
724
+ reviewSpent = false
725
+ postPlanCalls = 0
726
+ state = { ...state, plan, planDemanded: true, reviewing: false, stepIndex: 0, failures: 0, wall: null }
727
+ persist()
728
+ })
729
+
730
+ pi.on('tool_result', async (event, ctx) => {
731
+ if (!state?.swapped) return
732
+ const plan = state.plan
733
+ const blocks = Array.isArray(event.content)
734
+ ? event.content.map((b) => (b.type === 'text' ? (b.text ?? '') : ''))
735
+ : [String(event.content ?? '')]
736
+ const last = blocks[blocks.length - 1] ?? ''
737
+ if (event.isError) {
738
+ const failures = state.failures + 1
739
+ const wall = failures >= ESCALATION_BAR.failures ? last.trim().slice(0, MAX_WALL_CHARS) : state.wall
740
+ const changed = wall !== state.wall
741
+ state = { ...state, failures, wall }
742
+ if (changed) persist()
743
+ if (plan === null || failures !== ESCALATION_BAR.failures) return
744
+ if (state.escalations >= ESCALATION_BAR.upgrades || config.mode !== 'plan') return
745
+ blocks[blocks.length - 1] = `${last}\n${wallBrief(plan, state.stepIndex, failures)}`
746
+ return { content: [{ type: 'text', text: blocks.join('\n') }] }
747
+ }
748
+ if (state.failures > 0 || state.wall !== null) state = { ...state, failures: 0, wall: null }
749
+ if (plan === null) return
750
+ if (!isToolInputSchema(event.input)) return
751
+ const tool = event.toolName.toLowerCase()
752
+ const path = event.input.path ?? event.input.file ?? ''
753
+ const command = event.input.command ?? ''
754
+ const matched = READ_ONLY_TOOLS.has(tool)
755
+ ? -1
756
+ : path === ''
757
+ ? stepForCommand(plan, state.stepIndex, command)
758
+ : stepForPath(plan, state.stepIndex, path)
759
+ if (matched === -1) return
760
+ const { index, footer } = planProgress(plan, state.stepIndex, matched)
761
+ if (index !== state.stepIndex) {
762
+ state = { ...state, stepIndex: index, failures: 0, wall: null }
763
+ stallNudged = false
764
+ persist()
765
+ updateStatus(ctx)
766
+ }
767
+ blocks[blocks.length - 1] = `${last}\n${footer}`
768
+ return { content: [{ type: 'text', text: blocks.join('\n') }] }
769
+ })
770
+
771
+ pi.on('context', async (event) => {
772
+ if (!state) return
773
+ return { messages: contextForModel(state, event.messages) }
774
+ })
775
+
776
+ const planTool: PlanToolDefinition = {
777
+ name: PLAN_TOOL_NAME,
778
+ label: 'Commit Plan',
779
+ defaultActive: false,
780
+ description: PLANNING_BRIEF,
781
+ promptSnippet: 'Commit the implementation plan before your first edit',
782
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
783
+ exposure: 'deferred',
784
+ namespace: {
785
+ name: 'prewalk',
786
+ description:
787
+ 'Commit the implementation plan before your first edit, when a prewalk has the session planning.'
788
+ },
789
+ promptGuidelines: [
790
+ `While a prewalk has you planning, search for ${PLAN_TOOL_NAME} and call it exactly once, after you finish exploring and before your first edit.`,
791
+ 'The executor will not re-read what you read. Each step must carry enough detail to land the edit cold: the file, the exact anchor text to find, what changes, and the verify command.',
792
+ 'If a step requires a judgement the executor cannot make, resolve it now or split the step.'
793
+ ],
794
+ parameters: PLAN_PARAMETERS,
795
+ async execute(_toolCallId, params: PlanParams, _signal, _onUpdate, _ctx) {
796
+ const text = renderHandoff({
797
+ summary: params.summary,
798
+ facts: params.facts,
799
+ steps: params.steps,
800
+ check: params.check,
801
+ outOfScope: params.outOfScope
802
+ })
803
+ return {
804
+ content: [{ type: 'text', text }],
805
+ details: { summary: params.summary, steps: params.steps.length }
806
+ }
807
+ }
808
+ }
809
+ pi.registerTool(planTool)
810
+
811
+ pi.registerCommand('prewalk', {
812
+ description: 'Route each task to a planning model or a cheap one, by the phase or by hand',
813
+ getArgumentCompletions: (prefix: string) => {
814
+ const lastSpace = prefix.lastIndexOf(' ')
815
+ const head = lastSpace === -1 ? '' : prefix.slice(0, lastSpace + 1)
816
+ const p = prefix.slice(lastSpace + 1).toLowerCase()
817
+ const parsedHead = parseArgs(head, makeSpecPredicate(models))
818
+ if (parsedHead.kind !== 'bare' && parsedHead.kind !== 'start') return null
819
+ if (parsedHead.kind === 'start' && (parsedHead.task !== '' || parsedHead.planner)) return null
820
+ const items: { value: string; label: string; description?: string }[] = []
821
+ if (head === '') {
822
+ if (state?.executor) {
823
+ items.push({
824
+ value: state.executor,
825
+ label: `${shortName(state.executor)} (last used)`,
826
+ description: 're-arm with this executor'
827
+ })
828
+ }
829
+ for (const [value, description] of [
830
+ ['models', 'both chains, what resolves, what it costs'],
831
+ ['mode plan', 'plan each task, no routing call'],
832
+ ['mode off', 'the cheap model, no plan and no call'],
833
+ ['planner', 'set the planner chain'],
834
+ ['executor', 'set the executor chain'],
835
+ ['plan', 'next task plans, by hand'],
836
+ ['run', 'next task runs on the cheap model'],
837
+ ['off', 'disarm prewalk']
838
+ ] as const) {
839
+ items.push({ value, label: value, description })
840
+ }
841
+ }
842
+ const slot = parsedHead.kind === 'start' && parsedHead.executor ? 'executor' : 'planner or executor'
843
+ for (const m of models) {
844
+ items.push({ value: modelRef(m), label: m.id, description: `${m.provider} · ${slot}` })
845
+ }
846
+ const filtered = items.filter(
847
+ (item) => item.value.toLowerCase().includes(p) || item.label.toLowerCase().includes(p)
848
+ )
849
+ return filtered.length > 0 ? filtered.slice(0, 30).map((i) => ({ ...i, value: head + i.value })) : null
850
+ },
851
+ handler: async (args, ctx) => {
852
+ const parsed = parseArgs(args, makeSpecPredicate(physicalModels(ctx)))
853
+
854
+ if (parsed.kind === 'status') {
855
+ const report = configReport(ctx)
856
+ ctx.ui.notify(state ? `${statusLabel(state)}\n${report}` : `prewalk is not armed.\n${report}`, 'info')
857
+ return
858
+ }
859
+
860
+ if (parsed.kind === 'stop') {
861
+ if (!state) {
862
+ ctx.ui.notify('No prewalk active', 'info')
863
+ return
864
+ }
865
+ disarm(ctx)
866
+ ctx.ui.notify('Prewalk disarmed', 'info')
867
+ return
868
+ }
869
+
870
+ if (parsed.kind === 'models') {
871
+ ctx.ui.notify(configReport(ctx), 'info')
872
+ return
873
+ }
874
+
875
+ if (parsed.kind === 'mode') {
876
+ config = { ...config, mode: parsed.mode }
877
+ writePrewalkConfig(config)
878
+ ctx.ui.notify(`prewalk: ${MODE_LABEL[parsed.mode]}`, 'info')
879
+ return
880
+ }
881
+
882
+ if (parsed.kind === 'role') {
883
+ if (parsed.chain.length === 0) {
884
+ const choices = modelChoices(ctx)
885
+ const picked =
886
+ choices.length === 0
887
+ ? undefined
888
+ : await ctx.ui.select(
889
+ `prewalk: pick the ${parsed.role} model`,
890
+ choices.map((choice) => choice.label)
891
+ )
892
+ const choice = choices.find((candidate) => candidate.label === picked)
893
+ if (choice === undefined) return
894
+ parsed.chain.push(choice.ref)
895
+ }
896
+ config = { ...config, [parsed.role]: parsed.chain }
897
+ writePrewalkConfig(config)
898
+ // The badge reads the armed pair, so a chain that moves under a live session moves it too.
899
+ if (state !== null) {
900
+ const head = chainModel(ctx, parsed.chain)
901
+ state = { ...state, [parsed.role]: head ? modelRef(head) : parsed.chain[0] }
902
+ persist()
903
+ updateStatus(ctx)
904
+ }
905
+ ctx.ui.notify(`prewalk: ${parsed.role} chain is ${parsed.chain.map(shortName).join(' -> ')}`, 'info')
906
+ return
907
+ }
908
+
909
+ if (parsed.kind === 'force') {
910
+ await arm(ctx, { planner: null, executor: null, task: parsed.task, forced: parsed.route })
911
+ return
912
+ }
913
+
914
+ if (parsed.kind === 'bare') {
915
+ if (state) {
916
+ ctx.ui.notify(statusLabel(state), 'info')
917
+ return
918
+ }
919
+ await arm(ctx, { planner: null, executor: null, task: '' })
920
+ return
921
+ }
922
+ await arm(ctx, parsed)
923
+ }
924
+ })
925
+ }