@tduyng/pi-prewalk 0.0.0-stage → 0.0.2

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